diff --git a/.gitignore b/.gitignore index 08b52fa9..72d6256f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ research-*.md __pycache__/ .sdk-types-work/ *.egg-info/ + +# IDE-generated resolver lock for the gen/python package manifest; the pipeline pins +# its real dependencies in scripts/sdk-types/requirements-*.txt (hash-locked), and an +# untracked file under gen/ fails the generated-drift gate. +gen/python/uv.lock diff --git a/README.md b/README.md index 1d6a10a8..216a0fbf 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Built on [IAB Tech Lab CoMP v1.0](https://github.com/IABTechLab/CoMP) and [RSL 1 ``` proto/ Protocol buffer source — the wire format ramp/v1/ RAMP messages and services - ramp/admin/v1/ AdminService — the Exchange operator/config plane + ramp/admin/v1/ AdminService — the Exchange operator plane (configuration + forensics) comp/v1/ IAB CoMP v1.0 (1:1 mapping; included for reference) buf.yaml Buf module config diff --git a/conformance/bytes_wire_forms_test.go b/conformance/bytes_wire_forms_test.go new file mode 100644 index 00000000..9669f0a0 --- /dev/null +++ b/conformance/bytes_wire_forms_test.go @@ -0,0 +1,216 @@ +// Package conformance — bytes_wire_forms_test.go is the ORACLE half of the +// shared base64 truth table (testdata/bytes_wire_forms.json). +// +// The table says, for each bytes-rule field, which base64 wire forms are +// accepted. The Pydantic and Zod harnesses assert those verdicts against the +// generated schemas; this test asserts the SAME rows against Go — protojson's +// decoder plus protovalidate — which is what the generated patterns are supposed +// to mirror. Without it, the table would be a hand-written claim about Go's +// behavior on both client sides and nothing would check the claim: a row pinned +// from a wrong belief (that protojson accepts a mixed-alphabet string, say) would +// make both clients agree with each other and disagree with the server. +// +// It also keeps the shared bases honest: each one must be a VALID message, so it +// cannot drift into a shape where a "rejected" row is rejected for an unrelated +// reason. +package conformance + +import ( + "encoding/json" + "fmt" + "os" + "testing" + + "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" +) + +type bytesWireForm struct { + Value string `json:"value"` + Accepted bool `json:"accepted"` + Why string `json:"why"` +} + +type bytesWireVectors struct { + Bases map[string]map[string]any `json:"bases"` + FormSets map[string]struct { + Rule string `json:"rule"` + Forms []bytesWireForm `json:"forms"` + } `json:"form_sets"` + Fields []struct { + Message string `json:"message"` + Field string `json:"field"` + FormSet string `json:"form_set"` + } `json:"fields"` +} + +func loadBytesWireVectors(t *testing.T) bytesWireVectors { + t.Helper() + raw, err := os.ReadFile("testdata/bytes_wire_forms.json") + if err != nil { + t.Fatalf("read bytes wire-form vectors: %v", err) + } + var v bytesWireVectors + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode bytes wire-form vectors: %v", err) + } + if len(v.Fields) == 0 { + t.Fatal("bytes wire-form vectors carry no fields — the shared table is wired to nothing") + } + return v +} + +// parseBase applies one field override to a base and returns the message, plus +// whether protojson accepted the JSON at all (a malformed base64 string is +// rejected by the DECODER, before any rule runs). +func parseBase(t *testing.T, message string, base map[string]any, field string, value any) (proto.Message, bool) { + t.Helper() + obj := map[string]any{} + for k, v := range base { + obj[k] = v + } + if field != "" { + obj[field] = value + } + raw, err := json.Marshal(obj) + if err != nil { + t.Fatalf("marshal case json: %v", err) + } + mt, err := findContractMessage(message) + if err != nil { + t.Fatalf("resolve message %s: %v", message, err) + } + m := mt.New().Interface() + if err := protojson.Unmarshal(raw, m); err != nil { + return nil, false + } + return m, true +} + +// TestBytesWireFormCoverageIsComplete derives the table's scope from the +// DESCRIPTOR instead of trusting the table to list itself. +// +// The base64 axis — padded vs unpadded, standard vs url-safe, mixed alphabet, +// pure padding — is the one axis the generated corpus cannot reach, because +// corpusgen emits values through protojson and therefore only ever produces the +// canonical padded standard form. testdata/bytes_wire_forms.json is the ONLY +// coverage those wire forms have, in all three languages. +// +// That made the table's completeness load-bearing and unchecked. Adding a sixth +// bytes-length field tightens the generated Pydantic/Zod pattern automatically, +// so the new field looks covered — while zero wire-form rows exercise it and +// every gate stays green. The failure is silent in exactly the place a reviewer +// would assume coverage exists. +// +// So this fails in BOTH directions, the shape TestRuleIdenticalGroupsAreDeclared +// already uses: a ruled field with no table entry, and a table entry naming a +// field that no longer carries the rule. It also pins the form set to the rule's +// VALUE, so a bytes.len = 64 field cannot quietly point at the 32-byte set and +// collect verdicts computed for a different length. +func TestBytesWireFormCoverageIsComplete(t *testing.T) { + vectors := loadBytesWireVectors(t) + + // want: every bytes-length rule in the contract, keyed as the table keys it. + // Bare message names are safe as keys because AssertUniqueBareNames proves + // they are unique contract-wide. + want := map[string]string{} + EachRuleSet(func(md protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, prefix string, fr *validate.FieldRules) { + r := MustBytesLength(fd, fr) + if r == nil { + return + } + if prefix != "" { + // A bytes-length rule under repeated.items. (or map values) is real + // but the table cannot express it: an entry overrides ONE scalar + // field on a base message, and the JSON value here would be a list. + // Fail rather than skip — skipping is how a guard silently narrows. + t.Errorf("%s carries a bytes-length rule at %s, which the wire-form table cannot express; "+ + "extend the table format to override a repeated/map element before adding this rule", + fd.FullName(), prefix) + return + } + want[string(md.Name())+"."+string(fd.Name())] = fmt.Sprintf("bytes.%s = %d", r.Kind, r.Value) + }) + + got := map[string]string{} + for _, f := range vectors.Fields { + key := f.Message + "." + f.Field + if prev, dup := got[key]; dup { + t.Errorf("the table lists %s twice (form sets %q and %q) — one field, one form set", key, prev, f.FormSet) + continue + } + got[key] = f.FormSet + } + + // Direction 1: every ruled field must be in the table. + for key, rule := range want { + formSet, listed := got[key] + if !listed { + t.Errorf("%s carries %s but has no entry in testdata/bytes_wire_forms.json — the generated "+ + "patterns already enforce it in Pydantic and Zod, so with no rows nothing checks that the "+ + "three languages agree on padding and alphabet", key, rule) + continue + } + // Direction 3: the named form set must be the one for THIS rule value. + set, defined := vectors.FormSets[formSet] + if !defined { + continue // TestBytesWireFormsMatchGo reports the undefined set + } + if set.Rule != rule { + t.Errorf("%s carries %s but points at form set %q, whose rows were computed for %q — "+ + "the verdicts in that set do not describe this field", key, rule, formSet, set.Rule) + } + } + + // Direction 2: every table entry must name a field that still carries a rule. + for key := range got { + if _, ruled := want[key]; !ruled { + t.Errorf("testdata/bytes_wire_forms.json lists %s, which carries no bytes-length rule in the "+ + "contract — the rule was removed or the field renamed, and the rows now prove nothing", key) + } + } +} + +func TestBytesWireFormsMatchGo(t *testing.T) { + vectors := loadBytesWireVectors(t) + v, err := protovalidate.New() + if err != nil { + t.Fatalf("protovalidate: %v", err) + } + + // The bases must be valid on their own, or a rejected row proves nothing. + for message, base := range vectors.Bases { + m, ok := parseBase(t, message, base, "", nil) + if !ok { + t.Errorf("base for %s is not decodable proto-JSON", message) + continue + } + if err := v.Validate(m); err != nil { + t.Errorf("base for %s is not a valid message: %v — a 'rejected' row would then be rejected for the wrong reason", message, err) + } + } + + for _, f := range vectors.Fields { + set, ok := vectors.FormSets[f.FormSet] + if !ok { + t.Errorf("%s.%s names form set %q, which the table does not define", f.Message, f.Field, f.FormSet) + continue + } + base, ok := vectors.Bases[f.Message] + if !ok { + t.Errorf("%s.%s has no base in the table", f.Message, f.Field) + continue + } + for _, form := range set.Forms { + m, decoded := parseBase(t, f.Message, base, f.Field, form.Value) + accepted := decoded && v.Validate(m) == nil + if accepted != form.Accepted { + t.Errorf("%s.%s = %q (%s, %s): the table says accepted=%v, Go says %v — the generated client patterns mirror Go, so fix the row or the rule, not the pattern", + f.Message, f.Field, form.Value, set.Rule, form.Why, form.Accepted, accepted) + } + } + } +} diff --git a/conformance/bytesgen/main.go b/conformance/bytesgen/main.go new file mode 100644 index 00000000..8dcbdbeb --- /dev/null +++ b/conformance/bytesgen/main.go @@ -0,0 +1,185 @@ +// Command bytesgen emits bytes_len.json: per message, the JSON field names of +// every bytes field declaring a length rule, with the rule's kind and value — +// {"len": N} for (buf.validate.field).bytes.len (the evidence rows' raw +// 32-byte Ed25519 keys and sha256 signed_url_hash) or {"min_len": N} for +// bytes.min_len (the evidence rows' canonical-bytes fields). +// +// It exists because protoschema's translation of both rules is too loose: +// - bytes.len=N renders as base64 with a minLength/maxLength window (43..44 +// chars for N=32) that also admits an N+1-byte value — 33 bytes encode to +// 44 unpadded chars, inside the window — so the generated Pydantic/Zod +// accept a key length the Go server rejects. +// - bytes.min_len=N renders as a pattern + minLength counting CHARACTERS, +// so for N=1 the two-character string "==" (pure padding, zero payload +// bytes) passes the generated clients while Go protojson refuses to +// decode it. +// +// merge_schema.py reads this manifest and tightens each field: exact-length +// fields to the EXACT encoded forms of N bytes, minimum-length fields to a +// pattern requiring the encoded payload characters of at least N bytes — +// making byte length checkable at the schema layer without decoding. +// +// Anything this pipeline cannot translate fails the generator closed, because +// protoschema's rendering of an untranslated shape must not ship silently loose +// (the convention requiredgen uses for string.min_bytes): any OTHER bytes rule +// member (max_len, pattern, prefix, …) and a length rule sitting at +// repeated.items level both die in assertTranslatable, while len and min_len set +// together on one field and an explicitly zero-valued length rule die in +// conformance.BytesLength — they are contract errors for every consumer, not a +// bytesgen-only opinion. +// +// Like requiredgen and uniquegen, this is a Go program because Go protovalidate +// is the authoritative view of the rules (the same engine the conformance corpus +// is labeled against), and the Python bridge must not re-implement rule +// semantics. Scope comes from conformance.Contract. +package main + +import ( + "encoding/json" + "fmt" + "os" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/RAMP-Protocol/protocol/conformance" +) + +func main() { + // Fail fast on a cross-package bare-name collision: this manifest (and + // merge_schema.py, which consumes it) key by bare message name, so a + // duplicate would silently clobber one message's exact-length enforcement. + // Same guard as requiredgen; without it, only the run order in + // gen-sdk-types.sh protects a standalone bytesgen run. + if err := conformance.AssertUniqueBareNames(); err != nil { + panic(err) + } + assertEveryBytesFieldRuledOrAllowed() + out := "bytes_len.json" + if len(os.Args) > 1 { + out = os.Args[1] + } + lens := map[string]map[string]map[string]uint64{} + // EachRuleSet, not EachRuledField: the manifest can only express a length rule + // on the field itself, so an item-level one must be caught rather than walked + // past. assertTranslatable dies on it; the prefix check below keeps item rules + // out of the manifest. + conformance.EachRuleSet(func(md protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, prefix string, fr *validate.FieldRules) { + assertTranslatable(fd, prefix, fr) + if prefix != "" { + return + } + r := conformance.MustBytesLength(fd, fr) + if r == nil { + return + } + msg := string(md.Name()) + if lens[msg] == nil { + lens[msg] = map[string]map[string]uint64{} + } + lens[msg][string(fd.Name())] = map[string]uint64{r.Kind: r.Value} + }) + b, err := json.MarshalIndent(lens, "", " ") + if err != nil { + panic(err) + } + if err := os.WriteFile(out, append(b, '\n'), 0o644); err != nil { + panic(err) + } +} + +// bytesFieldsAllowedUnruled names the bytes fields that ship with NO length rule +// and therefore keep protoschema's loose base64 rendering in the generated +// clients. Each entry is a standing exception, not a decision to leave alone: the +// value is the reason it is tolerable today. +// +// The rendering an entry accepts is `^[A-Za-z0-9+/]*={0,2}$` — no url-safe arm, and +// padding characters allowed anywhere the regex's tail permits. A generated client +// therefore accepts values Go protojson refuses to decode, and refuses url-safe +// values Go protojson accepts. Wrong in both directions, quietly. +var bytesFieldsAllowedUnruled = map[string]string{ + "ramp.v1.Delegation.token": "A JWS compact serialization, not a fixed-size or " + + "minimum-size payload, so neither len nor min_len describes it — the shapes this " + + "manifest can express are the wrong tool. What it needs is a base64url-faithful " + + "PATTERN, which tighten_bytes_len cannot emit today. Tracked separately.", +} + +// assertEveryBytesFieldRuledOrAllowed fails the generator on a bytes field that +// carries no length rule and is not an explicit exception. +// +// WHY THIS EXISTS. The manifest walk uses EachRuleSet, which visits fields that +// HAVE rules. A bytes field with none was never visited, so it fell through and +// shipped with protoschema's loose rendering — in a generator whose header says it +// fails closed on everything it cannot translate. The gap was invisible for the +// usual reason: a guard that only inspects what it was given cannot report what it +// was never given. +// +// The list is self-cleaning in both directions. A new unruled bytes field fails +// until someone decides about it, and an entry that stops naming a live unruled +// field also fails, so the exception cannot outlive the field it excuses. +func assertEveryBytesFieldRuledOrAllowed() { + unruled := map[string]bool{} + conformance.EachMessage(func(md protoreflect.MessageDescriptor) { + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + if fd.Kind() != protoreflect.BytesKind { + continue + } + fr := conformance.FieldRules(fd) + if fr.GetBytes() != nil || fr.GetRepeated().GetItems().GetBytes() != nil { + continue + } + unruled[string(fd.FullName())] = true + } + }) + + for name := range unruled { + if _, ok := bytesFieldsAllowedUnruled[name]; !ok { + panic(fmt.Sprintf("bytesgen: bytes field %s carries no length rule, so the generated "+ + "Pydantic/Zod keep protoschema's loose base64 pattern and disagree with Go protojson "+ + "in both directions. Give it a bytes.len or bytes.min_len rule, or add it to "+ + "bytesFieldsAllowedUnruled with the reason it cannot have one.", name)) + } + } + for name := range bytesFieldsAllowedUnruled { + if !unruled[name] { + panic(fmt.Sprintf("bytesgen: bytesFieldsAllowedUnruled names %s, which is no longer an "+ + "unruled bytes field — it gained a rule, was renamed, or was removed. Drop the entry.", name)) + } + } +} + +// assertTranslatable dies on any bytes rule shape merge_schema.py's +// tighten_bytes_len cannot translate. Anything it cannot represent must stop the +// build rather than ship with protoschema's loose rendering and every gate green +// (fail closed, the requiredgen convention for string.min_bytes). +// +// Two shapes are fatal here: +// +// 1. Any bytes rule member outside len/min_len (max_len, pattern, prefix, …): +// no translation exists for it. +// 2. A bytes length rule at repeated.items level: the manifest keys by +// "/" and tighten_bytes_len rewrites the field's own schema +// node, so it has no way to reach a list's items. Without this check the +// rule would tighten nothing and every gate would stay green. +// +// The remaining two — len and min_len both set, and a zero-valued length — are +// rejected by conformance.BytesLength, so they are contract errors for every +// consumer rather than a bytesgen-only opinion. +func assertTranslatable(fd protoreflect.FieldDescriptor, prefix string, fr *validate.FieldRules) { + b := fr.GetBytes() + if b == nil { + return + } + b.ProtoReflect().Range(func(f protoreflect.FieldDescriptor, _ protoreflect.Value) bool { + switch f.Name() { + case "len", "min_len": + return true + default: + panic(fmt.Sprintf("bytesgen: field %s carries rule %sbytes.%s — merge_schema.py has no translation for it; teach tighten_bytes_len its shape before shipping it", fd.FullName(), prefix, f.Name())) + } + }) + if prefix != "" { + panic(fmt.Sprintf("bytesgen: field %s carries a %sbytes length rule — the manifest holds one entry per FIELD and tighten_bytes_len rewrites that field's schema node, so it cannot reach list items; teach the manifest an item level before shipping it", fd.FullName(), prefix)) + } +} diff --git a/conformance/contract.go b/conformance/contract.go index 0afec0f6..5b31c045 100644 --- a/conformance/contract.go +++ b/conformance/contract.go @@ -1,12 +1,22 @@ // Package conformance — contract.go is the SINGLE source of "which proto packages make -// up the wire contract". Every descriptor-walking guard and every generator iterates this -// list, so adding the next contract package is one entry here and nothing else. +// up the wire contract" AND of how a field's protovalidate rules are resolved. Every +// descriptor-walking guard and every generator iterates this list, so adding the next +// contract package is one entry here and nothing else. // // This is the only non-test file in the package, and that is deliberate: the corpus and // required/unique manifest generators are `package main` under conformance/*/ and cannot // import a _test.go file. Before this existed, each of them re-hardcoded the same two // walk() calls, so a new package was covered only where someone remembered to add it — -// the exact opt-in failure mode descriptor_invariants_test.go's header warns about. +// the exact opt-in failure mode descriptor_invariants_test.go's header warns about. The +// rule-resolution helpers below exist for the same reason one level down: every caller +// walked the fields itself and decided on its own what a RESOLVER ERROR means, and +// several decided "no rules", which silently disarms the guard doing the asking. +// +// The rule-SHAPE helpers exist for the same reason one level down again: every caller +// also decided on its own what a rule looks like. "Does this field carry a bytes length +// rule" was read four ways (by value, by presence, and two different mixes), and the +// fail-closed guards read only the top-level rule oneof, so a repeated.items.* rule +// walked past them. One accessor and one two-level sweep, both here. // // Note wire_naming_test.go deliberately does NOT walk descriptors: it reads the committed // corpus JSON, which already covers every package walked here. @@ -15,6 +25,8 @@ package conformance import ( "fmt" + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protovalidate "buf.build/go/protovalidate" "google.golang.org/protobuf/reflect/protoreflect" rampadminv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/admin/v1" @@ -83,22 +95,244 @@ func EachMessage(fn func(protoreflect.MessageDescriptor)) { } } +// FieldRules resolves fd's protovalidate field rules, returning nil when fd carries +// none. +// +// It exists to own ONE decision: a resolver error is not "no rules". Swallowing it +// disarms whatever the caller was going to do with the rules — the corpus loses every +// case for the field, a manifest loses its entry, a coverage guard stops requiring +// anything — and the build stays green, which is the failure mode this whole package +// is built to prevent. So the policy is to panic: a `package main` generator dies with +// a non-zero exit, a test fails with the message. Both are the fail-closed direction. +// +// Callers that need the rules of one specific field (not a whole walk) use this +// directly; callers that walk the contract use EachRuledField. +func FieldRules(fd protoreflect.FieldDescriptor) *validate.FieldRules { + fr, err := protovalidate.ResolveFieldRules(fd) + if err != nil { + panic(fmt.Sprintf("conformance: resolving protovalidate rules for field %s: %v — a resolver error is not 'no rules'", fd.FullName(), err)) + } + return fr +} + +// EachRuledField visits every field carrying protovalidate field rules, across every +// message EachMessage walks, in descriptor order. Fields with no rules are skipped; a +// resolver error panics (see FieldRules). +// +// This is the walk every generator and rule-shaped guard wants. Hand-rolling it means +// re-deciding the error policy each time, which is how the class-6 corpus coverage +// guard came to treat an unresolvable field as unruled. +func EachRuledField(fn func(md protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, fr *validate.FieldRules)) { + EachMessage(func(md protoreflect.MessageDescriptor) { + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + if fr := FieldRules(fd); fr != nil { + fn(md, fd, fr) + } + } + }) +} + +// ── rule-shape inspection ──────────────────────────────────────────────────── +// +// The helpers below own the two questions every generator and rule-shaped guard +// asks about a rule set, so the repo has ONE answer to each: +// +// - "does this field carry a bytes length rule, and which one" (BytesLength) +// - "does this rule set carry a string byte-length rule" (StringByteLength) +// +// Before this, the first question was read four different ways — by value in one +// generator, by presence in another, by a mix of both in two more — so the same +// field could be ruled for one consumer and unruled for the next. + +// RuleSet is one level of a field's protovalidate rules: the field's own rules, +// or the per-item rules of a repeated field. Prefix names the level for +// diagnostics — "" for the field's own rules, "repeated.items." for item rules — +// so a message can say WHERE it found the rule. +type RuleSet struct { + Prefix string + Rules *validate.FieldRules +} + +// RuleSets returns fr's rule levels in sweep order: the field's own rules first, +// then its repeated.items rules when the field declares them. +// +// A guard that reads only the top-level rule oneof walks straight past +// repeated.items... That shape is already used by the contract +// (admin.proto ListRequest.filters and five sites in ramp.proto), so such a guard +// is open on every repeated field while looking fail-closed. Anything asking +// "does the contract carry rule X anywhere" must go through this. +// +// It is a plain function over a FieldRules, not part of the descriptor walk, so a +// test can feed it a synthetic rule set and prove the descent still happens. +func RuleSets(fr *validate.FieldRules) []RuleSet { + if fr == nil { + return nil + } + out := []RuleSet{{Prefix: "", Rules: fr}} + if it := fr.GetRepeated().GetItems(); it != nil { + out = append(out, RuleSet{Prefix: "repeated.items.", Rules: it}) + } + return out +} + +// EachRuleSet is EachRuledField descended one level: it visits every ruled +// field's own rules AND its repeated.items rules, in descriptor order. Use it +// for any contract-wide "is rule X present" sweep; use EachRuledField only when +// the question is genuinely about the field's own top-level rules. +func EachRuleSet(fn func(md protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, prefix string, fr *validate.FieldRules)) { + EachRuledField(func(md protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, fr *validate.FieldRules) { + for _, rs := range RuleSets(fr) { + fn(md, fd, rs.Prefix, rs.Rules) + } + }) +} + +// BytesLengthRule names one bytes length rule: Kind is "len" (an exact length) +// or "min_len" (a floor), and Value is the declared byte count. +type BytesLengthRule struct { + Kind string + Value uint64 +} + +// BytesLength reads the bytes length rule fr declares, returning nil when it +// declares none. This is the ONLY reading of "does this carry a bytes length +// rule" in the repo — see the four divergent readings this replaced. +// +// The reading is by PRESENCE, not by value, and the two shapes that would make +// presence and value disagree are errors rather than results: +// +// 1. len AND min_len both set. Every consumer carries one length kind per field +// (the bytes_len.json manifest, the corpus mutants, the coverage guard), so +// one of the two rules would be dropped silently. +// 2. A zero value (len:0 / min_len:0). It constrains nothing, so a value-reading +// consumer treats the field as unruled while a presence-reading one enters it +// into the manifest. It is a contract error, not a rule. +// +// With both rejected, presence and value cannot disagree, so every consumer sees +// the same set of ruled fields. Callers inside a generator use MustBytesLength, +// which applies the package's fail-closed panic policy; the invariant test in +// descriptor_invariants_test.go calls this one so it can report the field name +// instead of aborting the test binary. +func BytesLength(fr *validate.FieldRules) (*BytesLengthRule, error) { + b := fr.GetBytes() + if b == nil { + return nil, nil + } + if b.Len != nil && b.MinLen != nil { + return nil, fmt.Errorf("carries BOTH bytes.len:%d and bytes.min_len:%d — every consumer holds one length kind per field, so one of the two would be enforced and the other silently dropped; express the intent as a single rule", + b.GetLen(), b.GetMinLen()) + } + if b.Len != nil { + if b.GetLen() == 0 { + return nil, fmt.Errorf("carries bytes.len:0 — an explicit zero length constrains nothing, so it is a contract error, not a rule") + } + return &BytesLengthRule{Kind: "len", Value: b.GetLen()}, nil + } + if b.MinLen != nil { + if b.GetMinLen() == 0 { + return nil, fmt.Errorf("carries bytes.min_len:0 — an explicit zero floor constrains nothing, so it is a contract error, not a rule") + } + return &BytesLengthRule{Kind: "min_len", Value: b.GetMinLen()}, nil + } + return nil, nil +} + +// MustBytesLength is BytesLength with this package's fail-closed error policy: +// an ill-formed length rule stops the generator (non-zero exit) or fails the test +// rather than being read as "no length rule". fd only names the offender. +func MustBytesLength(fd protoreflect.FieldDescriptor, fr *validate.FieldRules) *BytesLengthRule { + r, err := BytesLength(fr) + if err != nil { + panic(fmt.Sprintf("conformance: field %s %v", fd.FullName(), err)) + } + return r +} + +// StringByteLength reports the string byte-length rule fr declares — the member +// name ("min_bytes" or "max_bytes") and its value — or ok=false when it declares +// none. +// +// It is shared because it is a fail-closed guard's predicate (requiredgen's +// assertNoStringByteLengthRules) and a guard is only as wide as the sweep it runs +// on: protoschema translates both members into a JSON Schema minLength/maxLength, +// which counts CHARACTERS, so a multibyte value the Go server rejects passes the +// generated Pydantic/Zod. Run it over EachRuleSet, never EachRuledField, or the +// rule can enter the contract as repeated.items.string.max_bytes unseen. +func StringByteLength(fr *validate.FieldRules) (member string, value uint64, ok bool) { + s := fr.GetString() + if s == nil { + return "", 0, false + } + if n := s.GetMinBytes(); n > 0 { + return "min_bytes", n, true + } + if n := s.GetMaxBytes(); n > 0 { + return "max_bytes", n, true + } + return "", 0, false +} + +// EachEnum calls fn for every enum in the contract — file-level and nested inside +// a message, in every contract package. +// +// It exists because the bare-name scheme treats enums exactly like messages, and +// a walk that visited only messages left half the namespace unguarded. ramp.v1 +// was the only package defining enums until ramp.admin.v1 added ObligationState, +// so the gap had no way to bite and no way to be noticed. +func EachEnum(fn func(protoreflect.EnumDescriptor)) { + var walk func(protoreflect.MessageDescriptors) + walk = func(ms protoreflect.MessageDescriptors) { + for i := 0; i < ms.Len(); i++ { + md := ms.Get(i) + for j := 0; j < md.Enums().Len(); j++ { + fn(md.Enums().Get(j)) + } + walk(md.Messages()) + } + } + for _, f := range Contract { + for i := 0; i < f.File.Enums().Len(); i++ { + fn(f.File.Enums().Get(i)) + } + walk(f.File.Messages()) + } +} + // AssertUniqueBareNames reports an error when two contract packages define a message -// with the same bare name. The corpus keys cases by bare short name (Case.Message == -// the generated class/schema name), the merged JSON-Schema $defs are keyed the same way, -// and the {/* ramp-validate: X */} doc markers resolve the same way — a cross-package -// duplicate would silently collide in all three. Returned as an error, not a fatal, so -// both a `package main` generator (which exits) and a test (which fails) can use it. +// or an ENUM with the same bare name. The corpus keys cases by bare short name +// (Case.Message == the generated class/schema name), the merged JSON-Schema $defs are +// keyed the same way, and the {/* ramp-validate: X */} doc markers resolve the same way +// — a cross-package duplicate would silently collide in all three. Returned as an error, +// not a fatal, so both a `package main` generator (which exits) and a test (which fails) +// can use it. +// +// ENUMS ARE CHECKED IN THE SAME NAMESPACE AS MESSAGES, not in one of their own, +// because merge_schema.py hoists both into a single $defs map. A message and an +// enum sharing a bare name collide there just as two messages would. +// +// The enum half is the one that fails quietly. merge_schema.py keys enum $defs by +// bare name with setdefault, so the SECOND enum of a colliding pair is dropped and +// every field referring to it silently gets the FIRST enum's value list. Generated +// Pydantic and Zod would then accept values the Go server rejects, with nothing +// failing anywhere in between. A duplicate message name at least collides on a +// structure a reader can see. func AssertUniqueBareNames() error { seen := map[string]protoreflect.FullName{} var err error - EachMessage(func(md protoreflect.MessageDescriptor) { - if prev, ok := seen[string(md.Name())]; ok && prev != md.FullName() && err == nil { - err = fmt.Errorf("duplicate bare message name %q (%s vs %s) — the corpus/parity bare-name scheme cannot represent it", - md.Name(), prev, md.FullName()) + claim := func(kind, bare string, full protoreflect.FullName) { + if prev, ok := seen[bare]; ok && prev != full && err == nil { + err = fmt.Errorf("duplicate bare %s name %q (%s vs %s) — the corpus/parity bare-name scheme cannot represent it", + kind, bare, prev, full) return } - seen[string(md.Name())] = md.FullName() + seen[bare] = full + } + EachMessage(func(md protoreflect.MessageDescriptor) { + claim("message", string(md.Name()), md.FullName()) + }) + EachEnum(func(ed protoreflect.EnumDescriptor) { + claim("enum", string(ed.Name()), ed.FullName()) }) return err } diff --git a/conformance/corpus/cases.json b/conformance/corpus/cases.json index 5a66cbae..41b1dcf6 100644 --- a/conformance/corpus/cases.json +++ b/conformance/corpus/cases.json @@ -135,20 +135,119 @@ } }, { - "id": "AgentAcceptance/signature/too_short", + "id": "AgentAcceptance/signature/missing_empty", "message": "AgentAcceptance", "valid": false, "rules": [ - "string.min_len" + "string.pattern" ], "json": {} }, + { + "id": "AgentAcceptance/signature/pattern#0", + "message": "AgentAcceptance", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "signature": "two words" + } + }, + { + "id": "AgentAcceptance/signature/pattern#1", + "message": "AgentAcceptance", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "signature": "1.2.3" + } + }, + { + "id": "AgentAcceptance/signature/pattern#2", + "message": "AgentAcceptance", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "signature": "!!bad!!" + } + }, + { + "id": "AgentAcceptance/signature/pattern#3", + "message": "AgentAcceptance", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "signature": "\u0000ctl\u0000" + } + }, + { + "id": "AgentAcceptance/signature/pattern#4", + "message": "AgentAcceptance", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "signature": " " + } + }, + { + "id": "AgentAcceptance/signature/pattern#5", + "message": "AgentAcceptance", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "signature": "-5" + } + }, + { + "id": "AgentAcceptance/signature/pattern#6", + "message": "AgentAcceptance", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "signature": "NaN" + } + }, + { + "id": "AgentAcceptance/signature/pattern#7", + "message": "AgentAcceptance", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "signature": "Infinity" + } + }, + { + "id": "AgentAcceptance/signature/pattern#8", + "message": "AgentAcceptance", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "signature": "1E3" + } + }, { "id": "AgentAcceptance/valid", "message": "AgentAcceptance", "valid": true, "json": { - "signature": "x" + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -379,7 +478,19 @@ } }, { - "id": "AuthorizedExchange/relationship/not_in", + "id": "AuthorizedExchange/relationship/not_in_zero_explicit", + "message": "AuthorizedExchange", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "domain": "x", + "relationship": "PROVIDER_RELATIONSHIP_UNSPECIFIED" + } + }, + { + "id": "AuthorizedExchange/relationship/not_in_zero_omitted", "message": "AuthorizedExchange", "valid": false, "rules": [ @@ -399,7 +510,18 @@ } }, { - "id": "CatalogRejection/reason/not_in", + "id": "CatalogRejection/reason/not_in_zero_explicit", + "message": "CatalogRejection", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "reason": "CATALOG_REJECTION_REASON_UNSPECIFIED" + } + }, + { + "id": "CatalogRejection/reason/not_in_zero_omitted", "message": "CatalogRejection", "valid": false, "rules": [ @@ -974,7 +1096,18 @@ } }, { - "id": "DisputeFailure/reason/not_in", + "id": "DisputeFailure/reason/not_in_zero_explicit", + "message": "DisputeFailure", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "reason": "DISPUTE_FAILURE_REASON_UNSPECIFIED" + } + }, + { + "id": "DisputeFailure/reason/not_in_zero_omitted", "message": "DisputeFailure", "valid": false, "rules": [ @@ -1011,7 +1144,8 @@ "json": { "exchange": "https://exchange.example", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1024,7 +1158,8 @@ "json": { "exchange": "exchange.example/register", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1037,7 +1172,8 @@ "json": { "exchange": "exchange.example:0", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1050,7 +1186,8 @@ "json": { "exchange": "exchange.example:99999", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1063,7 +1200,8 @@ "json": { "exchange": "exchange.example?x=1", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1076,7 +1214,8 @@ "json": { "exchange": "user@exchange.example", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1089,7 +1228,8 @@ "json": { "exchange": "exchange.example:123456", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1102,7 +1242,8 @@ "json": { "exchange": "exchange.example:", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1115,7 +1256,8 @@ "json": { "exchange": "exchange.example.", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1128,7 +1270,8 @@ "json": { "exchange": "exchange..example", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1141,7 +1284,8 @@ "json": { "exchange": "-exchange.example", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1154,7 +1298,8 @@ "json": { "exchange": "[::1]:443", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1166,7 +1311,8 @@ ], "json": { "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1179,7 +1325,8 @@ "json": { "exchange": "two words", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1192,7 +1339,8 @@ "json": { "exchange": "!!bad!!", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1205,7 +1353,8 @@ "json": { "exchange": "\u0000ctl\u0000", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1218,7 +1367,8 @@ "json": { "exchange": " ", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1231,7 +1381,8 @@ "json": { "exchange": "-5", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1244,7 +1395,8 @@ "json": { "exchange": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1257,11 +1409,12 @@ "json": { "exchange": "exchange.example", "idempotency_key": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { - "id": "DisputeRequest/idempotency_key/too_short", + "id": "DisputeRequest/idempotency_key/too_short_explicit_empty", "message": "DisputeRequest", "valid": false, "rules": [ @@ -1269,11 +1422,40 @@ ], "json": { "exchange": "exchange.example", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "idempotency_key": "", + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" + } + }, + { + "id": "DisputeRequest/idempotency_key/too_short_omitted", + "message": "DisputeRequest", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "exchange": "exchange.example", + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" + } + }, + { + "id": "DisputeRequest/reason/not_in_zero_explicit", + "message": "DisputeRequest", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "exchange": "exchange.example", + "idempotency_key": "idem-dr", + "reason": "DISPUTE_REASON_UNSPECIFIED", + "transaction_id": "tx-dr" } }, { - "id": "DisputeRequest/reason/not_in", + "id": "DisputeRequest/reason/not_in_zero_omitted", "message": "DisputeRequest", "valid": false, "rules": [ @@ -1281,7 +1463,35 @@ ], "json": { "exchange": "exchange.example", - "idempotency_key": "idem-dr" + "idempotency_key": "idem-dr", + "transaction_id": "tx-dr" + } + }, + { + "id": "DisputeRequest/transaction_id/too_short_explicit_empty", + "message": "DisputeRequest", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "exchange": "exchange.example", + "idempotency_key": "idem-dr", + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "" + } + }, + { + "id": "DisputeRequest/transaction_id/too_short_omitted", + "message": "DisputeRequest", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "exchange": "exchange.example", + "idempotency_key": "idem-dr", + "reason": "DISPUTE_REASON_CONTENT_MISMATCH" } }, { @@ -1291,7 +1501,8 @@ "json": { "exchange": "exchange.example", "idempotency_key": "idem-dr", - "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + "reason": "DISPUTE_REASON_CONTENT_MISMATCH", + "transaction_id": "tx-dr" } }, { @@ -1510,7 +1721,18 @@ } }, { - "id": "DomainVerificationFailure/reason/not_in", + "id": "DomainVerificationFailure/reason/not_in_zero_explicit", + "message": "DomainVerificationFailure", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "reason": "DOMAIN_VERIFICATION_FAILURE_REASON_UNSPECIFIED" + } + }, + { + "id": "DomainVerificationFailure/reason/not_in_zero_omitted", "message": "DomainVerificationFailure", "valid": false, "rules": [ @@ -1968,120 +2190,272 @@ } }, { - "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", + "id": "GetTransactionEvidenceRequest/tenant_id/too_long", + "message": "GetTransactionEvidenceRequest", "valid": false, "rules": [ - "string.pattern" + "string.max_len" ], "json": { - "id": "CC-BY-4.0", - "uri_digest": "two words" + "tenant_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "transaction_id": "x" } }, { - "id": "License/uri_digest/pattern#1", - "message": "License", + "id": "GetTransactionEvidenceRequest/tenant_id/too_short_explicit_empty", + "message": "GetTransactionEvidenceRequest", "valid": false, "rules": [ - "string.pattern" + "string.min_len" ], "json": { - "id": "CC-BY-4.0", - "uri_digest": "1.2.3" + "tenant_id": "", + "transaction_id": "x" } }, { - "id": "License/uri_digest/pattern#2", - "message": "License", + "id": "GetTransactionEvidenceRequest/tenant_id/too_short_omitted", + "message": "GetTransactionEvidenceRequest", "valid": false, "rules": [ - "string.pattern" + "string.min_len" ], "json": { - "id": "CC-BY-4.0", - "uri_digest": "!!bad!!" + "transaction_id": "x" } }, { - "id": "License/uri_digest/pattern#3", - "message": "License", + "id": "GetTransactionEvidenceRequest/transaction_id/too_long", + "message": "GetTransactionEvidenceRequest", "valid": false, "rules": [ - "string.pattern" + "string.max_len" ], "json": { - "id": "CC-BY-4.0", - "uri_digest": "\u0000ctl\u0000" + "tenant_id": "x", + "transaction_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } }, { - "id": "License/uri_digest/pattern#4", - "message": "License", + "id": "GetTransactionEvidenceRequest/transaction_id/too_short_explicit_empty", + "message": "GetTransactionEvidenceRequest", "valid": false, "rules": [ - "string.pattern" + "string.min_len" ], "json": { - "id": "CC-BY-4.0", - "uri_digest": " " + "tenant_id": "x", + "transaction_id": "" } }, { - "id": "License/uri_digest/pattern#5", - "message": "License", + "id": "GetTransactionEvidenceRequest/transaction_id/too_short_omitted", + "message": "GetTransactionEvidenceRequest", "valid": false, "rules": [ - "string.pattern" + "string.min_len" ], "json": { - "id": "CC-BY-4.0", - "uri_digest": "-5" + "tenant_id": "x" } }, { - "id": "License/uri_digest/pattern#6", - "message": "License", - "valid": false, - "rules": [ - "string.pattern" - ], + "id": "GetTransactionEvidenceRequest/valid", + "message": "GetTransactionEvidenceRequest", + "valid": true, "json": { - "id": "CC-BY-4.0", - "uri_digest": "NaN" + "tenant_id": "x", + "transaction_id": "x" } }, { - "id": "License/uri_digest/pattern#7", - "message": "License", + "id": "GetTransactionEvidenceResponse/evidence/missing", + "message": "GetTransactionEvidenceResponse", "valid": false, "rules": [ - "string.pattern" + "required" ], "json": { - "id": "CC-BY-4.0", - "uri_digest": "Infinity" + "transaction_state": { + "idempotency_key": "idem-tx:offer-seed", + "signed_url_expiry": "2026-01-02T03:04:05Z", + "signed_url_hash": "aGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGg=" + } } }, { - "id": "License/uri_digest/pattern#8", - "message": "License", + "id": "GetTransactionEvidenceResponse/transaction_state/missing", + "message": "GetTransactionEvidenceResponse", "valid": false, "rules": [ - "string.pattern" + "required" ], "json": { - "id": "CC-BY-4.0", - "uri_digest": "1E3" + "evidence": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + } + }, + { + "id": "GetTransactionEvidenceResponse/valid", + "message": "GetTransactionEvidenceResponse", + "valid": true, + "json": { + "evidence": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + }, + "transaction_state": { + "idempotency_key": "idem-tx:offer-seed", + "signed_url_expiry": "2026-01-02T03:04:05Z", + "signed_url_hash": "aGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGg=" + } + } + }, + { + "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" } }, { @@ -2186,7 +2560,22 @@ } }, { - "id": "LicenseTerm/semantics/not_in", + "id": "LicenseTerm/semantics/not_in_zero_explicit", + "message": "LicenseTerm", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "semantics": "TERM_SEMANTICS_UNSPECIFIED" + } + }, + { + "id": "LicenseTerm/semantics/not_in_zero_omitted", "message": "LicenseTerm", "valid": false, "rules": [ @@ -2212,7 +2601,19 @@ } }, { - "id": "Obligation/kind/not_in", + "id": "Obligation/kind/not_in_zero_explicit", + "message": "Obligation", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "kind": "OBLIGATION_KIND_UNSPECIFIED", + "trigger": "OBLIGATION_TRIGGER_ON_USE" + } + }, + { + "id": "Obligation/kind/not_in_zero_omitted", "message": "Obligation", "valid": false, "rules": [ @@ -2223,7 +2624,19 @@ } }, { - "id": "Obligation/trigger/not_in", + "id": "Obligation/trigger/not_in_zero_explicit", + "message": "Obligation", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "kind": "OBLIGATION_KIND_ATTRIBUTION", + "trigger": "OBLIGATION_TRIGGER_UNSPECIFIED" + } + }, + { + "id": "Obligation/trigger/not_in_zero_omitted", "message": "Obligation", "valid": false, "rules": [ @@ -2257,7 +2670,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2275,7 +2689,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2293,7 +2708,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2311,7 +2727,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2329,7 +2746,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2347,7 +2765,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2365,7 +2784,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2383,7 +2803,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2401,7 +2822,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2419,7 +2841,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2437,7 +2860,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2455,7 +2879,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2472,7 +2897,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2490,7 +2916,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2508,7 +2935,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2526,7 +2954,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2544,7 +2973,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2562,7 +2992,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { @@ -2580,13 +3011,17 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } }, { - "id": "Offer/valid", + "id": "Offer/signature/missing_empty", "message": "Offer", - "valid": true, + "valid": false, + "rules": [ + "string.pattern" + ], "json": { "data_as_of": "2026-01-02T03:04:05Z", "exchange": "exchange.example", @@ -2599,60 +3034,259 @@ } }, { - "id": "Pricing/model/not_in", - "message": "Pricing", + "id": "Offer/signature/pattern#0", + "message": "Offer", "valid": false, "rules": [ - "enum.not_in" + "string.pattern" ], "json": { - "rate": "0" - } - }, - { - "id": "Pricing/rate/empty_ok", - "message": "Pricing", - "valid": true, - "json": { - "model": "PRICING_MODEL_FREE" + "data_as_of": "2026-01-02T03:04:05Z", + "exchange": "exchange.example", + "expires_at": "2026-01-02T03:04:05Z", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": "two words" } }, { - "id": "Pricing/rate/pattern#0", - "message": "Pricing", + "id": "Offer/signature/pattern#1", + "message": "Offer", "valid": false, "rules": [ - "pricing.free.zero_rate", "string.pattern" ], "json": { - "model": "PRICING_MODEL_FREE", - "rate": "two words" + "data_as_of": "2026-01-02T03:04:05Z", + "exchange": "exchange.example", + "expires_at": "2026-01-02T03:04:05Z", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": "1.2.3" } }, { - "id": "Pricing/rate/pattern#1", - "message": "Pricing", + "id": "Offer/signature/pattern#2", + "message": "Offer", "valid": false, "rules": [ - "pricing.free.zero_rate", "string.pattern" ], "json": { - "model": "PRICING_MODEL_FREE", - "rate": "1.2.3" + "data_as_of": "2026-01-02T03:04:05Z", + "exchange": "exchange.example", + "expires_at": "2026-01-02T03:04:05Z", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": "!!bad!!" } }, { - "id": "Pricing/rate/pattern#2", - "message": "Pricing", + "id": "Offer/signature/pattern#3", + "message": "Offer", "valid": false, "rules": [ - "pricing.free.zero_rate", "string.pattern" ], "json": { - "model": "PRICING_MODEL_FREE", + "data_as_of": "2026-01-02T03:04:05Z", + "exchange": "exchange.example", + "expires_at": "2026-01-02T03:04:05Z", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": "\u0000ctl\u0000" + } + }, + { + "id": "Offer/signature/pattern#4", + "message": "Offer", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "data_as_of": "2026-01-02T03:04:05Z", + "exchange": "exchange.example", + "expires_at": "2026-01-02T03:04:05Z", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": " " + } + }, + { + "id": "Offer/signature/pattern#5", + "message": "Offer", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "data_as_of": "2026-01-02T03:04:05Z", + "exchange": "exchange.example", + "expires_at": "2026-01-02T03:04:05Z", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": "-5" + } + }, + { + "id": "Offer/signature/pattern#6", + "message": "Offer", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "data_as_of": "2026-01-02T03:04:05Z", + "exchange": "exchange.example", + "expires_at": "2026-01-02T03:04:05Z", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": "NaN" + } + }, + { + "id": "Offer/signature/pattern#7", + "message": "Offer", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "data_as_of": "2026-01-02T03:04:05Z", + "exchange": "exchange.example", + "expires_at": "2026-01-02T03:04:05Z", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": "Infinity" + } + }, + { + "id": "Offer/signature/pattern#8", + "message": "Offer", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "data_as_of": "2026-01-02T03:04:05Z", + "exchange": "exchange.example", + "expires_at": "2026-01-02T03:04:05Z", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": "1E3" + } + }, + { + "id": "Offer/valid", + "message": "Offer", + "valid": true, + "json": { + "data_as_of": "2026-01-02T03:04:05Z", + "exchange": "exchange.example", + "expires_at": "2026-01-02T03:04:05Z", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, + { + "id": "Pricing/model/not_in_zero_explicit", + "message": "Pricing", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "model": "PRICING_MODEL_UNSPECIFIED", + "rate": "0" + } + }, + { + "id": "Pricing/model/not_in_zero_omitted", + "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!!" } }, @@ -3392,7 +4026,20 @@ } }, { - "id": "Quota/window/not_in", + "id": "Quota/window/not_in_zero_explicit", + "message": "Quota", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "limit": "1", + "metric": "accesses", + "window": "QUOTA_WINDOW_UNSPECIFIED" + } + }, + { + "id": "Quota/window/not_in_zero_omitted", "message": "Quota", "valid": false, "rules": [ @@ -4244,7 +4891,24 @@ } }, { - "id": "RegistrationFailure/reason/not_in", + "id": "RegistrationFailure/reason/not_in_zero_explicit", + "message": "RegistrationFailure", + "valid": false, + "rules": [ + "enum.not_in", + "registration_failure.field_errors_scoped_to_invalid_data" + ], + "json": { + "field_errors": [ + { + "error": "matched 2 branches of oneOf, exactly 1 required" + } + ], + "reason": "REGISTRATION_FAILURE_REASON_UNSPECIFIED" + } + }, + { + "id": "RegistrationFailure/reason/not_in_zero_omitted", "message": "RegistrationFailure", "valid": false, "rules": [ @@ -4302,7 +4966,19 @@ } }, { - "id": "RegistrationFieldError/error/too_short", + "id": "RegistrationFieldError/error/too_short_explicit_empty", + "message": "RegistrationFieldError", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "error": "", + "path": "x" + } + }, + { + "id": "RegistrationFieldError/error/too_short_omitted", "message": "RegistrationFieldError", "valid": false, "rules": [ @@ -4548,6 +5224,84 @@ "exchange": "x" } }, + { + "id": "ReportingObligationState/created_at/missing", + "message": "ReportingObligationState", + "valid": false, + "rules": [ + "required" + ], + "json": { + "fulfilled_at": "2026-01-02T03:04:05Z", + "state": "OBLIGATION_STATE_PENDING", + "window_end": "2026-01-02T03:04:05Z" + } + }, + { + "id": "ReportingObligationState/state/not_in_zero_explicit", + "message": "ReportingObligationState", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "created_at": "2026-01-02T03:04:05Z", + "fulfilled_at": "2026-01-02T03:04:05Z", + "state": "OBLIGATION_STATE_UNSPECIFIED", + "window_end": "2026-01-02T03:04:05Z" + } + }, + { + "id": "ReportingObligationState/state/not_in_zero_omitted", + "message": "ReportingObligationState", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "created_at": "2026-01-02T03:04:05Z", + "fulfilled_at": "2026-01-02T03:04:05Z", + "window_end": "2026-01-02T03:04:05Z" + } + }, + { + "id": "ReportingObligationState/state/undefined", + "message": "ReportingObligationState", + "valid": false, + "rules": [ + "enum.defined_only" + ], + "json": { + "created_at": "2026-01-02T03:04:05Z", + "fulfilled_at": "2026-01-02T03:04:05Z", + "state": 6, + "window_end": "2026-01-02T03:04:05Z" + } + }, + { + "id": "ReportingObligationState/valid", + "message": "ReportingObligationState", + "valid": true, + "json": { + "created_at": "2026-01-02T03:04:05Z", + "fulfilled_at": "2026-01-02T03:04:05Z", + "state": "OBLIGATION_STATE_PENDING", + "window_end": "2026-01-02T03:04:05Z" + } + }, + { + "id": "ReportingObligationState/window_end/missing", + "message": "ReportingObligationState", + "valid": false, + "rules": [ + "required" + ], + "json": { + "created_at": "2026-01-02T03:04:05Z", + "fulfilled_at": "2026-01-02T03:04:05Z", + "state": "OBLIGATION_STATE_PENDING" + } + }, { "id": "ReportingPolicy/quantity_tolerance/above_max", "message": "ReportingPolicy", @@ -4701,7 +5455,7 @@ } }, { - "id": "ReportingPolicy/tenant_id/too_short", + "id": "ReportingPolicy/tenant_id/too_short_explicit_empty", "message": "ReportingPolicy", "valid": false, "rules": [ @@ -4710,7 +5464,21 @@ "json": { "required_fields": [ "x" - ] + ], + "tenant_id": "" + } + }, + { + "id": "ReportingPolicy/tenant_id/too_short_omitted", + "message": "ReportingPolicy", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "required_fields": [ + "x" + ] } }, { @@ -5537,6 +6305,90 @@ ] } }, + { + "id": "RequestCorrelation/request_id/missing_empty", + "message": "RequestCorrelation", + "valid": false, + "rules": [ + "string.min_len", + "string.pattern" + ], + "json": {} + }, + { + "id": "RequestCorrelation/request_id/pattern#0", + "message": "RequestCorrelation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "request_id": "two words" + } + }, + { + "id": "RequestCorrelation/request_id/pattern#3", + "message": "RequestCorrelation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "request_id": "\u0000ctl\u0000" + } + }, + { + "id": "RequestCorrelation/request_id/pattern#4", + "message": "RequestCorrelation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "request_id": " " + } + }, + { + "id": "RequestCorrelation/request_id/too_long", + "message": "RequestCorrelation", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "request_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "id": "RequestCorrelation/request_id/too_short_explicit_empty", + "message": "RequestCorrelation", + "valid": false, + "rules": [ + "string.min_len", + "string.pattern" + ], + "json": { + "request_id": "" + } + }, + { + "id": "RequestCorrelation/request_id/too_short_omitted", + "message": "RequestCorrelation", + "valid": false, + "rules": [ + "string.min_len", + "string.pattern" + ], + "json": {} + }, + { + "id": "RequestCorrelation/valid", + "message": "RequestCorrelation", + "valid": true, + "json": { + "request_id": "x" + } + }, { "id": "Requester/domain/killer#0", "message": "Requester", @@ -5901,7 +6753,22 @@ } }, { - "id": "Requester/type/not_in", + "id": "Requester/type/not_in_zero_explicit", + "message": "Requester", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "domain": "x", + "scopes": [ + "x" + ], + "type": "REQUESTER_TYPE_UNSPECIFIED" + } + }, + { + "id": "Requester/type/not_in_zero_omitted", "message": "Requester", "valid": false, "rules": [ @@ -5926,6 +6793,134 @@ "type": "REQUESTER_TYPE_AGENT" } }, + { + "id": "ResourceAttestation/signature/missing_empty", + "message": "ResourceAttestation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "attested_at": "2026-01-02T03:04:05Z" + } + }, + { + "id": "ResourceAttestation/signature/pattern#0", + "message": "ResourceAttestation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "attested_at": "2026-01-02T03:04:05Z", + "signature": "two words" + } + }, + { + "id": "ResourceAttestation/signature/pattern#1", + "message": "ResourceAttestation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "attested_at": "2026-01-02T03:04:05Z", + "signature": "1.2.3" + } + }, + { + "id": "ResourceAttestation/signature/pattern#2", + "message": "ResourceAttestation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "attested_at": "2026-01-02T03:04:05Z", + "signature": "!!bad!!" + } + }, + { + "id": "ResourceAttestation/signature/pattern#3", + "message": "ResourceAttestation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "attested_at": "2026-01-02T03:04:05Z", + "signature": "\u0000ctl\u0000" + } + }, + { + "id": "ResourceAttestation/signature/pattern#4", + "message": "ResourceAttestation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "attested_at": "2026-01-02T03:04:05Z", + "signature": " " + } + }, + { + "id": "ResourceAttestation/signature/pattern#5", + "message": "ResourceAttestation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "attested_at": "2026-01-02T03:04:05Z", + "signature": "-5" + } + }, + { + "id": "ResourceAttestation/signature/pattern#6", + "message": "ResourceAttestation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "attested_at": "2026-01-02T03:04:05Z", + "signature": "NaN" + } + }, + { + "id": "ResourceAttestation/signature/pattern#7", + "message": "ResourceAttestation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "attested_at": "2026-01-02T03:04:05Z", + "signature": "Infinity" + } + }, + { + "id": "ResourceAttestation/signature/pattern#8", + "message": "ResourceAttestation", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "attested_at": "2026-01-02T03:04:05Z", + "signature": "1E3" + } + }, + { + "id": "ResourceAttestation/valid", + "message": "ResourceAttestation", + "valid": true, + "json": { + "attested_at": "2026-01-02T03:04:05Z", + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + }, { "id": "ResourceEntry/resource_mutability/not_in", "message": "ResourceEntry", @@ -5956,7 +6951,18 @@ } }, { - "id": "ResourceIdentity/resource_mutability/not_in", + "id": "ResourceIdentity/resource_mutability/not_in_zero_explicit", + "message": "ResourceIdentity", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "resource_mutability": "RESOURCE_MUTABILITY_UNSPECIFIED" + } + }, + { + "id": "ResourceIdentity/resource_mutability/not_in_zero_omitted", "message": "ResourceIdentity", "valid": false, "rules": [ @@ -6755,7 +7761,21 @@ } }, { - "id": "Restriction/kind/not_in", + "id": "Restriction/kind/not_in_zero_explicit", + "message": "Restriction", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "kind": "RESTRICTION_KIND_UNSPECIFIED", + "permitted": [ + "ai-input" + ] + } + }, + { + "id": "Restriction/kind/not_in_zero_omitted", "message": "Restriction", "valid": false, "rules": [ @@ -7036,7 +8056,18 @@ } }, { - "id": "RetrievalAuthFailure/reason/not_in", + "id": "RetrievalAuthFailure/reason/not_in_zero_explicit", + "message": "RetrievalAuthFailure", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "reason": "RETRIEVAL_AUTH_FAILURE_REASON_UNSPECIFIED" + } + }, + { + "id": "RetrievalAuthFailure/reason/not_in_zero_omitted", "message": "RetrievalAuthFailure", "valid": false, "rules": [ @@ -7193,7 +8224,18 @@ } }, { - "id": "TenantFeeRate/tenant_id/too_short", + "id": "TenantFeeRate/tenant_id/too_short_explicit_empty", + "message": "TenantFeeRate", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "tenant_id": "" + } + }, + { + "id": "TenantFeeRate/tenant_id/too_short_omitted", "message": "TenantFeeRate", "valid": false, "rules": [ @@ -7282,179 +8324,1876 @@ } }, { - "id": "TransactionDenial/exchange/killer#4", - "message": "TransactionDenial", + "id": "TransactionDenial/exchange/killer#4", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": "exchange.example:123456", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/killer#5", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": "exchange.example:", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/killer#6", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": "exchange.example.", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/killer#7", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": "exchange..example", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/killer#8", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": "-exchange.example", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/killer#9", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": "[::1]:443", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/pattern#0", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": "two words", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/pattern#2", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": "!!bad!!", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/pattern#3", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": "\u0000ctl\u0000", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/pattern#4", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": " ", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/pattern#5", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "exchange": "-5", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/exchange/too_long", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "exchange": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionDenial/reason/not_in_zero_explicit", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "exchange": "x", + "reason": "DENIAL_REASON_UNSPECIFIED" + } + }, + { + "id": "TransactionDenial/reason/not_in_zero_omitted", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "exchange": "x" + } + }, + { + "id": "TransactionDenial/reason/undefined", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "enum.defined_only" + ], + "json": { + "exchange": "x", + "reason": 19 + } + }, + { + "id": "TransactionDenial/valid", + "message": "TransactionDenial", + "valid": true, + "json": { + "exchange": "x", + "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_canonical_bytes/too_short_explicit_empty", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "bytes.min_len" + ], + "json": { + "agent_acceptance_canonical_bytes": "", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_canonical_bytes/too_short_omitted", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "bytes.min_len" + ], + "json": { + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature/missing_empty", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature/pattern#0", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "two words", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature/pattern#1", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "1.2.3", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature/pattern#2", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "!!bad!!", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature/pattern#3", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "\u0000ctl\u0000", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature/pattern#4", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": " ", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature/pattern#5", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "-5", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature/pattern#6", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "NaN", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature/pattern#7", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "Infinity", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature/pattern#8", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "1E3", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature_algorithm/const_case", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.const" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "eddsa", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature_algorithm/const_omitted", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.const" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_acceptance_signature_algorithm/const_other", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.const" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "none", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/empty_ok", + "message": "TransactionEvidence", + "valid": true, + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/pattern#0", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_directory_url": "two words", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/pattern#1", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_directory_url": "1.2.3", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/pattern#2", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_directory_url": "!!bad!!", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/pattern#3", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_directory_url": "\u0000ctl\u0000", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/pattern#4", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_directory_url": " ", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/pattern#5", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_directory_url": "-5", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/pattern#6", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_directory_url": "NaN", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/pattern#7", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_directory_url": "Infinity", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/pattern#8", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_directory_url": "1E3", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_directory_url/too_long", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.max_len", + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_directory_url": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_public_key/wrong_len_long", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "bytes.len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJi", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/agent_public_key/wrong_len_short", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "bytes.len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYg==", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/broker/empty_ok", + "message": "TransactionEvidence", + "valid": true, + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "broker": "", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/broker/pattern#0", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "broker": "two words", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/broker/pattern#3", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "broker": "\u0000ctl\u0000", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/broker/pattern#4", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "broker": " ", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/broker/too_long", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "broker": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/created_at/missing", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "required" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/exchange_signing_public_key/wrong_len_long", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "bytes.len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJi", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/exchange_signing_public_key/wrong_len_short", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "bytes.len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYg==", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_canonical_bytes/too_short_explicit_empty", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "bytes.min_len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_canonical_bytes/too_short_omitted", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "bytes.min_len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_id/too_short_explicit_empty", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_id/too_short_omitted", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_json/too_short_explicit_empty", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_json/too_short_omitted", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_sig/missing_empty", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_sig/pattern#0", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "two words", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_sig/pattern#1", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "1.2.3", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_sig/pattern#2", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "!!bad!!", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_sig/pattern#3", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "\u0000ctl\u0000", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_sig/pattern#4", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": " ", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_sig/pattern#5", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "-5", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_sig/pattern#6", + "message": "TransactionEvidence", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "NaN", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + } + }, + { + "id": "TransactionEvidence/offer_sig/pattern#7", + "message": "TransactionEvidence", "valid": false, "rules": [ "string.pattern" ], "json": { - "exchange": "exchange.example:123456", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "Infinity", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/killer#5", - "message": "TransactionDenial", + "id": "TransactionEvidence/offer_sig/pattern#8", + "message": "TransactionEvidence", "valid": false, "rules": [ "string.pattern" ], "json": { - "exchange": "exchange.example:", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "1E3", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/killer#6", - "message": "TransactionDenial", + "id": "TransactionEvidence/offer_sig_algorithm/const_case", + "message": "TransactionEvidence", "valid": false, "rules": [ - "string.pattern" + "string.const" ], "json": { - "exchange": "exchange.example.", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "eddsa", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/killer#7", - "message": "TransactionDenial", + "id": "TransactionEvidence/offer_sig_algorithm/const_omitted", + "message": "TransactionEvidence", "valid": false, "rules": [ - "string.pattern" + "string.const" ], "json": { - "exchange": "exchange..example", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/killer#8", - "message": "TransactionDenial", + "id": "TransactionEvidence/offer_sig_algorithm/const_other", + "message": "TransactionEvidence", "valid": false, "rules": [ - "string.pattern" + "string.const" ], "json": { - "exchange": "-exchange.example", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "none", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/killer#9", - "message": "TransactionDenial", + "id": "TransactionEvidence/request_idempotency_key/too_long", + "message": "TransactionEvidence", "valid": false, "rules": [ - "string.pattern" + "string.max_len" ], "json": { - "exchange": "[::1]:443", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/pattern#0", - "message": "TransactionDenial", + "id": "TransactionEvidence/request_idempotency_key/too_short_explicit_empty", + "message": "TransactionEvidence", "valid": false, "rules": [ - "string.pattern" + "string.min_len" ], "json": { - "exchange": "two words", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/pattern#2", - "message": "TransactionDenial", + "id": "TransactionEvidence/request_idempotency_key/too_short_omitted", + "message": "TransactionEvidence", "valid": false, "rules": [ - "string.pattern" + "string.min_len" ], "json": { - "exchange": "!!bad!!", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/pattern#3", - "message": "TransactionDenial", + "id": "TransactionEvidence/tenant_id/too_long", + "message": "TransactionEvidence", "valid": false, "rules": [ - "string.pattern" + "string.max_len" ], "json": { - "exchange": "\u0000ctl\u0000", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/pattern#4", - "message": "TransactionDenial", + "id": "TransactionEvidence/tenant_id/too_short_explicit_empty", + "message": "TransactionEvidence", "valid": false, "rules": [ - "string.pattern" + "string.min_len" ], "json": { - "exchange": " ", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/pattern#5", - "message": "TransactionDenial", + "id": "TransactionEvidence/tenant_id/too_short_omitted", + "message": "TransactionEvidence", "valid": false, "rules": [ - "string.pattern" + "string.min_len" ], "json": { - "exchange": "-5", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "transaction_id": "tx-seed" } }, { - "id": "TransactionDenial/exchange/too_long", - "message": "TransactionDenial", + "id": "TransactionEvidence/transaction_id/too_long", + "message": "TransactionEvidence", "valid": false, "rules": [ "string.max_len" ], "json": { - "exchange": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } }, { - "id": "TransactionDenial/reason/not_in", - "message": "TransactionDenial", + "id": "TransactionEvidence/transaction_id/too_short_explicit_empty", + "message": "TransactionEvidence", "valid": false, "rules": [ - "enum.not_in" + "string.min_len" ], "json": { - "exchange": "x" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "" } }, { - "id": "TransactionDenial/reason/undefined", - "message": "TransactionDenial", + "id": "TransactionEvidence/transaction_id/too_short_omitted", + "message": "TransactionEvidence", "valid": false, "rules": [ - "enum.defined_only" + "string.min_len" ], "json": { - "exchange": "x", - "reason": 19 + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed" } }, { - "id": "TransactionDenial/valid", - "message": "TransactionDenial", + "id": "TransactionEvidence/valid", + "message": "TransactionEvidence", "valid": true, "json": { - "exchange": "x", - "reason": "DENIAL_REASON_ACCOUNT_INACTIVE" + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" } }, { @@ -7477,7 +10216,8 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } } }, @@ -7498,14 +10238,39 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" + } + } + ] + } + }, + { + "id": "TransactionRequest/idempotency_key/too_short_explicit_empty", + "message": "TransactionRequest", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "idempotency_key": "", + "items": [ + { + "offer": { + "exchange": "exchange.example", + "offer_id": "offer-seed", + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } } ] } }, { - "id": "TransactionRequest/idempotency_key/too_short", + "id": "TransactionRequest/idempotency_key/too_short_omitted", "message": "TransactionRequest", "valid": false, "rules": [ @@ -7520,12 +10285,36 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } } ] } }, + { + "id": "TransactionRequest/items/too_few_explicit_empty", + "message": "TransactionRequest", + "valid": false, + "rules": [ + "repeated.min_items" + ], + "json": { + "idempotency_key": "idem-tx", + "items": [] + } + }, + { + "id": "TransactionRequest/items/too_few_omitted", + "message": "TransactionRequest", + "valid": false, + "rules": [ + "repeated.min_items" + ], + "json": { + "idempotency_key": "idem-tx" + } + }, { "id": "TransactionRequest/valid", "message": "TransactionRequest", @@ -7540,12 +10329,74 @@ "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0" - } + }, + "signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab" } } ] } }, + { + "id": "TransactionState/idempotency_key/too_short_explicit_empty", + "message": "TransactionState", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "idempotency_key": "", + "signed_url_expiry": "2026-01-02T03:04:05Z", + "signed_url_hash": "aGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGg=" + } + }, + { + "id": "TransactionState/idempotency_key/too_short_omitted", + "message": "TransactionState", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "signed_url_expiry": "2026-01-02T03:04:05Z", + "signed_url_hash": "aGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGg=" + } + }, + { + "id": "TransactionState/signed_url_hash/wrong_len_long", + "message": "TransactionState", + "valid": false, + "rules": [ + "bytes.len" + ], + "json": { + "idempotency_key": "idem-tx:offer-seed", + "signed_url_expiry": "2026-01-02T03:04:05Z", + "signed_url_hash": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJi" + } + }, + { + "id": "TransactionState/signed_url_hash/wrong_len_short", + "message": "TransactionState", + "valid": false, + "rules": [ + "bytes.len" + ], + "json": { + "idempotency_key": "idem-tx:offer-seed", + "signed_url_expiry": "2026-01-02T03:04:05Z", + "signed_url_hash": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYg==" + } + }, + { + "id": "TransactionState/valid", + "message": "TransactionState", + "valid": true, + "json": { + "idempotency_key": "idem-tx:offer-seed", + "signed_url_expiry": "2026-01-02T03:04:05Z", + "signed_url_hash": "aGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGg=" + } + }, { "id": "Usage/consumed_unit/empty_ok", "message": "Usage", @@ -7671,7 +10522,8 @@ "json": { "exchange": "https://exchange.example", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7684,7 +10536,8 @@ "json": { "exchange": "exchange.example/register", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7697,7 +10550,8 @@ "json": { "exchange": "exchange.example:0", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7710,7 +10564,8 @@ "json": { "exchange": "exchange.example:99999", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7723,7 +10578,8 @@ "json": { "exchange": "exchange.example?x=1", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7736,7 +10592,8 @@ "json": { "exchange": "user@exchange.example", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7749,7 +10606,8 @@ "json": { "exchange": "exchange.example:123456", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7762,7 +10620,8 @@ "json": { "exchange": "exchange.example:", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7775,7 +10634,8 @@ "json": { "exchange": "exchange.example.", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7788,7 +10648,8 @@ "json": { "exchange": "exchange..example", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7801,7 +10662,8 @@ "json": { "exchange": "-exchange.example", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7814,7 +10676,8 @@ "json": { "exchange": "[::1]:443", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7826,7 +10689,8 @@ ], "json": { "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7839,7 +10703,8 @@ "json": { "exchange": "two words", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7852,7 +10717,8 @@ "json": { "exchange": "!!bad!!", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7865,7 +10731,8 @@ "json": { "exchange": "\u0000ctl\u0000", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7878,7 +10745,8 @@ "json": { "exchange": " ", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7891,7 +10759,8 @@ "json": { "exchange": "-5", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7904,7 +10773,8 @@ "json": { "exchange": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" } }, { @@ -7917,11 +10787,53 @@ "json": { "exchange": "x", "idempotency_key": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" + } + }, + { + "id": "UsageReport/idempotency_key/too_short_explicit_empty", + "message": "UsageReport", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "exchange": "x", + "idempotency_key": "", + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" + } + }, + { + "id": "UsageReport/idempotency_key/too_short_omitted", + "message": "UsageReport", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "exchange": "x", + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" + } + }, + { + "id": "UsageReport/transaction_id/too_short_explicit_empty", + "message": "UsageReport", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "exchange": "x", + "idempotency_key": "x", + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "" } }, { - "id": "UsageReport/idempotency_key/too_short", + "id": "UsageReport/transaction_id/too_short_omitted", "message": "UsageReport", "valid": false, "rules": [ @@ -7929,6 +10841,7 @@ ], "json": { "exchange": "x", + "idempotency_key": "x", "timestamp": "2026-01-02T03:04:05Z" } }, @@ -7939,11 +10852,23 @@ "json": { "exchange": "x", "idempotency_key": "x", - "timestamp": "2026-01-02T03:04:05Z" + "timestamp": "2026-01-02T03:04:05Z", + "transaction_id": "x" + } + }, + { + "id": "UsageReportRejection/reason/not_in_zero_explicit", + "message": "UsageReportRejection", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "reason": "USAGE_REPORT_REJECTION_REASON_UNSPECIFIED" } }, { - "id": "UsageReportRejection/reason/not_in", + "id": "UsageReportRejection/reason/not_in_zero_omitted", "message": "UsageReportRejection", "valid": false, "rules": [ @@ -7971,7 +10896,22 @@ } }, { - "id": "WellKnownManifest/role/not_in", + "id": "WellKnownManifest/role/not_in_zero_explicit", + "message": "WellKnownManifest", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "domain": "exchange.example", + "role": "ROLE_UNSPECIFIED", + "terms_digest": "sha256:abababababababababababababababababababababababababababababababab", + "terms_uri": "https://exchange.example/terms", + "ver": "1.0" + } + }, + { + "id": "WellKnownManifest/role/not_in_zero_omitted", "message": "WellKnownManifest", "valid": false, "rules": [ diff --git a/conformance/corpus_coverage_test.go b/conformance/corpus_coverage_test.go index eda8c602..fc857c23 100644 --- a/conformance/corpus_coverage_test.go +++ b/conformance/corpus_coverage_test.go @@ -3,19 +3,28 @@ // 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 five classes were the blind spots that let money/validation bugs ship +// These classes were the blind spots that let money/validation bugs ship // green: money's divergent value space, the empty-money // positive ” accept, repeated-item length bounds, pattern-derived -// required-presence, and presence-tracked-enum omitted-is-valid. Each is +// required-presence, presence-tracked-enum omitted-is-valid, and the bytes +// length rule shapes corpusgen once skipped silently. 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. +// +// Every class here guards a rule shape that CAN reach the corpus. A guard +// waiting for a shape the contract forbids never runs, so it is coverage on +// paper only; string.max_bytes was such a case and was removed rather than kept +// as forward provisioning (see class 6). package conformance import ( "encoding/json" "strings" "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "google.golang.org/protobuf/reflect/protoreflect" ) // moneyJSONKeys are the proto-JSON (snake_case) field names of every money-typed @@ -107,11 +116,15 @@ func ruleMatches(rules []string, substr string) bool { return false } -// TestCorpusCoverage guards that the generated corpus exercises the five -// field-level rule classes that were 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. +// TestCorpusCoverage guards that the generated corpus exercises the six +// field-level rule classes that shipped bugs (or would have) while every other +// gate stayed green: (1) money-specific killer values, (2) the accepted-empty +// money edge, (3) repeated-item length bounds, (4) pattern-derived required +// presence, (5) presence-tracked-enum omitted-is-valid, and (6) the +// bytes.len / bytes.min_len rule shapes. Each subtest is an +// independent coverage assertion whose failure names exactly which mutant +// class the corpus lacks; a class fails RED until corpusgen emits its mutants +// and turns red again if a regeneration drops them. func TestCorpusCoverage(t *testing.T) { cases := loadCorpus(t) @@ -217,4 +230,73 @@ func TestCorpusCoverage(t *testing.T) { "omission (the common ingest case) is unguarded, so dropping the 'optional' keyword would "+ "reject every omitting feed with all gates green (%d cases scanned)", len(cases)) }) + + // Class 6 — bytes.len / bytes.min_len. These rule shapes (the evidence rows' + // Ed25519 keys, canonical-bytes fields, signed_url_hash) once reached ZERO + // corpus cases because corpusgen skipped unknown rule shapes silently. Each + // shape is required only while the SCHEMA carries it (derived from the + // contract descriptors, not hardcoded), so dropping a rule from the contract + // does not strand the guard, and reintroducing one arms it again. + // + // string.max_bytes is NOT part of this class. The byte-vs-character bug it + // would guard (protoschema renders max_bytes as a CHARACTER-counting + // minLength/maxLength) is prevented one step earlier, at the contract: + // requiredgen's assertNoStringByteLengthRules panics on any string + // byte-length rule at any rule level, so the rule cannot be committed and no + // corpus case for it can ever exist. A branch here waiting for one would + // never run. If the sdk-types pipeline gains a byte-count refine and that + // panic is lifted, the corpus coverage for max_bytes comes back with it. + t.Run("invalid_bytes_rules", func(t *testing.T) { + bytesLen, bytesMinLen := schemaRuleShapes() + needRules := map[string]bool{ + "bytes.len": bytesLen, + "bytes.min_len": bytesMinLen, + } + for rule, need := range needRules { + if !need { + continue + } + found := false + for _, c := range cases { + if !c.Valid && ruleMatches(c.Rules, rule) { + found = true + break + } + } + if !found { + t.Errorf("MISSING CLASS 6 (%s): the schema carries a %s rule but no INVALID "+ + "case trips it; corpusgen is silently skipping this rule shape again (%d cases scanned)", + rule, rule, len(cases)) + } + } + }) +} + +// schemaRuleShapes reports which of the once-skipped rule shapes the contract +// schema currently carries. The class 6 guard requires corpus coverage only for +// shapes that are actually in the schema. +// +// The walk goes through EachRuleSet (contract.go), which panics on a resolver +// error. This guard used to resolve the rules itself and skip an unresolvable +// field, which read as "the schema has no such rule" and turned the whole class-6 +// requirement off with every gate green. +// +// The sweep descends into repeated.items, which is WIDER than corpusgen's edge +// scope on purpose: a bytes length rule added at item level would arm this guard +// while corpusgen emits no mutant for it, so the guard fails loudly instead of +// staying quiet about a rule shape nothing exercises. Rule membership is read +// through MustBytesLength so "the schema carries bytes.len" means the same thing +// here as it does in corpusgen and the bytes_len.json manifest. +func schemaRuleShapes() (bytesLen, bytesMinLen bool) { + EachRuleSet(func(_ protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, _ string, fr *validate.FieldRules) { + if r := MustBytesLength(fd, fr); r != nil { + switch r.Kind { + case "len": + bytesLen = true + case "min_len": + bytesMinLen = true + } + } + }) + return } diff --git a/conformance/corpusgen/main.go b/conformance/corpusgen/main.go index 12685d9e..2eb9110b 100644 --- a/conformance/corpusgen/main.go +++ b/conformance/corpusgen/main.go @@ -68,7 +68,12 @@ func seeds() map[string]proto.Message { // and the audience statement of a TransactionRequest), so a seed without it // is not a valid baseline — seeds bypass auto-fill entirely. offer := func() *rampv1.Offer { - return &rampv1.Offer{OfferId: "offer-seed", Exchange: "exchange.example", Pricing: pricing()} + return &rampv1.Offer{ + OfferId: "offer-seed", + Exchange: "exchange.example", + Pricing: pricing(), + Signature: strings.Repeat("ab", 64), + } } return map[string]proto.Message{ "Pricing": pricing(), @@ -81,7 +86,7 @@ func seeds() map[string]proto.Message { "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", Exchange: "exchange.example", Reason: rampv1.DisputeReason_DISPUTE_REASON_CONTENT_MISMATCH}, + "DisputeRequest": &rampv1.DisputeRequest{IdempotencyKey: "idem-dr", Exchange: "exchange.example", TransactionId: "tx-dr", Reason: rampv1.DisputeReason_DISPUTE_REASON_CONTENT_MISMATCH}, // Reflected-Offer execute contract (items-only): Offer is // the required sub-message of TransactionItem (auto-fill needs its seed), // and TransactionRequest needs a valid 1-item items[] baseline because its @@ -120,6 +125,40 @@ func seeds() map[string]proto.Message { }, "TenantFeeRate": &rampadminv1.TenantFeeRate{TenantId: "tenant-seed", FeeRateBps: 0}, "ReportingPolicy": &rampadminv1.ReportingPolicy{TenantId: "tenant-seed", RequiredFields: []string{"x"}}, + // ramp.admin.v1 evidence-read payloads (required sub-messages of + // GetTransactionEvidenceResponse). Seeded wholesale: they carry rule + // shapes auto-fill does not handle — bytes rules (len=32 keys, + // non-empty canonical bytes) and required Timestamp fields. + "TransactionEvidence": &rampadminv1.TransactionEvidence{ + TransactionId: "tx-seed", + TenantId: "tenant-seed", + OfferId: "offer-seed", + OfferJson: `{"offer_id":"offer-seed"}`, + OfferCanonicalBytes: []byte(`{"offer_id":"offer-seed"}`), + // "EdDSA" is the content-signature label the contract pins (see + // offer_sig_algorithm's comment); "ed25519" is the RFC 9421 + // HTTP-request-signature label and must not appear here. + OfferSig: strings.Repeat("ab", 64), + OfferSigAlgorithm: "EdDSA", + ExchangeSigningPublicKey: []byte(strings.Repeat("k", 32)), + AgentAcceptanceSignature: strings.Repeat("ab", 64), + AgentAcceptanceCanonicalBytes: []byte(`{"requester_id":"agent-seed"}`), + AgentAcceptanceSignatureAlgorithm: "EdDSA", + RequesterId: "agent-seed", + RequesterDomain: "agent.example", + RequestIdempotencyKey: "idem-tx", + AgentPublicKey: []byte(strings.Repeat("k", 32)), + CreatedAt: timestamppb.New(fixedTime), + }, + "TransactionState": &rampadminv1.TransactionState{ + IdempotencyKey: "idem-tx:offer-seed", + SignedUrlExpiry: timestamppb.New(fixedTime), + SignedUrlHash: []byte(strings.Repeat("h", 32)), + }, + "ReportingObligationState": &rampadminv1.ReportingObligationState{ + State: rampadminv1.ObligationState_OBLIGATION_STATE_PENDING, + WindowEnd: timestamppb.New(fixedTime), + }, } } @@ -132,7 +171,12 @@ func seeds() map[string]proto.Message { // FIRST entry that matches, so appending cannot change what any existing field // auto-fills to, and the corpus diff stays additive. Inserting anywhere else can // silently re-value every field a new earlier entry happens to satisfy. -var stringSamples = []string{"x", "ai-train", "tokens", "accesses", "0", "sha256:" + strings.Repeat("ab", 32), ""} +// The trailing entry is a 128-character lowercase hex string: one Ed25519 +// signature, the shape both planes' signature fields now require. Appended +// rather than seeded per message, because the shape belongs to a rule and not +// to any one message — the agent plane and the evidence row auto-fill from the +// same sample, which is what makes their corpus baselines comparable. +var stringSamples = []string{"x", "ai-train", "tokens", "accesses", "0", "sha256:" + strings.Repeat("ab", 32), "", strings.Repeat("ab", 64)} // 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 @@ -193,8 +237,15 @@ func main() { 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 fr := rules(fd); fr != nil { + // Fail loud on any rule member this generator cannot classify — + // an allowlist alone fails OPEN: a field carrying only an + // unrecognized shape (how the first bytes rules shipped + // uncovered) would get zero corpus cases with every gate green. + assertRulesClassified(string(md.Name()), fd, fr) + if hasConstraint(fr) { + constrained = append(constrained, fd) + } } } if len(constrained) == 0 { @@ -211,7 +262,14 @@ func main() { cases = append(cases, mkCase(short+"/valid", short, base.Interface(), true, nil, v)) for _, fd := range constrained { - for _, e := range edges(fd, rules(fd), sd) { + es := edges(fd, rules(fd), sd) + // A constrained field with zero edges means edges() does not know the + // field's rule shape — the corpus would silently carry no case for it + // (how the first bytes rules shipped uncovered). Fail the run instead. + if len(es) == 0 { + die("field %s.%s has rules but produced no edges — teach edges() its rule shape", short, fd.Name()) + } + for _, e := range es { m := proto.Clone(base.Interface()).ProtoReflect() e.apply(m) verr := v.Validate(m.Interface()) @@ -232,7 +290,7 @@ func main() { 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)) + cases = append(cases, mkCasePatched(id, short, m.Interface(), false, ids, e.postJSON)) } } }) @@ -250,8 +308,8 @@ func main() { // per message-level (cross-field) CEL rule, each pinned to Go protovalidate's // verdict. This is kept SEPARATE from cases.json on purpose — cases.json is the // FIELD-level corpus the generated Pydantic/Zod clients are tested against today, -// and those clients do not yet enforce cross-field CEL (the symmetric gap noted -// in ramp-sdk-api.md). The SDK L1 validator (helpers.Validate) is tested +// and those clients do not yet enforce cross-field CEL — the +// symmetric gap. The SDK L1 validator (helpers.Validate) is tested // against THIS file, and a future TS/Python L1 that authors the cross-field rules // by hand consumes it as their oracle — without breaking the field-level parity. func writeCrossField(v protovalidate.Validator) { @@ -496,6 +554,11 @@ type edge struct { 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. + // postJSON patches the marshaled JSON object AFTER protojson. Needed for + // wire shapes protojson cannot produce from a proto value: an explicit-empty + // implicit-presence field ("field": "", "items": []) is dropped by protojson, + // yet is a distinct client parse path from omission. + postJSON func(obj map[string]any) } func edges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules, sd map[string]proto.Message) []edge { @@ -511,6 +574,8 @@ func edges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules, sd map[stri es = append(es, enumEdges(fd, fr.GetEnum())...) case protoreflect.StringKind: es = append(es, stringEdges(fd, fr.GetString())...) + case protoreflect.BytesKind: + es = append(es, bytesEdges(fd, fr)...) case protoreflect.Int64Kind: if r := fr.GetInt64(); r != nil { if _, ok := r.GetGreaterThan().(*validate.Int64Rules_Gte); ok { @@ -603,9 +668,28 @@ 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)) - }}) + set := func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfEnum(nn)) } + zero := fd.Enum().Values().ByNumber(nn) + // The zero-floor pair, same shape as stringEdges, bytesEdges and listEdges: + // protojson DROPS a zero-valued implicit-presence enum, so for not_in:[0] on + // a non-optional field the emitted JSON OMITS the field entirely. That case + // pins "omitted is rejected" and its id says so. The explicit UNSPECIFIED + // string is a SECOND client parse path — a generated client drops + // *_UNSPECIFIED from its enum, so it must refuse the name rather than the + // absence — and protojson cannot produce that shape from a proto value, so + // it needs a postJSON patch of its own. A presence-tracked field (or a + // nonzero not_in value) serializes explicitly already and keeps the plain + // label with no companion. + if n == 0 && !fd.HasPresence() && zero != nil { + name := string(fd.Name()) + zeroName := string(zero.Name()) + es = append(es, + edge{label: "not_in_zero_omitted", want: "enum.not_in", apply: set}, + edge{label: "not_in_zero_explicit", want: "enum.not_in", apply: set, + postJSON: func(obj map[string]any) { obj[name] = zeroName }}) + continue + } + es = append(es, edge{label: "not_in", want: "enum.not_in", apply: set}) } // A presence-tracked (proto3 optional) enum that rejects its zero via not_in // still ACCEPTS omission: protovalidate skips an unset optional field's rule, @@ -632,6 +716,28 @@ func enumEdges(fd protoreflect.FieldDescriptor, r *validate.EnumRules) []edge { func stringEdges(fd protoreflect.FieldDescriptor, r *validate.StringRules) []edge { var es []edge + if r.Const != nil { + c := r.GetConst() + // Two invalid mutants: an unrelated label (the algorithm-confusion + // probe — a client must reject a claimed "none") and a case variant, + // so a client comparing case-insensitively diverges from Go. The case + // variant is skipped when flipping case cannot change the value. + es = append(es, edge{label: "const_other", want: "string.const", + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfString("none")) }}) + if variant := strings.ToLower(c); variant != c { + es = append(es, edge{label: "const_case", want: "string.const", + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfString(variant)) }}) + } else if variant := strings.ToUpper(c); variant != c { + es = append(es, edge{label: "const_case", want: "string.const", + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfString(variant)) }}) + } + if c != "" && !fd.HasPresence() { + // The cleared value "" also violates the const, so omission must be + // rejected — same presence collapse as min_len's too_short_omitted. + es = append(es, edge{label: "const_omitted", want: "string.const", + apply: func(m protoreflect.Message) { m.Clear(fd) }}) + } + } 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. @@ -668,17 +774,107 @@ func stringEdges(fd protoreflect.FieldDescriptor, r *validate.StringRules) []edg } 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)) }}) + set := func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfString(s)) } + if n == 1 && !fd.HasPresence() { + // Honest labels: same omission collapse as bytesEdges — one below a + // min_len=1 floor is "" and protojson drops it, so the case pins + // omission; the explicit "" wire shape is its own case. + name := string(fd.Name()) + es = append(es, + edge{label: "too_short_omitted", want: "string.min_len", apply: set}, + edge{label: "too_short_explicit_empty", want: "string.min_len", apply: set, + postJSON: func(obj map[string]any) { obj[name] = "" }}) + } else { + es = append(es, edge{label: "too_short", want: "string.min_len", apply: set}) + } } 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)) }}) } + // FORWARD-PROVISIONED, currently unreachable: no contract field may carry + // string.max_bytes while requiredgen's assertNoStringByteLengthRules panics + // on it (protoschema renders it as a CHARACTER count, so the generated + // clients would diverge from Go). The mutants below — and the max_bytes + // entry in classifiedRuleMembers — are what this generator would emit the day + // the sdk-types pipeline gets a byte-count refine and that panic is lifted. + // + // They stay because the cost is one branch inside a generator that already + // walks the rule. The class-6 corpus coverage guard did NOT stay: a coverage + // assertion that can never run is coverage on paper, and it also carried + // helpers no test ever exercised. Reintroducing max_bytes means writing the + // guard then, against the mutants below. + if n := r.GetMaxBytes(); n > 0 { + ascii := strings.Repeat("a", int(n)+1) + es = append(es, edge{label: "too_many_bytes", want: "string.max_bytes", + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfString(ascii)) }}) + // max_bytes counts BYTES, not characters. This mutant is over the limit in + // bytes but well under it in characters ("é" is 2 UTF-8 bytes), so a client + // that counts characters accepts it and diverges from Go's verdict. + multibyte := strings.Repeat("é", int(n)/2+1) + es = append(es, edge{label: "too_many_bytes_multibyte", want: "string.max_bytes", + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfString(multibyte)) }}) + } return es } +// bytesEdges emits boundary mutants for bytes rules. An exact-length rule +// (bytes.len) gets both a shorter and a longer mutant — one alone would let a +// client enforce only min or only max and stay green. min_len's mutant at +// len-1 is the empty value when min_len==1; protovalidate still reports +// bytes.min_len for it (no presence on proto3 singular bytes), which the +// generator's oracle check confirms at emit time. +// +// The rule is read through conformance.MustBytesLength so this generator, the +// bytes_len.json manifest and the class-6 coverage guard cannot disagree about +// which fields carry a length rule. It also means a zero-valued length rule dies +// there with the field's name instead of reaching bytesOf(-1) and surfacing as +// "strings: negative Repeat count". +func bytesEdges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules) []edge { + var es []edge + r := conformance.MustBytesLength(fd, fr) + if r == nil { + return es + } + if r.Kind == "len" { + short := bytesOf(int(r.Value) - 1) + long := bytesOf(int(r.Value) + 1) + es = append(es, + edge{label: "wrong_len_short", want: "bytes.len", + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfBytes(short)) }}, + edge{label: "wrong_len_long", want: "bytes.len", + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfBytes(long)) }}) + } + if n := r.Value; r.Kind == "min_len" { + short := bytesOf(int(n) - 1) + set := func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfBytes(short)) } + if n == 1 && !fd.HasPresence() { + // Honest labels: one below a min_len=1 floor is EMPTY bytes, and + // protojson DROPS an empty implicit-presence field — the emitted + // JSON omits the field entirely, so the case pins "omission is + // rejected" (same collapse as enum not_in_zero_omitted). The + // explicit-empty wire shape ("field": "") is a different client + // parse path, so it is emitted as its own case via a JSON-layer + // patch; Go's verdict is identical for both. + name := string(fd.Name()) + es = append(es, + edge{label: "too_short_omitted", want: "bytes.min_len", apply: set}, + edge{label: "too_short_explicit_empty", want: "bytes.min_len", apply: set, + postJSON: func(obj map[string]any) { obj[name] = "" }}) + } else { + es = append(es, edge{label: "too_short", want: "bytes.min_len", apply: set}) + } + } + return es +} + +// bytesOf is n copies of a fixed non-zero byte — deterministic filler for +// length-rule mutants. +func bytesOf(n int) []byte { + return []byte(strings.Repeat("b", n)) +} + // 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. @@ -699,6 +895,27 @@ func listEdges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules, sd map[ item := itemRules(fr) good, err := validItem(fd, item, sd) // a valid item value must(err) + if r != nil && r.GetMinItems() > 0 { + // The baseline is valid, so it holds at least min_items valid items; + // truncating to one below the floor trips ONLY repeated.min_items. + n := int(r.GetMinItems()) - 1 + truncate := func(m protoreflect.Message) { m.Mutable(fd).List().Truncate(n) } + if n == 0 { + // Honest labels: one below a min_items=1 floor is the EMPTY list, + // and protojson DROPS an empty repeated field — the emitted JSON + // omits the field entirely, so the case pins "omission is + // rejected". The explicit-empty wire shape ("items": []) is a + // different client parse path, emitted as its own case via a + // JSON-layer patch; Go's verdict is identical for both. + name := string(fd.Name()) + es = append(es, + edge{label: "too_few_omitted", want: "repeated.min_items", apply: truncate}, + edge{label: "too_few_explicit_empty", want: "repeated.min_items", apply: truncate, + postJSON: func(obj map[string]any) { obj[name] = []any{} }}) + } else { + es = append(es, edge{label: "too_few", want: "repeated.min_items", apply: truncate}) + } + } 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) { @@ -753,48 +970,152 @@ func listEdges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules, sd map[ // ── rule helpers ───────────────────────────────────────────────────────────── +// rules resolves fd's field rules through the shared helper, which panics on a +// resolver failure: it is not "no rules", and treating it as nil would silently +// drop every corpus case the field should have. func rules(fd protoreflect.FieldDescriptor) *validate.FieldRules { - fr, err := protovalidate.ResolveFieldRules(fd) - if err != nil { - return nil - } - return fr + return conformance.FieldRules(fd) } // itemRules is the per-item FieldRules of a repeated field (repeated.items). func itemRules(fr *validate.FieldRules) *validate.FieldRules { return fr.GetRepeated().GetItems() } +// classifiedRuleMembers is the closed set of (rule kind, member) pairs +// hasConstraint/edges() know how to turn into corpus cases, and the SINGLE +// inventory of them: hasConstraint reads this table rather than restating it. +// Field-level `cel` and `required` are handled outside it (cel is server-only by +// scope; required gets the `missing` edge). assertRulesClassified dies on +// anything set outside this table, so a new rule shape MUST be taught to edges() +// (and added here) before it can ship — the allowlist fails CLOSED. +var classifiedRuleMembers = map[string]map[string]bool{ + // string.max_bytes is classified but FORWARD-PROVISIONED: requiredgen's + // assertNoStringByteLengthRules currently forbids it contract-wide (see the + // max_bytes edge in stringEdges for the full position). + "string": {"const": true, "pattern": true, "min_len": true, "max_len": true, "max_bytes": true}, + "enum": {"defined_only": true, "not_in": true}, + "bytes": {"len": true, "min_len": true}, + "int64": {"gte": true}, + "int32": {"gte": true, "gt": true, "lt": true, "lte": true}, + "double": {"gte": true, "gt": true, "lt": true, "lte": true}, + "repeated": {"min_items": true, "max_items": true, "unique": true, "items": true}, +} + +// classifiedItemMembers is the same closed set for repeated.items sub-rules — +// listEdges only mutates string item rules today. +var classifiedItemMembers = map[string]map[string]bool{ + "string": {"pattern": true, "min_len": true, "max_len": true}, +} + +// assertRulesClassified dies if fr carries any set rule member outside the +// classified tables. This is the fail-closed complement to hasConstraint: +// hasConstraint answers "does this field get cases", this answers "is every +// rule on this field one the generator understands". +func assertRulesClassified(short string, fd protoreflect.FieldDescriptor, fr *validate.FieldRules) { + checkMembers(short, fd, fr, classifiedRuleMembers, "") + if it := fr.GetRepeated().GetItems(); it != nil { + checkMembers(short, fd, it, classifiedItemMembers, "repeated.items.") + } +} + +func checkMembers(short string, fd protoreflect.FieldDescriptor, fr *validate.FieldRules, known map[string]map[string]bool, prefix string) { + fr.ProtoReflect().Range(func(f protoreflect.FieldDescriptor, v protoreflect.Value) bool { + name := string(f.Name()) + if name == "cel" || name == "required" { + return true + } + // A non-message member (e.g. `ignore`) changes rule semantics in ways + // this generator does not model — unclassified, so fail closed. + if f.Kind() != protoreflect.MessageKind || f.IsList() { + die("field %s.%s carries rule %s%s — teach edges()/hasConstraint its shape (and classifiedRuleMembers) before shipping it", short, fd.Name(), prefix, name) + } + members, ok := known[name] + if !ok { + die("field %s.%s carries rule %s%s — teach edges()/hasConstraint its shape (and classifiedRuleMembers) before shipping it", short, fd.Name(), prefix, name) + } + v.Message().Range(func(mf protoreflect.FieldDescriptor, _ protoreflect.Value) bool { + if !members[string(mf.Name())] { + die("field %s.%s carries rule %s%s.%s — teach edges()/hasConstraint its shape (and classifiedRuleMembers) before shipping it", short, fd.Name(), prefix, name, mf.Name()) + } + return true + }) + return true + }) +} + // 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. +// +// It is DERIVED from the classified tables above, not from a third hand-written +// list of the same rules. The hand-written version drifted: the tables (and +// listEdges) covered repeated.items.string.min_len/max_len while this function's +// repeated branch fired only on an item pattern, so a repeated field whose only +// rules were item lengths would pass the fail-closed classification and then get +// ZERO corpus cases with every gate green. One inventory, one place to edit. +// +// Membership is by rule member SET, not by value: an explicitly zero-valued rule +// (min_len: 0, unique: false) now counts as a constraint and produces no edges, +// which the caller turns into a loud "has rules but produced no edges" failure. +// That is the intended direction — such a rule constrains nothing and is a +// contract error (bytesgen fails the same way on a zero-valued bytes length). 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 i := fr.GetInt32(); i != nil && (i.GetGreaterThan() != nil || i.GetLessThan() != nil) { - return true - } - if d := fr.GetDouble(); d != nil && (d.GetGreaterThan() != nil || d.GetLessThan() != nil) { + found := false + fr.ProtoReflect().Range(func(f protoreflect.FieldDescriptor, v protoreflect.Value) bool { + name := string(f.Name()) + // `cel` is server-scope and `required` is handled above; anything not in + // the table already died in assertRulesClassified, which runs first. + if name == "cel" || name == "required" || f.Kind() != protoreflect.MessageKind || f.IsList() { + return true + } + members, ok := classifiedRuleMembers[name] + if !ok { + return true + } + v.Message().Range(func(mf protoreflect.FieldDescriptor, mv protoreflect.Value) bool { + mname := string(mf.Name()) + // repeated.items is a nested FieldRules, so it contributes through the + // item table (what listEdges actually mutates), not by its presence. + if name == "repeated" && mname == "items" { + if hasItemConstraint(mv.Message()) { + found = true + } + return true + } + if members[mname] { + found = true + } + return true + }) return true - } - if r := fr.GetRepeated(); r != nil { - if r.GetMaxItems() > 0 || r.GetMinItems() > 0 || r.GetUnique() { + }) + return found +} + +// hasItemConstraint is hasConstraint for a repeated field's per-item rules, +// derived the same way from classifiedItemMembers. +func hasItemConstraint(item protoreflect.Message) bool { + found := false + item.Range(func(f protoreflect.FieldDescriptor, v protoreflect.Value) bool { + name := string(f.Name()) + if name == "cel" || name == "required" || f.Kind() != protoreflect.MessageKind || f.IsList() { return true } - if it := r.GetItems(); it != nil && it.GetString().GetPattern() != "" { + members, ok := classifiedItemMembers[name] + if !ok { return true } - } - return false + v.Message().Range(func(mf protoreflect.FieldDescriptor, _ protoreflect.Value) bool { + if members[string(mf.Name())] { + found = true + } + return true + }) + return true + }) + return found } func firstAllowedEnum(ed protoreflect.EnumDescriptor, r *validate.EnumRules) protoreflect.EnumNumber { @@ -826,6 +1147,11 @@ func undefinedEnum(ed protoreflect.EnumDescriptor) protoreflect.EnumNumber { } func validString(r *validate.StringRules) (string, bool) { + if r != nil && r.Const != nil { + // A const admits exactly one value; the sample search below cannot + // discover it. + return r.GetConst(), true + } var re *regexp.Regexp if r != nil && r.GetPattern() != "" { re = regexp.MustCompile(r.GetPattern()) @@ -872,12 +1198,21 @@ func gte(r *validate.Int64Rules) int64 { // ── output / misc ──────────────────────────────────────────────────────────── func mkCase(id, short string, m proto.Message, valid bool, ids []string, _ protovalidate.Validator) Case { + return mkCasePatched(id, short, m, valid, ids, nil) +} + +// mkCasePatched is mkCase with an optional JSON-layer patch (edge.postJSON) +// applied between protojson marshal and canonical re-marshal. +func mkCasePatched(id, short string, m proto.Message, valid bool, ids []string, patch func(obj map[string]any)) 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) + obj := map[string]any{} + must(json.Unmarshal(b, &obj)) + if patch != nil { + patch(obj) + } + canon, err := json.Marshal(obj) must(err) sort.Strings(ids) return Case{ID: id, Message: short, Valid: valid, Rules: dedupe(ids), JSON: canon} diff --git a/conformance/descriptor_invariants_test.go b/conformance/descriptor_invariants_test.go index ad6f6021..e0736de0 100644 --- a/conformance/descriptor_invariants_test.go +++ b/conformance/descriptor_invariants_test.go @@ -17,9 +17,11 @@ import ( "strings" "testing" + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protovalidate "buf.build/go/protovalidate" "google.golang.org/protobuf/reflect/protoreflect" + rampadminv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/admin/v1" rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" ) @@ -136,7 +138,7 @@ func TestRequiredEnumDiscriminatorsRejectZero(t *testing.T) { // value: a field-level enum not_in:[0], a field-level CEL mentioning UNSPECIFIED, // or a message-level CEL referencing `this.` together with UNSPECIFIED. func fieldRejectsZero(md protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor) bool { - if fr, err := protovalidate.ResolveFieldRules(fd); err == nil && fr != nil { + if fr := FieldRules(fd); fr != nil { if er := fr.GetEnum(); er != nil { for _, v := range er.GetNotIn() { if v == 0 { @@ -198,8 +200,7 @@ func TestCELIDPrefixMatchesMessage(t *testing.T) { } } for j := 0; j < md.Fields().Len(); j++ { - fd := md.Fields().Get(j) - if fr, err := protovalidate.ResolveFieldRules(fd); err == nil && fr != nil { + if fr := FieldRules(md.Fields().Get(j)); fr != nil { for _, r := range fr.GetCel() { check(r.GetId()) } @@ -228,6 +229,150 @@ func messageSnake(name string) string { return b.String() } +// ─── INV-4: every bytes length rule is well formed ─────────────────────────── +// +// A bytes length rule is ill formed when it sets len and min_len together, or +// when its value is zero. Both make "does this field carry a length rule" answer +// differently depending on whether the asker reads by presence or by value, and +// four consumers ask it: corpusgen (mutants), bytesgen (the bytes_len.json +// manifest), requiredgen (the required-fields manifest) and the class-6 corpus +// coverage guard. conformance.BytesLength rejects both shapes for all of them. +// +// This test exists so `go test ./conformance` is where a developer SEES the +// problem. Without it, ci-local.sh reaches corpusgen (line 66) before any test, +// and a bytes.len:0 used to surface there as "strings: negative Repeat count" +// from bytesOf(-1) — a message that names neither the field nor the rule. +// corpusgen now dies with the same field-naming text this test prints, and the +// test reports every offender instead of stopping at the first. +func TestBytesLengthRulesWellFormed(t *testing.T) { + checked := 0 + EachRuleSet(func(md protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, prefix string, fr *validate.FieldRules) { + if fr.GetBytes() == nil { + return + } + checked++ + if _, err := BytesLength(fr); err != nil { + t.Errorf("%s.%s (%sbytes) %v", md.Name(), fd.Name(), prefix, err) + } + }) + if checked == 0 { + t.Fatal("no bytes rules found in the contract — this guard would be vacuous; " + + "if the evidence rows really lost every bytes rule, delete the guard deliberately") + } +} + +// TestBytesLengthAccessor is the anti-vacuity control for INV-4 and for the +// shared accessor every consumer now reads: the walk above passes trivially if +// BytesLength stops returning errors. It also pins the two-level descent, which +// is what keeps a repeated.items rule from walking past a fail-closed guard. +func TestBytesLengthAccessor(t *testing.T) { + lenRule := func(n uint64) *validate.FieldRules { + return &validate.FieldRules{Type: &validate.FieldRules_Bytes{Bytes: &validate.BytesRules{Len: &n}}} + } + minLenRule := func(n uint64) *validate.FieldRules { + return &validate.FieldRules{Type: &validate.FieldRules_Bytes{Bytes: &validate.BytesRules{MinLen: &n}}} + } + + if r, err := BytesLength(lenRule(32)); err != nil || r == nil || r.Kind != "len" || r.Value != 32 { + t.Errorf("BytesLength(bytes.len:32) = %+v, %v; want {len 32}, nil", r, err) + } + if r, err := BytesLength(minLenRule(1)); err != nil || r == nil || r.Kind != "min_len" || r.Value != 1 { + t.Errorf("BytesLength(bytes.min_len:1) = %+v, %v; want {min_len 1}, nil", r, err) + } + if r, err := BytesLength(&validate.FieldRules{}); err != nil || r != nil { + t.Errorf("BytesLength(no bytes rule) = %+v, %v; want nil, nil", r, err) + } + // The two ill-formed shapes MUST be errors, not a silent "no length rule" — + // that reading is what let a zero-valued rule enter one consumer's view of the + // contract and vanish from another's. + if _, err := BytesLength(lenRule(0)); err == nil { + t.Error("BytesLength(bytes.len:0) returned no error; an explicit zero length must be a contract error") + } + if _, err := BytesLength(minLenRule(0)); err == nil { + t.Error("BytesLength(bytes.min_len:0) returned no error; an explicit zero floor must be a contract error") + } + both := lenRule(32) + one := uint64(1) + both.GetBytes().MinLen = &one + if _, err := BytesLength(both); err == nil { + t.Error("BytesLength(bytes.len:32 + bytes.min_len:1) returned no error; one of the two rules would be enforced and the other silently dropped") + } + + // The generators call MustBytesLength, so its panic — not the error above — + // is what a developer actually reads. It must name the offending field: the + // message it replaced was "strings: negative Repeat count" from bytesOf(-1). + fd := (&rampadminv1.TransactionState{}).ProtoReflect().Descriptor().Fields().ByName("signed_url_hash") + if fd == nil { + t.Fatal("TransactionState has no signed_url_hash field — pick another bytes field for this control") + } + func() { + defer func() { + r := recover() + if r == nil { + t.Error("MustBytesLength did not panic on bytes.len:0 — a generator would read it as 'no length rule' and carry on") + return + } + if msg, _ := r.(string); !strings.Contains(msg, string(fd.FullName())) { + t.Errorf("MustBytesLength panicked with %q, which does not name the field %s", r, fd.FullName()) + } + }() + MustBytesLength(fd, lenRule(0)) + }() +} + +// TestRuleSetsDescendIntoItems pins the descent the fail-closed guards depend on. +// The guards' predicates run per rule SET, so a top-level-only sweep leaves them +// open on every repeated field. A repeated.items.string.max_bytes rule — exactly +// the shape requiredgen's assertNoStringByteLengthRules forbids contract-wide — +// must be visible through RuleSets and invisible to a top-level-only read. +func TestRuleSetsDescendIntoItems(t *testing.T) { + maxBytes := uint64(64) + fr := &validate.FieldRules{Type: &validate.FieldRules_Repeated{Repeated: &validate.RepeatedRules{ + Items: &validate.FieldRules{Type: &validate.FieldRules_String_{String_: &validate.StringRules{MaxBytes: &maxBytes}}}, + }}} + + if _, _, ok := StringByteLength(fr); ok { + t.Fatal("StringByteLength saw an item-level rule in the top-level rule set — the test's premise is wrong") + } + sets := RuleSets(fr) + if len(sets) != 2 || sets[0].Prefix != "" || sets[1].Prefix != "repeated.items." { + t.Fatalf("RuleSets returned %d sets with prefixes %q — want the field's own rules then repeated.items.", len(sets), prefixesOf(sets)) + } + found := false + for _, rs := range sets { + if member, n, ok := StringByteLength(rs.Rules); ok { + found = true + if rs.Prefix != "repeated.items." || member != "max_bytes" || n != 64 { + t.Errorf("StringByteLength found %s%s:%d; want repeated.items.max_bytes:64", rs.Prefix, member, n) + } + } + } + if !found { + t.Error("a repeated.items.string.max_bytes rule was invisible to the sweep — every guard built on it is open on repeated fields") + } + + // The contract really uses this shape, so the descent is not theoretical: the + // real walk must reach item rules too, or the guards are wide on live fields. + items := 0 + EachRuleSet(func(_ protoreflect.MessageDescriptor, _ protoreflect.FieldDescriptor, prefix string, _ *validate.FieldRules) { + if prefix == "repeated.items." { + items++ + } + }) + if items == 0 { + t.Error("EachRuleSet visited no repeated.items rule set, but the contract declares them " + + "(ListRequest.filters and five ramp.v1 fields) — the descent regressed") + } +} + +func prefixesOf(sets []RuleSet) []string { + out := make([]string, 0, len(sets)) + for _, rs := range sets { + out = append(out, rs.Prefix) + } + return out +} + // ─── INV-3 removed ─────────────────────────────────────────────────────────── // // The namespaced-token format is now expressed as STANDARD protovalidate diff --git a/conformance/domain_constraint_test.go b/conformance/domain_constraint_test.go index f9c7f230..4ee85636 100644 --- a/conformance/domain_constraint_test.go +++ b/conformance/domain_constraint_test.go @@ -53,6 +53,13 @@ const digestPattern = `^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f] const wantDigestFields = 3 +// hexSignaturePattern is the detached-signature shape, quoted from the proto. It +// is carried by Offer.signature, ResourceAttestation.signature, +// AgentAcceptance.signature and the two TransactionEvidence copies of those. +const hexSignaturePattern = `^[0-9A-Fa-f]{128}$` + +const wantHexSignatureFields = 5 + func fieldNames(fs []domainField) string { names := make([]string, 0, len(fs)) for _, f := range fs { @@ -87,6 +94,55 @@ func TestDigestPatternMembership(t *testing.T) { } } +// TestHexSignaturePatternAdmits pins WHAT the detached-signature rule accepts, +// which the restated-rule drift gate cannot. +// +// That gate proves the five copies stay EQUAL. Move them all in step and they +// are still equal, so a rule that changed shape passes it. Two such mutations +// were measured against this repo. Narrowing every copy to lowercase-only is +// caught, but only by accident: evidence_offline_verify_test.go stores +// agent_acceptance_signature in uppercase, and a value that no longer validates +// fails that test. Widening the character class to alphanumeric is caught by +// nothing at all — both cases still match, and no badStrings entry is 128 +// characters long, so the corpus regenerates byte-identical. +// +// This test states the two properties directly, next to the rule they protect, +// instead of leaving them resting on one fixture literal in another file. +func TestHexSignaturePatternAdmits(t *testing.T) { + fields := findFieldsWithPattern(t, hexSignaturePattern) + if len(fields) != wantHexSignatureFields { + t.Fatalf("the hex signature pattern is on %d fields, expected %d — a copy drifted, "+ + "a new signature field was added without it, or the pattern moved in the proto "+ + "and this const was left behind.\nFields: %s", + len(fields), wantHexSignatureFields, fieldNames(fields)) + } + + sig := strings.Repeat("ab", 64) // 128 hex characters + for _, df := range fields { + name := string(df.msg.Name()) + "." + string(df.fd.Name()) + + // "Either case is accepted" is a promise the field comments make. Hex + // decoding accepts both, and a dispute should read the same characters a + // request log holds. + for _, good := range []string{sig, strings.ToUpper(sig), strings.Repeat("aB", 64)} { + if !validateDomainValue(t, df, good) { + t.Errorf("%s refused %q… — the rule promises either case, verbatim", name, good[:16]) + } + } + + for _, bad := range []struct{ v, why string }{ + {strings.Repeat("ab", 63) + "a", "127 characters — one short of a 64-byte signature"}, + {sig + "a", "129 characters — one over"}, + {strings.Repeat("ab", 63) + "gg", "right length, but 'g' is not a hex digit"}, + {"", "empty — the rule is what makes the field mandatory in practice"}, + } { + if validateDomainValue(t, df, bad.v) { + t.Errorf("%s accepted a value it must refuse: %s", name, bad.why) + } + } + } +} + // domainField is one field carrying the shared constraint. type domainField struct { msg protoreflect.MessageDescriptor @@ -108,37 +164,26 @@ func findDomainFields(t *testing.T) []domainField { func findFieldsWithPattern(t *testing.T, pattern string) []domainField { t.Helper() var out []domainField - EachMessage(func(md protoreflect.MessageDescriptor) { - for i := 0; i < md.Fields().Len(); i++ { - fd := md.Fields().Get(i) - rules, has := fieldRules(fd) - if !has { - continue - } - if s := rules.GetString(); s != nil && s.GetPattern() == pattern { - out = append(out, domainField{md, fd, false}) - continue - } - if r := rules.GetRepeated(); r != nil && r.GetItems() != nil { - if s := r.GetItems().GetString(); s != nil && s.GetPattern() == pattern { - out = append(out, domainField{md, fd, true}) - } + // EachRuledField (contract.go) reads the rules through the library's own + // resolver rather than pulling the extension by hand — the same call every + // generator in this package makes, so a change in how rules are carried (a + // predefined rule, say) reaches this guard without a second implementation to + // remember — and it panics on a resolver error instead of reading it as "this + // field has no rules", which would drop the field out of the family silently. + EachRuledField(func(md protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, rules *validate.FieldRules) { + if s := rules.GetString(); s != nil && s.GetPattern() == pattern { + out = append(out, domainField{md, fd, false}) + return + } + if r := rules.GetRepeated(); r != nil && r.GetItems() != nil { + if s := r.GetItems().GetString(); s != nil && s.GetPattern() == pattern { + out = append(out, domainField{md, fd, true}) } } }) return out } -// fieldRules reads a field's protovalidate rules through the library's own -// resolver rather than pulling the extension by hand — the same call the three -// generators in this package already make, so a change in how rules are carried -// (a predefined rule, say) reaches this guard without a second implementation to -// remember. -func fieldRules(fd protoreflect.FieldDescriptor) (*validate.FieldRules, bool) { - fr, err := protovalidate.ResolveFieldRules(fd) - return fr, err == nil && fr != nil -} - // messageWith returns a fresh instance of the field's message carrying v in that // field and nothing else. Both guards need exactly this, and a second copy is a // second place for the repeated/singular distinction to be got wrong. diff --git a/conformance/domain_sdk_parity_test.go b/conformance/domain_sdk_parity_test.go index 4215f75a..02f3ab12 100644 --- a/conformance/domain_sdk_parity_test.go +++ b/conformance/domain_sdk_parity_test.go @@ -92,10 +92,17 @@ var errNoSDKDomainRule = errors.New( // wrapper when the field is a list. The singular/repeated split is the same one // the membership guard makes; going through domainField.repeated keeps the two // reading the descriptor the same way. +// +// Resolution goes through contract.go's FieldRules, the one resolver every +// generator and guard in this package shares. It also fixes the error policy +// this guard was written with: the local helper it used to call read a RESOLVER +// ERROR as "no rules", which would report the rules as vanished — a confusing +// diagnosis of a resolver problem. FieldRules panics on that case and returns +// nil only for a genuinely unruled field. func stringRules(t *testing.T, df domainField) *validate.StringRules { t.Helper() - rules, has := fieldRules(df.fd) - if !has { + rules := FieldRules(df.fd) + if rules == nil { t.Fatalf("%s.%s: protovalidate rules vanished between the two guards", df.msg.Name(), df.fd.Name()) } if df.repeated { diff --git a/conformance/enum_comment_paragraph_test.go b/conformance/enum_comment_paragraph_test.go new file mode 100644 index 00000000..e2e0066a --- /dev/null +++ b/conformance/enum_comment_paragraph_test.go @@ -0,0 +1,127 @@ +// Package conformance — enum_comment_paragraph_test.go keeps an enum-typed +// field's documentation from disappearing on its way to the generated clients. +// +// protoc-gen-jsonschema renders an enum-typed field with the enum TYPE NAME in +// the JSON Schema `title` slot. A leading proto comment is split at its first +// blank line: the part above becomes `title`, the rest becomes `description`. +// For an enum field the type name wins, so the part above the blank line is +// overwritten and never reaches gen/python/wire/models.py or +// gen/ts/wire/schemas.ts. A single-paragraph comment has no split and arrives +// whole. +// +// THE FAILURE THIS STOPS. Write a two-paragraph comment on an enum field. Go +// gets the whole thing, so the proto and the Go bindings read correctly and a +// reviewer sees nothing wrong. Every gate stays green. A Python or TypeScript +// integrator silently gets the comment without its opening claim. Three fields +// were already losing text this way — including one whose lost sentence said +// when the field is set and when it is unset — and it was found by accident. +// +// The fix belongs upstream in the plugin. Until it is there, this is the guard: +// the rule is cheap to follow (one paragraph) and impossible to remember, which +// is exactly the kind of rule a test should hold rather than a comment. +package conformance + +import ( + "os" + "strings" + "testing" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +// splitsIntoParagraphs reports whether a leading comment contains a blank line, +// which is what the plugin splits on. +func splitsIntoParagraphs(comment string) bool { + for _, line := range strings.Split(strings.Trim(comment, "\n"), "\n") { + if strings.TrimSpace(line) == "" { + return true + } + } + return false +} + +func TestEnumFieldCommentsAreOneParagraph(t *testing.T) { + // Detector first. Without this the whole test can pass by never recognising a + // paragraph break, which is the one way a guard like this fails silently. + for _, c := range []struct { + name string + text string + split bool + }{ + {"single paragraph", " One line.\n and its continuation.\n", false}, + {"blank line", " First.\n\n Second.\n", true}, + {"blank line with spaces", " First.\n \n Second.\n", true}, + {"leading and trailing blanks only", "\n One paragraph.\n", false}, + } { + if got := splitsIntoParagraphs(c.text); got != c.split { + t.Fatalf("splitsIntoParagraphs(%s) = %v, want %v — the detector is broken, "+ + "so the sweep below proves nothing", c.name, got, c.split) + } + } + + // Comments live only in gen/descriptor.binpb: `buf build` keeps source info and + // the generated Go packages strip it, so Contract's own descriptors cannot + // answer this. + raw, err := os.ReadFile("../gen/descriptor.binpb") + if err != nil { + t.Fatalf("read descriptor: %v", err) + } + var fds descriptorpb.FileDescriptorSet + if err := proto.Unmarshal(raw, &fds); err != nil { + t.Fatalf("parse descriptor: %v", err) + } + files, err := protodesc.NewFiles(&fds) + if err != nil { + t.Fatalf("build descriptor files: %v", err) + } + + contract := map[string]bool{} + for _, p := range ContractPackages() { + contract[p] = true + } + + var offenders []string + inspected := 0 + files.RangeFiles(func(fd protoreflect.FileDescriptor) bool { + if !contract[string(fd.Package())] { + return true + } + locs := fd.SourceLocations() + var walk func(protoreflect.MessageDescriptors) + walk = func(ms protoreflect.MessageDescriptors) { + for i := 0; i < ms.Len(); i++ { + md := ms.Get(i) + if !md.IsMapEntry() { + for j := 0; j < md.Fields().Len(); j++ { + f := md.Fields().Get(j) + if f.Enum() == nil { + continue + } + inspected++ + if splitsIntoParagraphs(locs.ByDescriptor(f).LeadingComments) { + offenders = append(offenders, string(md.FullName())+"."+string(f.Name())) + } + } + } + walk(md.Messages()) + } + } + walk(fd.Messages()) + return true + }) + + if inspected == 0 { + t.Fatal("no enum-typed contract fields were inspected — the descriptor carries no " + + "source info, or the walk reaches nothing. Either way this guard is not guarding.") + } + if len(offenders) > 0 { + t.Errorf("enum-typed field(s) whose leading comment has more than one paragraph: %v\n"+ + "Everything above the first blank line is dropped before the Python and Zod "+ + "clients are generated, silently and in those two languages only. Join the "+ + "comment into a single paragraph. Merging two paragraphs of three is not enough "+ + "— whatever ends up first is what disappears.", offenders) + } +} diff --git a/conformance/evidence_directory_url_test.go b/conformance/evidence_directory_url_test.go new file mode 100644 index 00000000..7c6bc486 --- /dev/null +++ b/conformance/evidence_directory_url_test.go @@ -0,0 +1,70 @@ +package conformance + +import ( + "testing" + + protovalidate "buf.build/go/protovalidate" + + rampadminv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/admin/v1" +) + +// TestAgentDirectoryURLAdmits pins what TransactionEvidence.agent_directory_url +// actually accepts, because its comment makes specific claims about that and a +// reader makes security decisions on them. +// +// The field is provenance, not authority: it is covered by neither signature and +// is written by the same party as the rest of the row, so its rule cannot make +// following the URL safe. What the rule does is bound the damage when tooling +// follows it anyway — and the comment states the bound precisely, including what +// it does NOT catch. This table is the proof that the stated bound is the real +// one. A row that is silently stricter than its comment breaks legitimate +// evidence; one that is silently looser is a claim the schema does not keep. +func TestAgentDirectoryURLAdmits(t *testing.T) { + v, err := protovalidate.New() + if err != nil { + t.Fatalf("protovalidate.New: %v", err) + } + + cases := []struct { + value string + accepted bool + why string + }{ + {"", true, + "the agent carried no directory anchor; an append-once row states a value for every column"}, + {"https://agent.example/.well-known/http-message-signatures-directory", true, + "the ordinary WBA identity-directory URL the Exchange pins from"}, + {"https://agent.example:8443/.well-known/x", true, + "an explicit port, same port grammar as the recipient host"}, + {"https://169.254.169.254/", true, + "STATED NON-GUARANTEE: the recipient-host grammar admits all-numeric labels, so an " + + "IPv4 literal passes. Blocking link-local and private address space is the fetching " + + "tool's job — if this ever flips to rejected, the comment must stop saying it passes"}, + {"http://169.254.169.254/", false, + "plaintext scheme — https is the only accepted scheme, which is what refuses this one"}, + {"ftp://agent.example/x", false, "non-http scheme"}, + {"file:///etc/passwd", false, "no host, and not an https scheme"}, + {"https://agent.example", false, + "no path: the value is a directory URL, and a bare origin is not one"}, + {"https://user:pw@agent.example/x", false, + "embedded userinfo — credentials in a stored, replayed-into-tooling URL"}, + {"https://agent.example/a b", false, + "raw space: the path is ASCII-printable, so an unencoded space cannot ride through"}, + } + + for _, c := range cases { + row := &rampadminv1.TransactionEvidence{AgentDirectoryUrl: c.value} + accepted := true + if verr, ok := v.Validate(row).(*protovalidate.ValidationError); ok { + for _, viol := range verr.Violations { + if els := viol.Proto.GetField().GetElements(); len(els) > 0 && + els[0].GetFieldName() == "agent_directory_url" { + accepted = false + } + } + } + if accepted != c.accepted { + t.Errorf("agent_directory_url %q: accepted=%v, want %v — %s", c.value, accepted, c.accepted, c.why) + } + } +} diff --git a/conformance/evidence_offline_verify_test.go b/conformance/evidence_offline_verify_test.go new file mode 100644 index 00000000..99347bf2 --- /dev/null +++ b/conformance/evidence_offline_verify_test.go @@ -0,0 +1,286 @@ +// Package conformance — evidence_offline_verify_test.go executes the offline +// re-verification recipe the TransactionEvidence proto comment states: +// +// ed25519.Verify(exchange_signing_public_key, offer_canonical_bytes, hex-decoded offer_sig) +// ed25519.Verify(agent_public_key, agent_acceptance_canonical_bytes, hex-decoded agent_acceptance_signature) +// JCS-parse(agent_acceptance_canonical_bytes) matches the row on all four +// signed members (offer_sig, requester_id, requester_domain, and +// idempotency_key against the row's request_idempotency_key) +// +// Every corpus vector is a field-rule mutant carrying filler key material, so +// nothing else in the repo runs the recipe against a row whose signatures are +// real. This test builds one and runs it. +// +// WHAT CAN TURN THIS RED, and what cannot — worth stating, because the two +// halves below look equally load-bearing and are not. +// +// The protovalidate call IS the gate. It asserts that a row built exactly as +// the recipe requires is CONTRACT-VALID, so a field rule that tightens past +// what a legitimate evidence row can satisfy fails here rather than in +// production. It also pins the mixed-case hex claim the field comments make: +// offer_sig is stored lowercase and agent_acceptance_signature uppercase, so +// a pattern narrowed to one case would fail this row. +// +// The ed25519.Verify assertions are NOT a gate. That a genuine signature +// verifies, and that flipping a byte of the signed bytes breaks it, is +// crypto/ed25519's contract rather than this repo's — no change here can turn +// those lines red. They stay because they execute the documented recipe +// literally instead of paraphrasing it: a reader sees the exact call sequence +// the comment promises, performed on a row the contract accepts, with the +// tamper cases showing which side each signature covers. Read them as +// executable documentation, not as proof that the row detects tampering. +// +// The two MEMBER-COMPARISON cases ARE gates, and they are the reason the third +// step exists. Both build rows whose halves are individually genuine, so the +// ed25519.Verify calls above pass on rows that assert something false. One +// covers splicing an acceptance from another offer, caught by offer_sig. The +// other covers reusing one acceptance for a second execute against the SAME +// offer, where offer_sig is identical and only the idempotency key differs. +// Narrow the check back to offer_sig alone and the second case starts passing, +// which is exactly the regression it is here to refuse. +package conformance + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + protovalidate "buf.build/go/protovalidate" + "google.golang.org/protobuf/types/known/timestamppb" + + rampadminv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/admin/v1" +) + +// evidenceRow builds a TransactionEvidence whose signatures are REAL: each +// side's canonical bytes are signed with a deterministic Ed25519 key (fixed +// seeds, so a failure reproduces byte-for-byte). offer_sig is stored lowercase +// and agent_acceptance_signature uppercase, pinning the field comments' claim +// that the hex rides verbatim in either case. +// +// offerID and idemKey vary the two halves of the binding independently, so a +// caller can build two rows that differ ONLY in which offer was accepted (the +// splice case) or ONLY in which execute the acceptance covers (the reuse case). +func evidenceRow() *rampadminv1.TransactionEvidence { + return evidenceRowFor("offer-verify", "idem-verify") +} + +func evidenceRowFor(offerID, idemKey string) *rampadminv1.TransactionEvidence { + exchangeKey := ed25519.NewKeyFromSeed([]byte(strings.Repeat("exchange-seed-01", 2))) + agentKey := ed25519.NewKeyFromSeed([]byte(strings.Repeat("agent-seed-00001", 2))) + + offerCanonical := []byte(fmt.Sprintf(`{"offer_id":%q,"price":{"amount":"1.00","currency":"USD"}}`, offerID)) + offerSig := strings.ToLower(hex.EncodeToString(ed25519.Sign(exchangeKey, offerCanonical))) + + // The acceptance carries all four AgentAcceptancePayload members, which is + // what the row's four stored copies are compared against. JCS orders members + // lexicographically: idempotency_key < offer_sig < requester_domain < + // requester_id. + acceptanceCanonical := []byte(fmt.Sprintf( + `{"idempotency_key":%q,"offer_sig":%q,"requester_domain":"agent.example","requester_id":"agent-verify"}`, + idemKey, offerSig)) + + return &rampadminv1.TransactionEvidence{ + TransactionId: "tx-verify", + TenantId: "tenant-verify", + OfferId: offerID, + OfferJson: fmt.Sprintf(`{"offer_id":%q}`, offerID), + OfferCanonicalBytes: offerCanonical, + OfferSig: offerSig, + OfferSigAlgorithm: "EdDSA", + ExchangeSigningPublicKey: exchangeKey.Public().(ed25519.PublicKey), + // The ToLower above and the ToUpper here are DELIBERATE and load-bearing: + // together they pin the "either case" promise against the live contract. + // Do not normalise them to one case. TestHexSignaturePatternAdmits states + // the same property directly, so this row is no longer the only thing + // holding it — but it is the only place that holds it on a row built the + // way the recipe requires. + AgentAcceptanceSignature: strings.ToUpper(hex.EncodeToString(ed25519.Sign(agentKey, acceptanceCanonical))), + AgentAcceptanceCanonicalBytes: acceptanceCanonical, + AgentAcceptanceSignatureAlgorithm: "EdDSA", + RequesterId: "agent-verify", + RequesterDomain: "agent.example", + RequestIdempotencyKey: idemKey, + AgentPublicKey: agentKey.Public().(ed25519.PublicKey), + AgentDirectoryUrl: "https://agent.example/.well-known/ramp-agent.json", + CreatedAt: timestamppb.New(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)), + } +} + +// verifyRecipe is the documented procedure, literally: hex-decode the stored +// signature, then ed25519.Verify against the stored key and canonical bytes. +// It operates on the row alone — no registry, no key file, no live service — +// which is the property the proto comment promises. +func verifyRecipe(t *testing.T, key, canonical []byte, hexSig string) bool { + t.Helper() + sig, err := hex.DecodeString(hexSig) + if err != nil { + t.Fatalf("hex-decoding signature %q: %v", hexSig, err) + } + return ed25519.Verify(key, canonical, sig) +} + +// acceptanceMatchesRow is the recipe's third step: parse the signed acceptance +// bytes and compare EVERY member of the payload to the row's stored copy. +// +// All four, not just offer_sig. Two acceptances by one agent against one offer +// share offer_sig and differ only in the idempotency key, so an offer_sig-only +// check lets a single genuine acceptance stand behind two rows for two +// different executes. It returns the name of the first member that does not +// match, or "" when the row and the acceptance agree. +// +// Note the fourth name: the payload member is idempotency_key, and the row +// stores it as request_idempotency_key. +func acceptanceMatchesRow(t *testing.T, row *rampadminv1.TransactionEvidence) string { + t.Helper() + var payload struct { + OfferSig *string `json:"offer_sig"` + RequesterID *string `json:"requester_id"` + RequesterDomain *string `json:"requester_domain"` + IdempotencyKey *string `json:"idempotency_key"` + } + if err := json.Unmarshal(row.AgentAcceptanceCanonicalBytes, &payload); err != nil { + t.Fatalf("parsing agent_acceptance_canonical_bytes as JCS JSON: %v", err) + } + // A missing member is fatal, not a mismatch: the signed bytes cannot be + // audited against the row at all, which is a different and worse problem + // than the two disagreeing. + for name, got := range map[string]*string{ + "offer_sig": payload.OfferSig, + "requester_id": payload.RequesterID, + "requester_domain": payload.RequesterDomain, + "idempotency_key": payload.IdempotencyKey, + } { + if got == nil { + t.Fatalf("the acceptance payload carries no %s — the row cannot prove which "+ + "agreement this acceptance covers", name) + } + } + // offer_sig alone is case-insensitive: the contract admits either hex case + // verbatim, so the row's copy and the signed copy can legitimately differ in + // case. The other three are exact. + if !strings.EqualFold(*payload.OfferSig, row.OfferSig) { + return "offer_sig" + } + for _, c := range []struct{ name, payload, row string }{ + {"requester_id", *payload.RequesterID, row.RequesterId}, + {"requester_domain", *payload.RequesterDomain, row.RequesterDomain}, + {"idempotency_key vs request_idempotency_key", *payload.IdempotencyKey, row.RequestIdempotencyKey}, + } { + if c.payload != c.row { + return c.name + } + } + return "" +} + +func TestEvidenceOfflineVerificationRecipe(t *testing.T) { + row := evidenceRow() + + // The row the recipe runs on is contract-valid, not merely well-formed: + // a recipe demonstrated on a row the contract rejects would prove nothing + // about rows the read RPC can actually return. + v, err := protovalidate.New() + if err != nil { + t.Fatalf("protovalidate.New: %v", err) + } + if err := v.Validate(row); err != nil { + t.Fatalf("evidence row must pass the wire contract before the recipe runs: %v", err) + } + + if !verifyRecipe(t, row.ExchangeSigningPublicKey, row.OfferCanonicalBytes, row.OfferSig) { + t.Error("offer signature must verify: ed25519.Verify(exchange_signing_public_key, offer_canonical_bytes, hex-decoded offer_sig)") + } + if !verifyRecipe(t, row.AgentPublicKey, row.AgentAcceptanceCanonicalBytes, row.AgentAcceptanceSignature) { + t.Error("acceptance signature must verify: ed25519.Verify(agent_public_key, agent_acceptance_canonical_bytes, hex-decoded agent_acceptance_signature)") + } + if mismatch := acceptanceMatchesRow(t, row); mismatch != "" { + t.Errorf("an honest row must match its acceptance on every signed member; %s differs", mismatch) + } + + // The two cases below are the gate. Both build a row whose halves are + // individually genuine, so both ed25519.Verify calls pass on a row that + // asserts something false — only the member comparison separates them from an + // honest row. They cover DIFFERENT attacks and neither subsumes the other. + + // SPLICING, by an outsider: two genuine halves of two different + // transactions, joined. Caught by offer_sig. + t.Run("spliced acceptance from another offer", func(t *testing.T) { + spliced := evidenceRowFor("offer-verify", "idem-verify") + other := evidenceRowFor("offer-other", "idem-verify") + spliced.AgentAcceptanceCanonicalBytes = other.AgentAcceptanceCanonicalBytes + spliced.AgentAcceptanceSignature = other.AgentAcceptanceSignature + + if err := v.Validate(spliced); err != nil { + t.Fatalf("the spliced row is still contract-valid — that is the point: %v", err) + } + if !verifyRecipe(t, spliced.ExchangeSigningPublicKey, spliced.OfferCanonicalBytes, spliced.OfferSig) { + t.Error("the offer half of a spliced row is genuine and must still verify") + } + if !verifyRecipe(t, spliced.AgentPublicKey, spliced.AgentAcceptanceCanonicalBytes, spliced.AgentAcceptanceSignature) { + t.Error("the acceptance half of a spliced row is genuine and must still verify") + } + if got := acceptanceMatchesRow(t, spliced); got != "offer_sig" { + t.Errorf("a spliced row must be caught on offer_sig, got %q — the recipe cannot tell an "+ + "agreement from two unrelated genuine signatures", got) + } + }) + + // FABRICATION, by whoever writes the row: ONE genuine acceptance reused + // across two executes against the SAME offer. offer_sig is identical in both + // rows, so an offer_sig-only check accepts this. Only the idempotency key + // separates them, which is exactly what that payload member exists to bind. + t.Run("one acceptance reused for a second execute against the same offer", func(t *testing.T) { + reused := evidenceRowFor("offer-verify", "idem-second-execute") + first := evidenceRowFor("offer-verify", "idem-verify") + reused.AgentAcceptanceCanonicalBytes = first.AgentAcceptanceCanonicalBytes + reused.AgentAcceptanceSignature = first.AgentAcceptanceSignature + + if err := v.Validate(reused); err != nil { + t.Fatalf("the fabricated row is still contract-valid — that is the point: %v", err) + } + if !verifyRecipe(t, reused.ExchangeSigningPublicKey, reused.OfferCanonicalBytes, reused.OfferSig) { + t.Error("the offer half is genuine and must still verify") + } + if !verifyRecipe(t, reused.AgentPublicKey, reused.AgentAcceptanceCanonicalBytes, reused.AgentAcceptanceSignature) { + t.Error("the acceptance half is genuine and must still verify") + } + // The offer halves are identical, so offer_sig cannot catch this. Assert + // that directly, or a later reader will think the check below is redundant. + if !strings.EqualFold(reused.OfferSig, first.OfferSig) { + t.Fatal("the two rows must share an offer_sig, or this case is not testing reuse") + } + if got := acceptanceMatchesRow(t, reused); got != "idempotency_key vs request_idempotency_key" { + t.Errorf("a reused acceptance must be caught on the idempotency key, got %q — one genuine "+ + "acceptance is standing behind two rows for two different executes", got) + } + }) + + // Tamper half: one flipped bit in the canonical bytes breaks the matching + // verification and leaves the other side intact. This demonstrates which + // side each signature covers; it cannot fail on a repo change (see the + // file header). + t.Run("tampered offer_canonical_bytes fails", func(t *testing.T) { + tampered := evidenceRow() + tampered.OfferCanonicalBytes[0] ^= 0x01 + if verifyRecipe(t, tampered.ExchangeSigningPublicKey, tampered.OfferCanonicalBytes, tampered.OfferSig) { + t.Error("offer signature verified over tampered offer_canonical_bytes — the recipe does not detect tampering") + } + if !verifyRecipe(t, tampered.AgentPublicKey, tampered.AgentAcceptanceCanonicalBytes, tampered.AgentAcceptanceSignature) { + t.Error("acceptance verification must be unaffected by offer-side tampering") + } + }) + t.Run("tampered agent_acceptance_canonical_bytes fails", func(t *testing.T) { + tampered := evidenceRow() + tampered.AgentAcceptanceCanonicalBytes[0] ^= 0x01 + if verifyRecipe(t, tampered.AgentPublicKey, tampered.AgentAcceptanceCanonicalBytes, tampered.AgentAcceptanceSignature) { + t.Error("acceptance signature verified over tampered agent_acceptance_canonical_bytes — the recipe does not detect tampering") + } + if !verifyRecipe(t, tampered.ExchangeSigningPublicKey, tampered.OfferCanonicalBytes, tampered.OfferSig) { + t.Error("offer verification must be unaffected by acceptance-side tampering") + } + }) +} diff --git a/conformance/evidence_selector_test.go b/conformance/evidence_selector_test.go new file mode 100644 index 00000000..bbb1185c --- /dev/null +++ b/conformance/evidence_selector_test.go @@ -0,0 +1,81 @@ +package conformance + +import ( + "testing" + + protovalidate "buf.build/go/protovalidate" + "google.golang.org/protobuf/reflect/protoreflect" + + rampadminv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/admin/v1" +) + +// TestEvidenceSelectorIsAPair pins the two-field selector on +// GetTransactionEvidenceRequest, because an AGENT-plane statement depends on it. +// +// ramp.v1.TransactionResultItem.transaction_id tells every implementer that RAMP +// places no entropy requirement on a transaction id, and it grounds that on this +// message: the evidence read selects by the (tenant_id, transaction_id) PAIR, so +// an id ALONE is never a bearer capability for the forensic row. Counterparty +// agents legitimately hold transaction ids, so that pairing is the entire reason +// a predictable id is acceptable. +// +// The dependency crosses packages and had nothing holding it. Deleting tenant_id +// or dropping its min_len would leave every gate green: the only other mention is +// inside the GENERATED corpus, which would simply regenerate smaller, and +// committing the smaller corpus makes the tree pass again. That is a visible diff, +// not a failure — and meanwhile the published agent-plane spec would go on telling +// implementers that predictable transaction ids are safe, having lost the +// mechanism that made them safe. +// +// This guard turns that silent weakening into a failing test. It deliberately +// checks BEHAVIOUR (each half is rejected when empty) rather than the rule text: +// what the agent-plane claim needs is that neither half can be omitted, however +// that is expressed. +func TestEvidenceSelectorIsAPair(t *testing.T) { + v, err := protovalidate.New() + if err != nil { + t.Fatalf("protovalidate.New: %v", err) + } + + md := (&rampadminv1.GetTransactionEvidenceRequest{}).ProtoReflect().Descriptor() + for _, name := range []string{"transaction_id", "tenant_id"} { + if md.Fields().ByName(protoreflect.Name(name)) == nil { + t.Fatalf("GetTransactionEvidenceRequest lost its %s field — the evidence read no longer "+ + "selects by a pair, so ramp.v1.TransactionResultItem.transaction_id's entropy "+ + "statement is now false and must be rewritten before this field goes", name) + } + } + + // The complete pair is the only accepted selector. + if err := v.Validate(&rampadminv1.GetTransactionEvidenceRequest{ + TransactionId: "01JC0X8N9WQ0000000000000AB", + TenantId: "hearst-media", + }); err != nil { + t.Errorf("a complete (tenant_id, transaction_id) selector was rejected: %v", err) + } + + // Each half missing must be refused. The tenant half is the load-bearing one + // for the agent-plane claim: without it a bare transaction id would read the row. + halves := []struct { + name string + req *rampadminv1.GetTransactionEvidenceRequest + why string + }{ + { + "tenant_id", + &rampadminv1.GetTransactionEvidenceRequest{TransactionId: "01JC0X8N9WQ0000000000000AB"}, + "a transaction id alone would become a bearer capability for the forensic row, which is " + + "exactly what ramp.v1 promises it is not", + }, + { + "transaction_id", + &rampadminv1.GetTransactionEvidenceRequest{TenantId: "hearst-media"}, + "a tenant alone does not name a row; an unselective read is not what this RPC offers", + }, + } + for _, h := range halves { + if err := v.Validate(h.req); err == nil { + t.Errorf("a selector omitting %s was accepted — %s", h.name, h.why) + } + } +} diff --git a/conformance/lookup_test.go b/conformance/lookup_test.go index 5a2d5b62..3f36c985 100644 --- a/conformance/lookup_test.go +++ b/conformance/lookup_test.go @@ -44,3 +44,53 @@ func TestContractBareNamesUnique(t *testing.T) { t.Fatal("no contract files — the guard would be vacuous") } } + +// TestBareNameSweepReachesEnums pins the SCOPE of the guard above, which the guard +// itself cannot report. +// +// AssertUniqueBareNames returning nil means "no duplicates found in what I looked +// at". For a long time it looked at messages only, and that was invisible: ramp.v1 +// was the sole package defining enums, so no cross-package enum collision could +// exist to be missed. ramp.admin.v1 adding ObligationState is what made the gap +// reachable, and nothing would have failed when it did. +// +// So this asserts the walk actually reaches enums, and reaches them in more than +// one package. A future sweep that quietly stopped visiting them would leave +// TestContractBareNamesUnique green. +// +// The collision matters because merge_schema.py hoists enums into the same $defs +// map as messages and keys them by bare name with setdefault: the SECOND enum of a +// colliding pair is dropped, and every field referring to it silently takes the +// FIRST enum's value list. Generated Pydantic and Zod would then accept values the +// Go server rejects. +func TestBareNameSweepReachesEnums(t *testing.T) { + byPackage := map[string]int{} + total := 0 + EachEnum(func(ed protoreflect.EnumDescriptor) { + total++ + byPackage[string(ed.ParentFile().Package())]++ + }) + + if total == 0 { + t.Fatal("EachEnum visited no enums — the enum half of AssertUniqueBareNames is wired to nothing") + } + if len(byPackage) < 2 { + t.Fatalf("EachEnum reached enums in %d contract package(s) (%v); the guard exists for CROSS-package "+ + "collisions, so it is vacuous while only one package defines enums", len(byPackage), byPackage) + } + + // Nested enums are the half a file-level-only walk would miss, and a message + // containing one is where a bare-name collision is easiest to introduce by + // accident, because the nesting hides the name from a reader scanning the file. + var nested int + EachMessage(func(md protoreflect.MessageDescriptor) { nested += md.Enums().Len() }) + var seenNested int + EachEnum(func(ed protoreflect.EnumDescriptor) { + if _, ok := ed.Parent().(protoreflect.MessageDescriptor); ok { + seenNested++ + } + }) + if seenNested != nested { + t.Errorf("EachEnum saw %d nested enum(s), the contract declares %d — the walk skips nesting levels", seenNested, nested) + } +} diff --git a/conformance/reachability_test.go b/conformance/reachability_test.go index a3f2c531..ae419522 100644 --- a/conformance/reachability_test.go +++ b/conformance/reachability_test.go @@ -1,9 +1,9 @@ // Package conformance — reachability_test.go holds INV-4, the orphan-type guard. // // The contract reaches the wire through exactly three delivery channels: -// 1. RPC bodies — every service method's input and output message -// 2. served documents — the manifest a participant publishes at .well-known -// 3. the error envelope — ErrorDetail and the typed failure reasons it carries +// 1. RPC bodies — every service method's input and output message +// 2. served documents — the manifest a participant publishes at .well-known +// 3. the error envelope — ErrorDetail and the typed failure reasons it carries // // A message or enum that NO channel can carry is an orphan: schema that is // defined but undeliverable — dead weight, or (worse) a wiring mistake where a @@ -28,10 +28,10 @@ import ( // already reachable from an RPC body — so an entry that becomes RPC-reachable is // flagged as stale instead of silently masking an orphan beneath it. var outOfBandRoots = map[string]string{ - "WellKnownManifest": "served at /.well-known/ramp.json by every participant (capabilities, role; identity keys moved to WBAFile)", - "WBAFile": "served at /.well-known/http-message-signatures-directory — the pure WBA JWK Set (identity keys + revocation_url)", - "ErrorDetail": "the transport-error envelope; carries the seven typed failure reasons", - "KeyRevocationList": "served key-revocation document (thumbprint list), fetched out of band from WBAFile.revocation_url", + "WellKnownManifest": "served at /.well-known/ramp.json by every participant (capabilities, role; identity keys moved to WBAFile)", + "WBAFile": "served at /.well-known/http-message-signatures-directory — the pure WBA JWK Set (identity keys + revocation_url)", + "ErrorDetail": "the transport-error envelope; carries the seven typed failure reasons", + "KeyRevocationList": "served key-revocation document (thumbprint list), fetched out of band from WBAFile.revocation_url", "AgentAcceptancePayload": "canonical signing structure for AgentAcceptance; never sent on the wire — it fixes the field set the signer and verifier canonicalize (RFC 8785 JCS over canonical proto-JSON) to derive byte-identical signed bytes", } diff --git a/conformance/requiredgen/main.go b/conformance/requiredgen/main.go index 1ba67eda..16d8db7a 100644 --- a/conformance/requiredgen/main.go +++ b/conformance/requiredgen/main.go @@ -20,7 +20,6 @@ import ( "sort" "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" - protovalidate "buf.build/go/protovalidate" "google.golang.org/protobuf/reflect/protoreflect" "github.com/RAMP-Protocol/protocol/conformance" @@ -34,24 +33,20 @@ func main() { if err := conformance.AssertUniqueBareNames(); err != nil { panic(err) } + assertNoStringByteLengthRules() out := "required_fields.json" if len(os.Args) > 1 { out = os.Args[1] } req := map[string][]string{} - conformance.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 + conformance.EachRuledField(func(md protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, fr *validate.FieldRules) { + if zeroRejected(fd, fr) { + req[string(md.Name())] = append(req[string(md.Name())], string(fd.Name())) } }) + for _, names := range req { + sort.Strings(names) + } b, err := json.MarshalIndent(req, "", " ") if err != nil { panic(err) @@ -61,15 +56,34 @@ func main() { } } +// assertNoStringByteLengthRules panics on any string byte-length rule +// (min_bytes/max_bytes), on any field, regardless of presence or `required`: +// protoschema renders them as JSON Schema minLength/maxLength, which count +// CHARACTERS, so a multibyte value the Go server rejects would pass the +// generated clients. This runs as its own sweep — not inside zeroRejected — +// so the required/HasPresence early returns there cannot skip it. No contract +// field carries these rules today; implement a byte-count refine in the +// sdk-types pipeline before adding one. +// +// The sweep is EachRuleSet, not EachRuledField: the rule is just as wrong at +// repeated.items.string.max_bytes, and the contract already uses item-level +// string rules on six fields, so a top-level-only sweep would be a guard with a +// hole exactly where the shape is most likely to be added. +func assertNoStringByteLengthRules() { + conformance.EachRuleSet(func(_ protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, prefix string, fr *validate.FieldRules) { + if member, n, ok := conformance.StringByteLength(fr); ok { + panic(fmt.Sprintf("requiredgen: string byte-length rule %sstring.%s:%d on field %s — protoschema translates it to a CHARACTER count; implement a byte-count refine in the sdk-types pipeline first", prefix, member, n, fd.FullName())) + } + }) +} + // 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 - } +// field rule fr (already resolved by the EachRuledField walk, which panics +// rather than reading a resolver error as "no rules"). 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, fr *validate.FieldRules) bool { if fr.GetRequired() { return true } @@ -87,12 +101,32 @@ func zeroRejected(fd protoreflect.FieldDescriptor) bool { if s.GetMinLen() >= 1 { return true } + // A non-empty const rejects the zero value "", so omission must be + // rejected too (the pinned "EdDSA" algorithm labels). + if s.Const != nil && s.GetConst() != "" { + return true + } if p := s.GetPattern(); p != "" { if re, err := regexp.Compile(p); err == nil && !re.MatchString("") { return true } } } + if r := fr.GetRepeated(); r != nil && r.GetMinItems() >= 1 { + // An empty list is the repeated zero value; min_items≥1 rejects it, and + // proto-JSON omits an empty list entirely, so omission must be rejected + // too (TransactionRequest.items — the corpus too_few mutant). + return true + } + // Any bytes length rule rejects the zero value (empty bytes) — a floor of at + // least 1 and an exact length of at least 1 both do — so omission must be + // rejected by the clients too. These are the evidence rows' Ed25519 keys and + // canonical-bytes fields. conformance.MustBytesLength owns the "at least 1" + // part: a zero-valued length rule is a contract error there, not a rule that + // reaches here. + if conformance.MustBytesLength(fd, fr) != nil { + return true + } if i := fr.GetInt64(); i != nil { switch x := i.GetGreaterThan().(type) { case *validate.Int64Rules_Gte: diff --git a/conformance/samerule_test.go b/conformance/samerule_test.go new file mode 100644 index 00000000..6915ca1a --- /dev/null +++ b/conformance/samerule_test.go @@ -0,0 +1,264 @@ +// Package conformance — samerule_test.go is the drift gate for restated rules. +// +// The toolchain forces some rules to be INLINE COPIES: corpusgen and the client +// generators read field-level rules directly, so a rule that ramp.admin.v1 +// restates from ramp.v1 (or from another field in the same file) cannot be +// deduplicated at the proto layer — but nothing else keeps the copies equal if +// the source changes. Before this gate, a drifted copy would surface only as +// unexplained parity-corpus differences. +// +// The gate has two halves: +// +// 1. EQUALITY. A field whose leading comment carries the directive +// +// Same rule as +// +// declares itself a copy, and this test fails unless the two fields' +// resolved protovalidate FieldRules are EQUAL (proto.Equal — the +// byte-for-byte view after option merging). +// +// 2. COMPLETENESS. The set of fields that must declare is derived from the +// descriptor itself, never from a hand-maintained list (the +// descriptor_invariants_test.go rule): every scalar/enum/bytes field in a +// non-root contract package whose resolved rules are byte-identical to +// another contract field's must be a directive source, a directive target, +// or carry an entry in sameRuleCoincidences — an explicit "identical by +// coincidence, not by copy" exemption. The exemption list is itself +// fail-closed: an entry that no longer names a field, or whose field no +// longer has a rule twin, fails as stale. +// +// The root contract package (the first in Contract — ramp.v1) is the upstream +// vocabulary restatements are copied FROM, so the COMPLETENESS half does not +// demand a declaration from it. That is a scoping choice, not a statement that +// its duplicates are safe: a root field can still opt in, and one that names a +// shape another root field also carries SHOULD, because the equality half below +// enforces every directive it finds regardless of package. Message-typed fields +// are skipped: the only rule they carry is the required-envelope convention, +// which is a shape, not a restatable value rule. +// +// Comments come from gen/descriptor.binpb (built with source info); rules come +// from the linked-in generated descriptors — the same authoritative +// protovalidate view every other gate uses. +package conformance + +import ( + "fmt" + "os" + "regexp" + "strings" + "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" +) + +// The directive may wrap across comment lines, so whitespace between its words +// (including the newline+space form descriptor comments carry) is tolerated. +var sameRuleDirective = regexp.MustCompile(`[Ss]ame\s+rule\s+as\s+([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)`) + +// sameRuleCoincidences names non-root contract fields whose rules are +// byte-identical to another contract field's by COINCIDENCE, not by copy — +// tying them with a directive would force unrelated fields to move together. +// Every entry must still resolve to a field AND still have a rule twin, or the +// completeness sweep fails it as stale. +var sameRuleCoincidences = map[string]string{ + "ramp.admin.v1.TransactionEvidence.offer_id": "bare min_len:1 — any-non-empty-string, shared shape with unrelated fields", + "ramp.admin.v1.TransactionEvidence.offer_json": "bare min_len:1 — any-non-empty-string, shared shape with unrelated fields", + "ramp.admin.v1.TransactionState.idempotency_key": "bare min_len:1 — deliberately unbounded (derived key), not a copy of any bounded rule", + "ramp.admin.v1.TransactionState.signed_url_hash": "bytes.len:32 — a sha256 digest; matches the Ed25519 key length by arithmetic accident", + "ramp.admin.v1.TenantFeeRate.tenant_id": "the ANCHOR the tenant_id directives point at; itself identical to unrelated {min_len:1,max_len:255} fields", + "ramp.admin.v1.TransactionEvidence.transaction_id": "the ANCHOR the request's transaction_id directive points at; {min_len:1,max_len:255} matches tenant ids by convention, not by copy", +} + +func TestSameRuleDirectivesHoldByteForByte(t *testing.T) { + directives := collectSameRuleDirectives(t) // field full name -> target full name + + if len(directives) == 0 { + t.Fatal("no 'Same rule as' directives found in the contract — the gate is wired to nothing (descriptor missing source info?)") + } + + for src, dst := range directives { + srcFD, err := findField(src) + if err != nil { + t.Errorf("directive source %s: %v", src, err) + continue + } + dstFD, err := findField(dst) + if err != nil { + t.Errorf("%s says 'Same rule as %s', which does not resolve to a field: %v", src, dst, err) + continue + } + // Rules equality alone is blind to presence: protovalidate SKIPS an unset + // presence-tracked field, so `optional string x` and plain `string x` + // with byte-identical rules still behave differently at runtime (the + // optional copy accepts omission its source rejects). A directive + // declares same BEHAVIOR, so the presence mode must match too. + if srcFD.HasPresence() != dstFD.HasPresence() { + t.Errorf("restated rule PRESENCE mismatch: %s declares 'Same rule as %s' but HasPresence differs (source %v, target %v) — protovalidate skips an unset presence-tracked field, so the copies diverge at runtime on omission", + src, dst, srcFD.HasPresence(), dstFD.HasPresence()) + } + // FieldRules (contract.go) panics on a resolver error — one policy for + // every rule-reading guard in the package. + srcRules := FieldRules(srcFD) + dstRules := FieldRules(dstFD) + if srcRules == nil { + t.Errorf("%s declares 'Same rule as %s' but itself carries no field rules", src, dst) + continue + } + if dstRules == nil { + t.Errorf("%s says 'Same rule as %s', but the target carries no field rules", src, dst) + continue + } + if !proto.Equal(srcRules, dstRules) { + t.Errorf("restated rule DRIFTED: %s declares 'Same rule as %s' but the rules differ\n source: %v\n target: %v\n(update the copy, or remove the directive if the divergence is now intended)", + src, dst, srcRules, dstRules) + } + } +} + +// TestRuleIdenticalGroupsAreDeclared is the completeness half: it derives the +// set of fields that must declare from the descriptor (rule-identical groups) +// instead of trusting authors to opt in. See the file header. +func TestRuleIdenticalGroupsAreDeclared(t *testing.T) { + directives := collectSameRuleDirectives(t) + targets := map[string]bool{} + for _, dst := range directives { + targets[dst] = true + } + rootPkg := ContractPackages()[0] + + // Group every ruled non-message field by its deterministically serialized + // resolved rules. + groups := map[string][]string{} + EachRuledField(func(_ protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, fr *validate.FieldRules) { + if fd.Kind() == protoreflect.MessageKind || fd.Kind() == protoreflect.GroupKind { + return + } + key, err := proto.MarshalOptions{Deterministic: true}.Marshal(fr) + if err != nil { + t.Fatalf("serializing rules for %s: %v", fd.FullName(), err) + } + groups[string(key)] = append(groups[string(key)], string(fd.FullName())) + }) + + inTwinGroup := map[string]bool{} + for _, fields := range groups { + if len(fields) < 2 { + continue + } + for _, name := range fields { + inTwinGroup[name] = true + if strings.HasPrefix(name, rootPkg+".") { + continue // the root package is the upstream vocabulary, not a restater + } + if _, ok := directives[name]; ok { + continue + } + if targets[name] { + continue + } + if _, ok := sameRuleCoincidences[name]; ok { + continue + } + t.Errorf("%s has rules byte-identical to %v but declares nothing — add a 'Same rule as ' directive if it is a copy, or a sameRuleCoincidences entry if the match is accidental", + name, others(fields, name)) + } + } + + // Exemption hygiene: a coincidence entry must still name a real field that + // still has a rule twin, or it is stale and hides nothing. + for name, why := range sameRuleCoincidences { + if _, err := findField(name); err != nil { + t.Errorf("stale sameRuleCoincidences entry %s (%s): %v", name, why, err) + continue + } + if !inTwinGroup[name] { + t.Errorf("stale sameRuleCoincidences entry %s (%s): the field no longer has a rule-identical twin — remove the entry", name, why) + } + } +} + +func others(fields []string, self string) []string { + out := make([]string, 0, len(fields)-1) + for _, f := range fields { + if f != self { + out = append(out, f) + } + } + return out +} + +func findField(fullName string) (protoreflect.FieldDescriptor, error) { + d, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(fullName)) + if err != nil { + return nil, err + } + fd, ok := d.(protoreflect.FieldDescriptor) + if !ok { + return nil, fmt.Errorf("%s is a %T, not a field", fullName, d) + } + return fd, nil +} + +// collectSameRuleDirectives scans the leading comments of every field in the +// contract packages (from the committed descriptor, which carries source info) +// and returns source-field -> target-field for each directive found. +func collectSameRuleDirectives(t *testing.T) map[string]string { + t.Helper() + raw, err := os.ReadFile("../gen/descriptor.binpb") + if err != nil { + t.Fatalf("read descriptor: %v", err) + } + var fds descriptorpb.FileDescriptorSet + if err := proto.Unmarshal(raw, &fds); err != nil { + t.Fatalf("parse descriptor: %v", err) + } + contract := map[string]bool{} + for _, p := range ContractPackages() { + contract[p] = true + } + + out := map[string]string{} + for _, f := range fds.GetFile() { + if !contract[f.GetPackage()] { + continue + } + // Index leading comments by source path (the SourceCodeInfo location key). + comments := map[string]string{} + for _, loc := range f.GetSourceCodeInfo().GetLocation() { + if c := loc.GetLeadingComments(); c != "" { + comments[pathKey(loc.GetPath())] = c + } + } + var walk func(prefix []int32, scope string, msgs []*descriptorpb.DescriptorProto) + walk = func(prefix []int32, scope string, msgs []*descriptorpb.DescriptorProto) { + for mi, m := range msgs { + msgPath := append(append([]int32{}, prefix...), int32(mi)) + msgName := scope + "." + m.GetName() + for fi, fld := range m.GetField() { + // field path: , 2 (DescriptorProto.field), fi + key := pathKey(append(append([]int32{}, msgPath...), 2, int32(fi))) + if mm := sameRuleDirective.FindStringSubmatch(comments[key]); mm != nil { + out[msgName+"."+fld.GetName()] = mm[1] + } + } + // nested messages: , 3 (DescriptorProto.nested_type) + walk(append(append([]int32{}, msgPath...), 3), msgName, m.GetNestedType()) + } + } + // top-level messages: 4 (FileDescriptorProto.message_type) + walk([]int32{4}, f.GetPackage(), f.GetMessageType()) + } + return out +} + +func pathKey(p []int32) string { + out := make([]byte, 0, len(p)*4) + for _, v := range p { + out = append(out, byte(v), byte(v>>8), byte(v>>16), byte(v>>24)) + } + return string(out) +} diff --git a/conformance/sig_algorithm_contract_test.go b/conformance/sig_algorithm_contract_test.go new file mode 100644 index 00000000..decf8fe8 --- /dev/null +++ b/conformance/sig_algorithm_contract_test.go @@ -0,0 +1,174 @@ +// Package conformance — sig_algorithm_contract_test.go ties the pinned +// signature-algorithm labels to the SDK constants that actually write them. +// +// ramp.admin.v1 pins "EdDSA" as a string.const on the evidence row's two +// algorithm fields, so a row whose label says anything else is refused. The +// values that get written come from SDK constants. Nothing connected the two. +// +// THE FAILURE THAT WAS POSSIBLE. Change the SDK constant. Every Go test passes, +// every parity test passes, the corpus regenerates cleanly — and every evidence +// row the Exchange writes from then on is rejected by its own read RPC, because +// the const still says the old value. The break surfaces in production, on the +// forensic plane, at the moment someone needs it. +// +// The const is read from the DESCRIPTOR and the constant from the committed wire +// vector, so neither literal is restated here. A guard that spelled "EdDSA" a +// third time would add a third place to drift. +// +// Reading the SDK side as data rather than importing it is the ver-field guard's +// arrangement, for its reason: nothing in conformance depends on sdk/, because +// this package is the descriptor-level layer BELOW the SDKs. The vector file is +// generated from the real Go constants and is already replayed by the Python and +// TS parity suites, so a change that misses either side goes red. +package conformance + +import ( + "encoding/json" + "fmt" + "os" + "testing" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +// sigAlgorithmBindings pairs each pinned contract field with the SDK constant +// that produces its value. +// +// The pairing is the one thing here that must be stated by hand — no descriptor +// or vector says which constant feeds which field. It is a short, closed list +// and the test below fails if either side of a pair stops resolving, so a +// renamed field or a renamed constant is a failure rather than a silent skip. +var sigAlgorithmBindings = []struct { + message string // bare contract message name + field string // field carrying the string.const + constant string // vector entry name in wire-constants-vectors.json +}{ + {"TransactionEvidence", "offer_sig_algorithm", "OfferSignatureAlgorithm"}, + {"TransactionEvidence", "agent_acceptance_signature_algorithm", "AcceptanceSignatureAlgorithm"}, +} + +// wireConstant returns the value of a named entry in the committed wire-constants +// vector. Absence is fatal: a missing entry means this guard lost its anchor, and +// passing silently would be worse than failing. +func wireConstant(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(wireConstantsVectors) + if err != nil { + t.Fatalf("read %s: %v", wireConstantsVectors, err) + } + var doc struct { + Vectors []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"vectors"` + } + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatalf("parse %s: %v", wireConstantsVectors, err) + } + for _, v := range doc.Vectors { + if v.Name == name { + return v.Value + } + } + t.Fatalf("%s carries no %s entry — the SDK constant is not exported to the "+ + "cross-language vector, so this guard cannot compare it against the contract", + wireConstantsVectors, name) + return "" +} + +// constRule returns the string.const pinned on a contract field, and whether the +// field carries one at all. +func constRule(md protoreflect.MessageDescriptor, field string) (string, bool, error) { + fd := md.Fields().ByName(protoreflect.Name(field)) + if fd == nil { + return "", false, fmt.Errorf("message %s has no field %q", md.Name(), field) + } + fr := FieldRules(fd) + s := fr.GetString_() + if s == nil || s.Const == nil { + return "", false, nil + } + return s.GetConst(), true, nil +} + +func TestSignatureAlgorithmConstMatchesSDKConstant(t *testing.T) { + for _, b := range sigAlgorithmBindings { + t.Run(b.message+"."+b.field, func(t *testing.T) { + mt, err := findContractMessage(b.message) + if err != nil { + t.Fatalf("resolve %s: %v", b.message, err) + } + pinned, ok, err := constRule(mt.Descriptor(), b.field) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatalf("%s.%s carries no string.const — this guard exists because that field "+ + "pins the label an SDK writes; if the pin was removed deliberately, remove the "+ + "binding from sigAlgorithmBindings too", b.message, b.field) + } + + produced := wireConstant(t, b.constant) + if pinned != produced { + t.Errorf("the contract accepts only %q on %s.%s, but the SDK writes %q (constant %s).\n"+ + "Every row written with the SDK value would be refused by the read RPC that validates "+ + "against the const. Change both, or neither.", + pinned, b.message, b.field, produced, b.constant) + } + }) + } +} + +// TestEveryPinnedAlgorithmFieldIsBound stops the list above from going stale in +// the direction a hand-maintained list always goes: a new pinned field is added +// and nobody remembers to bind it. +// +// Scope comes from the descriptor. Any contract field whose NAME marks it as an +// algorithm label and which carries a string.const must appear in +// sigAlgorithmBindings — so adding a third one fails until someone says which +// constant produces it. +func TestEveryPinnedAlgorithmFieldIsBound(t *testing.T) { + bound := map[string]bool{} + for _, b := range sigAlgorithmBindings { + bound[b.message+"."+b.field] = true + } + + var unbound []string + EachMessage(func(md protoreflect.MessageDescriptor) { + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + name := string(fd.Name()) + if !isAlgorithmLabelField(name) { + continue + } + if _, ok, err := constRule(md, name); err != nil || !ok { + continue // unpinned label fields are a separate question + } + key := string(md.Name()) + "." + name + if !bound[key] { + unbound = append(unbound, key) + } + } + }) + + if len(unbound) > 0 { + t.Errorf("pinned algorithm-label field(s) with no SDK constant bound: %v\n"+ + "Each one accepts exactly one string. Add it to sigAlgorithmBindings naming the "+ + "constant that writes it, so the two cannot drift apart.", unbound) + } +} + +// isAlgorithmLabelField matches the naming the contract uses for a +// signature-algorithm label. Both spellings are live: ramp.v1 says +// signature_algorithm, and ramp.admin.v1 says sig_algorithm on the field +// neighbouring offer_sig, whose short name it inherits. +func isAlgorithmLabelField(name string) bool { + return name == "signature_algorithm" || + name == "sig_algorithm" || + hasSuffix(name, "_signature_algorithm") || + hasSuffix(name, "_sig_algorithm") +} + +func hasSuffix(s, suf string) bool { + return len(s) >= len(suf) && s[len(s)-len(suf):] == suf +} diff --git a/conformance/testdata/bytes_wire_forms.json b/conformance/testdata/bytes_wire_forms.json new file mode 100644 index 00000000..491a14d4 --- /dev/null +++ b/conformance/testdata/bytes_wire_forms.json @@ -0,0 +1,238 @@ +{ + "$comment": [ + "The base64 wire forms of a bytes-rule field, and the verdict all three languages must", + "reach on each one. Go protovalidate + protojson is the oracle (bytes_wire_forms_test.go", + "checks every row against it); the Pydantic and Zod harnesses", + "(gen/python/tests/test_bytes_wire_forms.py, gen/ts/tests/bytes_wire_forms.test.ts) assert", + "the same rows against the generated schemas, so a row lands here once instead of being", + "hand-copied into two suites that can drift.", + "", + "WHY THIS AXIS NEEDS ITS OWN VECTORS. The generated validation corpus cannot carry these", + "cases: corpusgen sets values through protoreflect and protojson always re-encodes bytes", + "into ONE canonical form (padded, standard alphabet), so no unpadded, url-safe or", + "malformed-base64 STRING can ever appear in a corpus case. Dropping the optional-padding", + "tail from a generated pattern would keep every corpus gate green while the clients then", + "reject the unpadded 43-char key Go accepts.", + "", + "WHAT GO ACCEPTS (protojson's decoder, which merge_schema.tighten_bytes_len mirrors as a", + "regex): the string is p payload characters plus padding. The alphabet is url-safe when", + "the string contains '-' or '_', standard otherwise, and decoding is strict afterwards —", + "so a MIXED string is refused. p % 4 == 1 is not a legal encoded length in any form, 4k", + "characters take no padding, 4k+2 take an optional '==', 4k+3 an optional '='.", + "", + "bases are the corpus valid baselines (proto-JSON, snake_case) — a whole valid message so", + "the Go oracle can validate it too. Each field entry pairs a bytes field with the form set", + "for its rule kind." + ], + "bases": { + "TransactionEvidence": { + "agent_acceptance_canonical_bytes": "eyJyZXF1ZXN0ZXJfaWQiOiJhZ2VudC1zZWVkIn0=", + "agent_acceptance_signature": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "agent_acceptance_signature_algorithm": "EdDSA", + "agent_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "created_at": "2026-01-02T03:04:05Z", + "exchange_signing_public_key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "offer_canonical_bytes": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "offer_id": "offer-seed", + "offer_json": "{\"offer_id\":\"offer-seed\"}", + "offer_sig": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "offer_sig_algorithm": "EdDSA", + "request_idempotency_key": "idem-tx", + "requester_domain": "agent.example", + "requester_id": "agent-seed", + "tenant_id": "tenant-seed", + "transaction_id": "tx-seed" + }, + "TransactionState": { + "signed_url_expiry": "2026-01-02T03:04:05Z", + "idempotency_key": "idem-tx:offer-seed", + "signed_url_hash": "aGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGg=" + } + }, + "form_sets": { + "bytes_len_32": { + "rule": "bytes.len = 32", + "why": "protoschema rendered this as a 43..44 CHARACTER window, which also admits a 33-byte value (44 unpadded chars) and a 31-byte padded one. The rewrite pins the payload to exactly 43 characters of ONE alphabet plus optional exact padding.", + "forms": [ + { + "value": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "accepted": true, + "why": "padded canonical form — what protojson emits" + }, + { + "value": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s", + "accepted": true, + "why": "unpadded (raw) form; protojson decodes it, the corpus never shows it" + }, + { + "value": "++/77/vv++/77/vv++/77/vv++/77/vv++/77/vv++8=", + "accepted": true, + "why": "standard alphabet with real '+' and '/'" + }, + { + "value": "--_77_vv--_77_vv--_77_vv--_77_vv--_77_vv--8=", + "accepted": true, + "why": "url-safe alphabet — a JWK \"x\" value pasted verbatim" + }, + { + "value": "++_77_vv--/77/vv++/77/vv++/77/vv++/77/vv++8=", + "accepted": false, + "why": "MIXED alphabets: protojson sees '-'/'_', switches to url-safe, then refuses '+' and '/'. A merged character class accepted this" + }, + { + "value": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2tr", + "accepted": false, + "why": "33 bytes (44 unpadded chars) — inside the old character window" + }, + { + "value": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2traw==", + "accepted": false, + "why": "31 bytes padded, also 44 chars — the other side of the old window" + }, + { + "value": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s==", + "accepted": false, + "why": "wrong padding length for a 32-byte payload" + }, + { + "value": "", + "accepted": false, + "why": "zero bytes" + }, + { + "value": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s=\n", + "accepted": false, + "why": "trailing newline. A LINE-WRAPPED value. Base64 is commonly emitted with newlines every 64 or 76 characters (PEM, MIME), so this is the shape most likely to arrive from a human or an older tool. Go protojson refuses it, and so must every generated client. Pinned because ONE plausible toolchain change would silently split them: Python's own `re` module treats `$` as matching before a trailing newline, so a Pydantic build switched to the python-re engine would start accepting the trailing-newline case while Go and Zod still reject it. Nothing else in this suite would notice." + }, + { + "value": "a2tra2tr\na2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "accepted": false, + "why": "newline INSIDE the payload — the wrapped-at-64-characters shape. Same reason as above." + }, + { + "value": "a2tra2tr a2tra2tra2tra2tra2tra2tra2tra2tra2s=", + "accepted": false, + "why": "space inside the payload; base64 decoders differ on whitespace and protojson does not skip it." + } + ] + }, + "bytes_min_len_1": { + "rule": "bytes.min_len = 1", + "why": "protoschema counted padding as content, so \"==\" (zero payload bytes, which protojson refuses to decode) passed. The rewrite requires the encoded payload characters of at least 1 byte BEFORE the padding tail, with the padding derived from the payload length mod 4.", + "forms": [ + { + "value": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==", + "accepted": true, + "why": "padded canonical form" + }, + { + "value": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ", + "accepted": true, + "why": "same value, unpadded" + }, + { + "value": "AA", + "accepted": true, + "why": "exactly 1 byte, unpadded" + }, + { + "value": "AA==", + "accepted": true, + "why": "exactly 1 byte, padded" + }, + { + "value": "-_", + "accepted": true, + "why": "url-safe alphabet" + }, + { + "value": "+/", + "accepted": true, + "why": "standard alphabet" + }, + { + "value": "AA=", + "accepted": false, + "why": "2 payload characters take two '=' or none; one is not a legal encoded length" + }, + { + "value": "AAA==", + "accepted": false, + "why": "3 payload characters take one '=' or none" + }, + { + "value": "AAAAA", + "accepted": false, + "why": "5 payload characters — 4k+1 is not a legal base64 length in any form" + }, + { + "value": "+_", + "accepted": false, + "why": "MIXED alphabets — the free ={0,2} tail over a merged character class accepted this" + }, + { + "value": "==", + "accepted": false, + "why": "pure padding: ZERO payload bytes; protojson refuses to decode it" + }, + { + "value": "=", + "accepted": false, + "why": "pure padding, odd length" + }, + { + "value": "A=", + "accepted": false, + "why": "one payload character never decodes" + }, + { + "value": "", + "accepted": false, + "why": "zero bytes, under the min_len 1 floor" + }, + { + "value": "eyJvZmZlcl9pZCI6Im9mZmVyLXNlZWQifQ==\n", + "accepted": false, + "why": "trailing newline. A LINE-WRAPPED value. Base64 is commonly emitted with newlines every 64 or 76 characters (PEM, MIME), so this is the shape most likely to arrive from a human or an older tool. Go protojson refuses it, and so must every generated client. Pinned because ONE plausible toolchain change would silently split them: Python's own `re` module treats `$` as matching before a trailing newline, so a Pydantic build switched to the python-re engine would start accepting the trailing-newline case while Go and Zod still reject it. Nothing else in this suite would notice." + }, + { + "value": "eyJvZmZl\ncl9pZCI6Im9mZmVyLXNlZWQifQ==", + "accepted": false, + "why": "newline INSIDE the payload — the wrapped-at-64-characters shape. Same reason as above." + }, + { + "value": "eyJvZmZl cl9pZCI6Im9mZmVyLXNlZWQifQ==", + "accepted": false, + "why": "space inside the payload; base64 decoders differ on whitespace and protojson does not skip it." + } + ] + } + }, + "fields": [ + { + "message": "TransactionEvidence", + "field": "exchange_signing_public_key", + "form_set": "bytes_len_32" + }, + { + "message": "TransactionEvidence", + "field": "agent_public_key", + "form_set": "bytes_len_32" + }, + { + "message": "TransactionState", + "field": "signed_url_hash", + "form_set": "bytes_len_32" + }, + { + "message": "TransactionEvidence", + "field": "offer_canonical_bytes", + "form_set": "bytes_min_len_1" + }, + { + "message": "TransactionEvidence", + "field": "agent_acceptance_canonical_bytes", + "form_set": "bytes_min_len_1" + } + ] +} diff --git a/conformance/transaction_state_delivery_test.go b/conformance/transaction_state_delivery_test.go new file mode 100644 index 00000000..f6b9413f --- /dev/null +++ b/conformance/transaction_state_delivery_test.go @@ -0,0 +1,82 @@ +package conformance + +import ( + "testing" + + protovalidate "buf.build/go/protovalidate" + + rampadminv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/admin/v1" +) + +// TestTransactionStateSignedURLFieldsAreOptional pins both halves of the rule that +// lets an evidence row describe a transaction which minted no signed URL. +// +// TransactionState.signed_url_expiry and TransactionState.signed_url_hash both describe ONE +// artifact: the signed retrieval URL. DELIVERY_METHOD_DIRECT returns the resource +// inline or from the Exchange's own endpoint, so a direct transaction has no URL, +// no expiry and nothing to hash — while the row still exists, because the execute +// succeeded. Make either field mandatory and the Exchange has no legal value to +// send for a transaction it genuinely completed: an empty value fails the message's +// own validation, and a fabricated one asserts a delivery that never happened. +// +// The two halves fail in opposite directions, so both are pinned here: +// +// - Drop the `optional` keyword from signed_url_hash and it becomes a singular +// scalar with no presence. An unset value is then a PRESENT zero-length value, +// bytes.len = 32 rejects it, and every direct-delivery row becomes unreadable — +// while `buf breaking` stays quiet, because the change is wire-compatible. +// - Weaken bytes.len = 32 (or re-add required = true on signed_url_expiry) and the row stops +// pinning the digest it joins the edge delivery log on. +func TestTransactionStateSignedURLFieldsAreOptional(t *testing.T) { + md := (&rampadminv1.TransactionState{}).ProtoReflect().Descriptor() + + hash := md.Fields().ByName("signed_url_hash") + if hash == nil { + t.Fatal("TransactionState has no signed_url_hash field") + } + if !hash.HasPresence() { + t.Error("TransactionState.signed_url_hash lost explicit presence — restore the `optional` keyword; " + + "without it an unset hash is a present zero-length value and bytes.len:32 rejects every " + + "direct-delivery row") + } + if fr := FieldRules(md.Fields().ByName("signed_url_expiry")); fr.GetRequired() { + t.Error("TransactionState.signed_url_expiry regained required = true — a DELIVERY_METHOD_DIRECT transaction " + + "mints no signed URL, so it has no expiry to state and the Exchange could not answer " + + "GetTransactionEvidence for a transaction that succeeded") + } + + v, err := protovalidate.New() + if err != nil { + t.Fatalf("protovalidate.New: %v", err) + } + + // A direct-delivery row: the derived per-item idempotency key and nothing else. + direct := &rampadminv1.TransactionState{IdempotencyKey: "idem-tx:offer-seed"} + if err := v.Validate(direct); err != nil { + t.Errorf("a TransactionState omitting signed_url_expiry and signed_url_hash was rejected: %v\n"+ + "that is the shape of every DELIVERY_METHOD_DIRECT transaction and it must be valid", err) + } + + // A PRESENT hash is still pinned to a full sha256 digest. Optional buys absence, + // never a short or long value: 31 and 33 bytes bracket the rule, and a present + // empty value is the case the `optional` keyword itself creates. + for _, n := range []int{0, 31, 33} { + row := &rampadminv1.TransactionState{ + IdempotencyKey: "idem-tx:offer-seed", + SignedUrlHash: make([]byte, n), + } + if err := v.Validate(row); err == nil { + t.Errorf("a present %d-byte signed_url_hash was accepted; bytes.len = 32 must still bind "+ + "any value that IS stated", n) + } + } + + // The signed-URL case still validates end to end. + full := &rampadminv1.TransactionState{ + IdempotencyKey: "idem-tx:offer-seed", + SignedUrlHash: make([]byte, 32), + } + if err := v.Validate(full); err != nil { + t.Errorf("a TransactionState carrying a 32-byte signed_url_hash was rejected: %v", err) + } +} diff --git a/conformance/uniquegen/main.go b/conformance/uniquegen/main.go index da06ab28..e1d0c1de 100644 --- a/conformance/uniquegen/main.go +++ b/conformance/uniquegen/main.go @@ -20,31 +20,33 @@ import ( "os" "sort" - protovalidate "buf.build/go/protovalidate" + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "google.golang.org/protobuf/reflect/protoreflect" "github.com/RAMP-Protocol/protocol/conformance" ) func main() { + // Fail fast on a cross-package bare-name collision: this manifest (and + // merge_schema.py + gen_unique_py.py, which consume it) key by bare message + // name, so a duplicate would silently clobber one message's set-semantics + // enforcement. Same guard as requiredgen and bytesgen. + if err := conformance.AssertUniqueBareNames(); err != nil { + panic(err) + } out := "unique_items.json" if len(os.Args) > 1 { out = os.Args[1] } uniq := map[string][]string{} - conformance.EachMessage(func(md protoreflect.MessageDescriptor) { - var names []string - for i := 0; i < md.Fields().Len(); i++ { - fd := md.Fields().Get(i) - if uniqueItems(fd) { - names = append(names, string(fd.Name())) - } - } - if len(names) > 0 { - sort.Strings(names) - uniq[string(md.Name())] = names + conformance.EachRuledField(func(md protoreflect.MessageDescriptor, fd protoreflect.FieldDescriptor, fr *validate.FieldRules) { + if fd.IsList() && fr.GetRepeated().GetUnique() { + uniq[string(md.Name())] = append(uniq[string(md.Name())], string(fd.Name())) } }) + for _, names := range uniq { + sort.Strings(names) + } b, err := json.MarshalIndent(uniq, "", " ") if err != nil { panic(err) @@ -53,15 +55,3 @@ func main() { panic(err) } } - -// uniqueItems reports whether fd is a repeated field whose items must be unique. -func uniqueItems(fd protoreflect.FieldDescriptor) bool { - if !fd.IsList() { - return false - } - fr, err := protovalidate.ResolveFieldRules(fd) - if err != nil || fr == nil { - return false - } - return fr.GetRepeated().GetUnique() -} diff --git a/conformance/validate_test.go b/conformance/validate_test.go index bc541448..9b8a80f5 100644 --- a/conformance/validate_test.go +++ b/conformance/validate_test.go @@ -201,8 +201,8 @@ func licensingCases() []validationCase { Role: rampv1.Role_ROLE_EXCHANGE, TermsUri: proto.String("https://exchange.example/terms"), }, true, ""}, - {"dispute_request reason set ok", &rampv1.DisputeRequest{IdempotencyKey: "idem-dr-x", Exchange: exampleExchange, Reason: rampv1.DisputeReason_DISPUTE_REASON_CONTENT_MISMATCH}, true, ""}, - {"dispute_request reason unspecified rejected", &rampv1.DisputeRequest{IdempotencyKey: "idem-dr-x", Exchange: exampleExchange}, false, "enum.not_in"}, + {"dispute_request reason set ok", &rampv1.DisputeRequest{IdempotencyKey: "idem-dr-x", Exchange: exampleExchange, TransactionId: exampleTransactionID, Reason: rampv1.DisputeReason_DISPUTE_REASON_CONTENT_MISMATCH}, true, ""}, + {"dispute_request reason unspecified rejected", &rampv1.DisputeRequest{IdempotencyKey: "idem-dr-x", Exchange: exampleExchange, TransactionId: exampleTransactionID}, 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, "string.pattern"}, @@ -219,13 +219,13 @@ func TestIdempotencyKeyRequired(t *testing.T) { func idempotencyCases() []validationCase { return []validationCase{ - {"transaction empty key rejected", &rampv1.TransactionRequest{IdempotencyKey: "", Items: []*rampv1.TransactionItem{{Offer: &rampv1.Offer{OfferId: "of_1", Exchange: exampleExchange, Pricing: freePricing()}}}}, false, "string.min_len"}, - {"transaction key ok", &rampv1.TransactionRequest{IdempotencyKey: "idem-tx-1", Items: []*rampv1.TransactionItem{{Offer: &rampv1.Offer{OfferId: "of_1", Exchange: exampleExchange, Pricing: freePricing()}}}}, true, ""}, + {"transaction empty key rejected", &rampv1.TransactionRequest{IdempotencyKey: "", Items: []*rampv1.TransactionItem{{Offer: signedOffer()}}}, false, "string.min_len"}, + {"transaction key ok", &rampv1.TransactionRequest{IdempotencyKey: "idem-tx-1", Items: []*rampv1.TransactionItem{{Offer: signedOffer()}}}, true, ""}, {"transaction empty items rejected", &rampv1.TransactionRequest{IdempotencyKey: "idem-tx-empty"}, false, "repeated.min_items"}, - {"usage report empty key rejected", &rampv1.UsageReport{IdempotencyKey: "", Exchange: exampleExchange}, false, "string.min_len"}, - {"usage report key ok", &rampv1.UsageReport{IdempotencyKey: "idem-ur-1", Exchange: exampleExchange}, true, ""}, - {"dispute empty key rejected", &rampv1.DisputeRequest{IdempotencyKey: "", Exchange: exampleExchange, Reason: rampv1.DisputeReason_DISPUTE_REASON_CONTENT_MISMATCH}, false, "string.min_len"}, - {"dispute key ok", &rampv1.DisputeRequest{IdempotencyKey: "idem-dr-1", Exchange: exampleExchange, Reason: rampv1.DisputeReason_DISPUTE_REASON_CONTENT_MISMATCH}, true, ""}, + {"usage report empty key rejected", &rampv1.UsageReport{IdempotencyKey: "", Exchange: exampleExchange, TransactionId: exampleTransactionID}, false, "string.min_len"}, + {"usage report key ok", &rampv1.UsageReport{IdempotencyKey: "idem-ur-1", Exchange: exampleExchange, TransactionId: exampleTransactionID}, true, ""}, + {"dispute empty key rejected", &rampv1.DisputeRequest{IdempotencyKey: "", Exchange: exampleExchange, TransactionId: exampleTransactionID, Reason: rampv1.DisputeReason_DISPUTE_REASON_CONTENT_MISMATCH}, false, "string.min_len"}, + {"dispute key ok", &rampv1.DisputeRequest{IdempotencyKey: "idem-dr-1", Exchange: exampleExchange, TransactionId: exampleTransactionID, Reason: rampv1.DisputeReason_DISPUTE_REASON_CONTENT_MISMATCH}, true, ""}, } } @@ -235,10 +235,29 @@ func idempotencyCases() []validationCase { // to exercise. const exampleExchange = "exchange.example" +// exampleTransactionID and exampleSignature exist for the same reason as +// exampleExchange: both fields now carry rules, so a fixture that leaves them +// empty fails on THEM instead of on the rule it was written to exercise. +// exampleSignature is 128 lowercase hex characters — one Ed25519 signature in +// the shape both planes require. It is filler, not a real signature: nothing +// here verifies it, only its shape is checked. +const ( + exampleTransactionID = "txn-example-1" + exampleSignature = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +) + func freePricing() *rampv1.Pricing { return &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_FREE, Rate: "0"} } +// signedOffer is the minimum Offer that passes its own rules — every field the +// contract requires and nothing more, so a case built on it fails only on what +// the case itself sets. +func signedOffer() *rampv1.Offer { + return &rampv1.Offer{OfferId: "of_1", Exchange: exampleExchange, Pricing: freePricing(), Signature: exampleSignature} +} + func gen65() []string { s := make([]string, 65) for i := range s { @@ -335,7 +354,7 @@ func TestCELRuleCoverage(t *testing.T) { } } for j := 0; j < md.Fields().Len(); j++ { - if fr, err := protovalidate.ResolveFieldRules(md.Fields().Get(j)); err == nil && fr != nil { + if fr := FieldRules(md.Fields().Get(j)); fr != nil { for _, r := range fr.GetCel() { declared[r.GetId()] = true } diff --git a/docs/design-history.md b/docs/design-history.md index b6a4d3dc..d81f8429 100644 --- a/docs/design-history.md +++ b/docs/design-history.md @@ -21,9 +21,15 @@ expected common case, not an exception. RFC 9421 lets each hop add its own `Signature` / `Signature-Input` entry independently, so a verifier checks each hop against the key it fetches from that hop's `/.well-known/ramp.json` — there is no nested in-message co-signing scheme to define or version. Content that must -outlive a single HTTP exchange — offers, attestations — keeps its signature as -JWS (RFC 7515, `alg=EdDSA`), because those objects are stored, forwarded, and -re-verified out of band. +outlive a single HTTP exchange — offers, attestations — keeps a content +signature of its own, because those objects are stored, forwarded, and +re-verified out of band. That signature is detached: the raw Ed25519 bytes in +hex over an RFC 8785 JCS canonical form, with the algorithm named by the JOSE +identifier `EdDSA`. Parts of the schema described this value as a JWS for +several revisions while other parts described it as hex; the hex reading is the +one every verifier implements, and the schema now says so in one voice. A +detached signature over a canonical form needs no JOSE library in any language, +and the value stays a plain field the admin plane can re-verify offline. Multi-hop forwarding rides on the same primitive rather than an in-message hop list. A forwarded request carries a stack of RFC 9421 signatures — one per party @@ -319,7 +325,7 @@ alternatives are conspicuously absent: baking each requester's self-declared attributes into a trust boundary. The full message-level specification and the membership/validation rules live in -ADR-014 (Universal Licensing Core) in the deployment repository; this entry +the deployment repository's licensing-core decision record; this entry records only the wire-shaping reasoning. ## `license_id` → `billing_ref`: identity is the signature, not a field @@ -592,6 +598,27 @@ identifiers that are *acted on* or *persisted*: the settlement and evidence keys (`transaction_id`, `billing_id`, `report_id`, `dispute_id`) the Exchange assigns and the reconciliation chain joins on. +One deliberate carve-out, by that same persisted-identifier rule: the admin +plane's forensic evidence read +(`ramp.admin.v1.TransactionEvidence.request_correlation`) carries the +correlation id the Exchange PERSISTED for a transaction, with a provenance +flag. This is not a return of the deleted fields — no live request or response +carries a correlation id in its body, and the agent plane still has none. The +evidence row is a read-only view of a store, and that store legitimately holds +the `X-Request-ID` value it recorded: the append-once transaction-evidence row +carries `request_id` plus `request_id_minted`, written once at the service +boundary and constrained to be present or absent together — the Exchange +storage model documents that store and those two columns. +A forensic read that could not state it would be unable to join the row to the +edge delivery log. Correlation still *flows* only in headers; the admin field +states, after the fact, what was recorded. + +The premise names the EVIDENCE store specifically, not the event store. The +transaction log has no correlation column — its idempotency key is a dedupe +key — and the discovery-plane `query_id` on `ResourceQueryReceived` is the +correlation of a different request on a different leg, not this value under +another name. + ## Idempotency is an explicit `idempotency_key`, not an overloaded `id` Idempotency is the mirror image of correlation, and the contrast is the point. A @@ -925,14 +952,22 @@ revisiting as a three-language change. ## One agent identity, one key The protocol carries a single agent identity and the SDK does not offer a second. -`agent_identity_hash` is defined as the RFC 7638 thumbprint of the agent's -request-signing key; an Exchange verifies the detached offer acceptance against the -key registered for whichever caller the request signature identified; and the -delivery URL is bound to that same thumbprint, which a later fetch must prove -possession of. A separately-custodied acceptance key would be refused at execute, -and any URL it did produce could never be fetched — the presented key would not -match the binding. So the client takes one Signer, and the public half of that -same key for the fetch header, which a Signer cannot yield. +`agent_identity_hash` is the RFC 7638 thumbprint of the ACCEPTANCE key — the key +whose signature over `AgentAcceptancePayload` the Exchange verified — and the +delivery URL is bound to that thumbprint, which a later fetch must prove +possession of. So a separately-custodied acceptance key produces a URL its +custodian cannot fetch with, and the fetching key it does hold does not match the +binding. The client therefore takes one Signer, and the public half of that same +key for the fetch header, which a Signer cannot yield. + +The anchor is the acceptance rather than the request signature because the two +part company under brokering. A Broker may author a re-packaged transaction as +sender, so the RFC 9421 signer on that leg is the broker and the in-body +acceptance is the only agent-authored signature in the request. Deriving the +identity from the transport signer would name the broker on exactly the topology +the acceptance exists to survive. On a direct hop the agent signs both with one +key and the distinction does not show, which is why earlier text here described +the value as the thumbprint of the request-signing key. One consequence for the cross-language surface: Go's `SignAgentBinding` takes a `Signer` plus the public half, while Python's counterpart takes raw seed bytes. diff --git a/docs/sdk-parity-matrix.md b/docs/sdk-parity-matrix.md index 949d224b..dbdeefe2 100644 --- a/docs/sdk-parity-matrix.md +++ b/docs/sdk-parity-matrix.md @@ -12,7 +12,7 @@ Go is the oracle (`sdk/go/{helpers,resolvers,core,connect,connectserver}`); Python and TS mirror it. This document is **generated** from the same two artifacts CI already enforces against the code, so it cannot drift from the real surface — a mismatch fails the API-surface gate or the corpus-completeness gate before it can reach this file. -**At a glance:** 82 symbols at cross-language parity · 14 documented divergences · 143 Go-idiomatic exclusions · 24 conformance corpora, each tri-replayed. +**At a glance:** 82 symbols at cross-language parity · 14 documented divergences · 147 Go-idiomatic exclusions · 24 conformance corpora, each tri-replayed. Layering (L1 pure trust core vs L2 I/O resolvers), the SSRF transport-wiring invariant, and naming conventions are recorded in [`design-history.md`](./design-history.md). @@ -213,13 +213,17 @@ Go constructs (functional-option builders, `errors.Is` sentinels, value types, c | `connectserver.WithValidation` | Go functional-option builder; py/ts pass options via kwargs/options objects. | | `connectserver.WithVerifyGate` | Go functional-option builder; py/ts pass options via kwargs/options objects. | | `connectserver.WithoutReplayStore` | Go functional-option builder; py/ts pass options via kwargs/options objects. | -| `core.DefaultRequestID` | Go default request-id minter; py/ts mint request-ids inline. | +| `core.DefaultRequestID` | Go default request-id minter. Go-only because py/ts mint nothing: neither SDK sets X-Request-ID on any request — both export the RequestIDHeader constant and nothing more. That is a real parity gap, tracked separately, not a difference in API shape. | | `core.DiscoveryResult` | Go per-URI discovery result carrying the fail-closed split plus the typed absence reasons; py/ts gain the same shape with their client verbs. | | `core.ErrOfferExpired` | Go errors.Is sentinel; py/ts express verification failures via typed failure unions / exception classes, not per-reason named sentinels. | +| `core.MintRequestID` | Go wrapper that makes a caller-supplied RequestIDFunc always return a value the admin plane can persist, and supplies the default when it is nil. Go-only because there is no py/ts mint to wrap: neither SDK sets X-Request-ID on any request — both export the RequestIDHeader constant and nothing more — so every RPC from a py/ts client arrives with no correlation id and the Exchange mints one. That is a real parity gap, tracked separately; this entry documents why the SYMBOL is Go-only, not that the behaviour is at parity. It exists as one exported function because Go has three stamping sites (the RPC interceptor, the delivery fetch, and the server middleware) and the check is a property of the mint, not of any one site. | | `core.OfferGroupResult` | Go per-URI group within a discovery result; py/ts gain the same shape with their client verbs. | -| `core.RequestIDFunc` | Go request-id function type; py/ts pass a callable inline. | +| `core.RequestID` | Go-only, and tied to core.RequestIDMiddleware which is already excluded. Python (ramp_sdk.server_verify) and TypeScript (core/verify-request.ts) DO have server-verify faces, but neither carries a request-id seam: they verify RFC 9421 signatures and return a verdict, and never read, mint, or stamp a correlation id. ValidRequestID is the wire rule on RequestCorrelation.request_id; RequestID and RequestIDFromContext carry the settled value and its provenance to a handler. These move to 'symbols' if and when the py/ts server faces gain a request-id seam — not merely when a server face exists, which it already does. | +| `core.RequestIDFromContext` | Go-only, and tied to core.RequestIDMiddleware which is already excluded. Python (ramp_sdk.server_verify) and TypeScript (core/verify-request.ts) DO have server-verify faces, but neither carries a request-id seam: they verify RFC 9421 signatures and return a verdict, and never read, mint, or stamp a correlation id. ValidRequestID is the wire rule on RequestCorrelation.request_id; RequestID and RequestIDFromContext carry the settled value and its provenance to a handler. These move to 'symbols' if and when the py/ts server faces gain a request-id seam — not merely when a server face exists, which it already does. | +| `core.RequestIDFunc` | Go request-id function type. Go-only because there is nothing in py/ts to pass one to: neither SDK mints or stamps a correlation id at all. That is a real parity gap, tracked separately, not a difference in API shape. | | `core.RequestIDMiddleware` | Go-only request-id middleware (matrix SERVER-role request-id row: TS/Py absent). | | `core.SigningOption` | Go functional-option type for the signing transport; py/ts pass options objects. | +| `core.ValidRequestID` | Go-only, and tied to core.RequestIDMiddleware which is already excluded. Python (ramp_sdk.server_verify) and TypeScript (core/verify-request.ts) DO have server-verify faces, but neither carries a request-id seam: they verify RFC 9421 signatures and return a verdict, and never read, mint, or stamp a correlation id. ValidRequestID is the wire rule on RequestCorrelation.request_id; RequestID and RequestIDFromContext carry the settled value and its provenance to a handler. These move to 'symbols' if and when the py/ts server faces gain a request-id seam — not merely when a server face exists, which it already does. | | `core.WithAppendSigner` | Go functional-option builder; py/ts pass options via kwargs/options objects. | | `core.WithSignPredicate` | Go functional-option builder; py/ts pass options via kwargs/options objects. | | `core.WithSignatureAgent` | Go functional-option builder; py/ts pass options via kwargs/options objects. | diff --git a/gen/descriptor.binpb b/gen/descriptor.binpb index 4cb48cba..35615a8b 100644 Binary files a/gen/descriptor.binpb and b/gen/descriptor.binpb differ diff --git a/gen/go/ramp/admin/v1/admin.pb.go b/gen/go/ramp/admin/v1/admin.pb.go index 0ba401dc..32ac0bd6 100644 --- a/gen/go/ramp/admin/v1/admin.pb.go +++ b/gen/go/ramp/admin/v1/admin.pb.go @@ -1,25 +1,54 @@ -// RAMP Admin v1 — operator-plane configuration service. +// RAMP Admin v1 — operator-plane configuration and forensics service. // -// AdminService carries Exchange operator overrides: the tenant fee rate and -// the tenant reporting policy. It is deliberately a separate package and -// service from ramp.v1.ExchangeService — the operator/config plane is not -// part of the agent hot-path contract, and keeping it out of ramp.v1 keeps -// the agent-facing surface unchanged. +// AdminService carries Exchange operator overrides — the tenant fee rate and +// the tenant reporting policy — plus one forensic read: the append-once +// evidence row the Exchange persists for every executed transaction. It is +// deliberately a separate package and service from ramp.v1.ExchangeService — +// the operator plane is not part of the agent hot-path contract, and keeping +// it out of ramp.v1 keeps the agent-facing surface unchanged. // // Trust model: deployments MUST NOT expose AdminService on the public // agent-facing listener. Reachability is restricted at the network layer // (an internal listener plus a source allowlist); there is no per-operator // identity inside the service in v1. Because the admin plane carries no // RFC 9421 request signing, there is no verified signer to deduplicate -// against — and both RPCs are full-replace overwrites, so they are naturally -// idempotent and carry no idempotency_key. +// against — and no RPC here needs one: the setters are full-replace +// overwrites and the evidence read is side-effect-free, so every RPC is +// naturally idempotent and carries no idempotency_key. // -// Message shape: each RPC takes a thin {ver, } envelope wrapping a +// The evidence read is keyed by the (tenant_id, transaction_id) PAIR. The +// tenant selector exists because transaction ids leave the deployment: +// every counterparty agent legitimately holds the ids of its own +// transactions, so an id alone must not act as a bearer capability for the +// forensic row. Naming the tenant narrows what a leaked id is worth; it is +// NOT an access control, and this plane has none — it carries no request +// signing and no per-operator identity, so there is no caller to attach a +// per-tenant rule to. A tenant +// mismatch is NOT_FOUND, byte-identical to an unknown id, so existence +// under another tenant is not revealed. The id format itself is +// implementation-defined (ramp.v1 places no entropy requirement on +// transaction ids); what bounds this read is the pair selector plus the +// network-layer reachability restriction above. +// +// Those two controls are not interchangeable, and the weaker one must not be +// mistaken for the stronger. The pair selector stops a transaction id ALONE +// from reading a row. It does NOT make enumeration infeasible: tenant ids are +// human brand slugs, and a tenant's slug is visible to every agent holding one +// of its offers, so a caller who reaches this plane can pair a known tenant +// with guessed ids. Enumeration is bounded by reachability — this service MUST +// NOT be exposed on the public agent-facing listener — which is why that +// restriction is the load-bearing control on this plane rather than a +// deployment convenience. +// +// Message shape: each setter takes a thin {ver, } envelope wrapping a // required payload message — TenantFeeRate or ReportingPolicy. The payload // type is shared by the request and its response, so every field rule is // stated ONCE; the read-back response cannot drift from the write. Responses // echo the payload as persisted, giving operator tooling a read-back -// confirmation of the applied values. +// confirmation of the applied values. The evidence read does not share this +// shape — its request carries only the (tenant_id, transaction_id) selector, +// and its response wraps read-only payloads that exist on no write path +// (TransactionEvidence, TransactionState, ReportingObligationState). // // Validation: every constraint here is a FIELD-level protovalidate rule so it // flows into the generated Pydantic/Zod types. Cross-field (message-level CEL) @@ -45,6 +74,7 @@ import ( _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -57,6 +87,78 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ObligationState — lifecycle of a reporting obligation, exactly the +// vocabulary the Exchange persists (the storage model's ObligationPending/ +// Fulfilled/Expired/Waived/Blocked, see components/exchange/storage-model). +// Defined here rather than imported from ramp.v1 (which carries no such +// enum — the agent plane states reporting REQUIREMENTS, never their +// server-side lifecycle) so the admin package stays self-contained. +// +// A REJECTED usage report does not transition the obligation: it stays +// PENDING until an accepted report, a waiver, or expiry. A rejection carries +// no response message at all — ramp.v1.UsageReportResponse is sent only when +// the report was ACCEPTED, and a rejection travels as a non-OK transport +// error carrying ramp.v1.ErrorDetail.usage_report_rejection. So a ledger +// renderer reading a PENDING obligation cannot assume a response was ever +// produced for the attempt that left it PENDING. +type ObligationState int32 + +const ( + ObligationState_OBLIGATION_STATE_UNSPECIFIED ObligationState = 0 // never sent — the server always maps a persisted state; rejected (not_in:[0]) on ReportingObligationState.state + ObligationState_OBLIGATION_STATE_PENDING ObligationState = 1 // minted; awaiting an accepted usage report + ObligationState_OBLIGATION_STATE_FULFILLED ObligationState = 2 // a usage report was received and accepted (including a late report accepted out of BLOCKED) + ObligationState_OBLIGATION_STATE_EXPIRED ObligationState = 3 // the reporting window elapsed with no accepted report + ObligationState_OBLIGATION_STATE_WAIVED ObligationState = 4 // the Exchange waived the requirement + ObligationState_OBLIGATION_STATE_BLOCKED ObligationState = 5 // enforcement gate: an expired obligation met a new transaction attempt; new transactions are rejected until the Exchange lifts the block or accepts a late report +) + +// Enum value maps for ObligationState. +var ( + ObligationState_name = map[int32]string{ + 0: "OBLIGATION_STATE_UNSPECIFIED", + 1: "OBLIGATION_STATE_PENDING", + 2: "OBLIGATION_STATE_FULFILLED", + 3: "OBLIGATION_STATE_EXPIRED", + 4: "OBLIGATION_STATE_WAIVED", + 5: "OBLIGATION_STATE_BLOCKED", + } + ObligationState_value = map[string]int32{ + "OBLIGATION_STATE_UNSPECIFIED": 0, + "OBLIGATION_STATE_PENDING": 1, + "OBLIGATION_STATE_FULFILLED": 2, + "OBLIGATION_STATE_EXPIRED": 3, + "OBLIGATION_STATE_WAIVED": 4, + "OBLIGATION_STATE_BLOCKED": 5, + } +) + +func (x ObligationState) Enum() *ObligationState { + p := new(ObligationState) + *p = x + return p +} + +func (x ObligationState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ObligationState) Descriptor() protoreflect.EnumDescriptor { + return file_ramp_admin_v1_admin_proto_enumTypes[0].Descriptor() +} + +func (ObligationState) Type() protoreflect.EnumType { + return &file_ramp_admin_v1_admin_proto_enumTypes[0] +} + +func (x ObligationState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ObligationState.Descriptor instead. +func (ObligationState) EnumDescriptor() ([]byte, []int) { + return file_ramp_admin_v1_admin_proto_rawDescGZIP(), []int{0} +} + // TenantFeeRate is the fee-rate payload shared by SetTenantFeeRate's request // and response. The field rules live here once, so the write and the echoed // read-back stay in lockstep. @@ -132,7 +234,8 @@ func (x *TenantFeeRate) GetNotes() string { // the write and the echoed read-back stay in lockstep. type ReportingPolicy struct { state protoimpl.MessageState `protogen:"open.v1"` - // The tenant whose reporting policy is being replaced. + // The tenant whose reporting policy is being replaced. Same rule as + // ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated). TenantId string `protobuf:"bytes,1,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` // Report field names the usage-report validator requires. The wire constrains // only the token shape; which names are meaningful is defined by the receiving @@ -431,11 +534,967 @@ func (x *SetReportingPolicyResponse) GetPolicy() *ReportingPolicy { return nil } +// TransactionEvidence — one append-once evidence row, exactly as the Exchange +// persisted it for a successfully executed transaction. The row is written +// only after both signatures verified, and a denied execute writes nothing, +// so the row's existence is itself the success statement. +// +// The row re-verifies OFFLINE, from this message alone — no agent registry, +// no Exchange key file, no live service: +// - the Exchange signed this exact offer: +// ed25519.Verify(exchange_signing_public_key, offer_canonical_bytes, hex-decoded offer_sig) +// - the agent signed an acceptance: +// ed25519.Verify(agent_public_key, agent_acceptance_canonical_bytes, hex-decoded agent_acceptance_signature) +// - and that acceptance is THIS agreement — not merely a valid acceptance: +// JCS-parse(agent_acceptance_canonical_bytes) matches the row, member for +// member. Three names coincide; the fourth does not: +// payload offer_sig == offer_sig (hex, case-insensitive) +// payload requester_id == requester_id +// payload requester_domain == requester_domain +// payload idempotency_key == request_idempotency_key +// Those four are every field of ramp.v1.AgentAcceptancePayload, and the row +// stores all four so this comparison is possible from the row alone. +// +// The third step is not bookkeeping, and offer_sig alone is not enough for it. +// Two failures it prevents, which are different: +// +// SPLICING, by an outsider. A genuine offer from one transaction and a +// genuine acceptance from another, by the same agent, both verify against +// real keys and pass the authenticity step below. offer_sig catches this one. +// FABRICATION, by whoever writes the row. Two acceptances by one agent +// against ONE offer share offer_sig and differ only in the idempotency key, +// which ramp.v1.AgentAcceptancePayload.idempotency_key exists to bind. So an +// Exchange holding a single genuine acceptance can write two rows for two +// different executes against one offer, and both pass an offer_sig-only +// check. Only the idempotency-key comparison separates them. +// +// Comparing all four leaves no member of the signed payload unchecked, which is +// the only version of this step that means what it says. A verifier that skips +// it has checked two signatures and no agreement. The Exchange MUST perform the +// same comparison before persisting a row, so a bad row is never written. +// Both verifying public keys ride along (not key ids) so re-verification +// survives key rotation, which removes retired ids from the published JWKS. +// The *_canonical_bytes are the verbatim RFC 8785 JCS bytes each signature +// was computed over, stored as-signed and never re-derived: protobuf-binary +// is non-canonical by protocol rule, and a stored-inputs-only row would stop +// re-verifying the day the canonicalization recipe moved. +// +// TRUST BOUNDARY. Offline re-verification proves the row is INTERNALLY +// CONSISTENT: each signature verifies against the key and bytes stored in +// the same row, so anyone able to write a row could mint one that passes. +// To prove AUTHENTICITY — that these parties actually operated these keys — +// a verifier must compare the embedded keys against copies obtained +// independently. WHERE to obtain them is the whole question, and only one of +// the two sides has an anchor inside a signature. +// +// EXCHANGE SIDE — anchored in the signed bytes. offer_canonical_bytes +// carries the offer's `exchange` field (ramp.v1.Offer.exchange, the bare +// host of the issuing Exchange), and offer_sig covers it. A verifier reads +// that host OUT of the canonical bytes, fetches THAT Exchange's published +// JWKS (the authority per protocol/authentication), and checks +// exchange_signing_public_key against it. A fabricated row cannot redirect +// this step: changing `exchange` invalidates the very signature the check +// exists to confirm. +// +// AGENT SIDE — no signed anchor exists, and the row does not supply one. +// agent_directory_url is covered by NEITHER signature and is written by the +// same party as the rest of the row, so a fabricated row satisfies any +// procedure built on it using a host its author controls. It is a record of +// where this Exchange states it pinned the key — provenance, never the +// authority. The agent anchor must be obtained INDEPENDENTLY: from the +// counterparty the audit is being run for, or from the agent's own directory +// located through an identity the verifier already trusts. This is unchanged +// when agent_directory_url is '' (the agent carried no directory anchor): +// there is no fallback to reconstruct, because the field was never the +// authority to fall back from. +// +// The in-row keys are convenience copies that keep old rows verifiable after +// rotation; they are not the root of trust. After matching a key against its +// authority, a verifier should also check that key's RFC 7638 thumbprint +// against a revocation list, because a key can be rotated out BECAUSE it was +// revoked, and a revoked key must not count as authentic. +// +// There is no single list covering both keys. WBAFile.revocation_url is one +// URL per DIRECTORY, so the ramp.v1.KeyRevocationList served there can only +// enumerate that directory's own revoked keys. Each key is therefore checked +// against ITS OWN side's list, reached the same way its anchor was: +// exchange_signing_public_key against the issuing Exchange's list, reached +// from the `exchange` host inside offer_canonical_bytes; agent_public_key +// against the agent's list, reached from the independent directory that +// supplied the agent anchor above — never from agent_directory_url, which is +// provenance and not authority. +// +// SCOPE OF THE GUARANTEE. The signatures cover what was AGREED, not what was +// DELIVERED. transaction_id, request_correlation, broker, created_at and +// agent_directory_url are this Exchange's own assertions, outside both +// signatures; the delivery witness is the edge delivery log, reconciled +// separately (the join key, sha256 of the signed retrieval URL, lives on +// TransactionState.signed_url_hash). agent_directory_url is listed here as well as under the +// trust boundary above because it is the one unsigned field a reader is most +// likely to mistake for an anchor. +// +// WHAT A ROW HOLDER CAN REPLAY. The delivery section below withholds the +// signed retrieval URL because it is a live bearer capability. Applying the +// same test to what the row DOES carry gives two different answers. +// +// THE OFFER IS REPLAYABLE, AND THAT IS A STATED RESIDUAL RISK. offer_json +// plus offer_sig are a complete, valid, Exchange-signed offer. +// ramp.v1.Offer binds NO requester and NO tenant — it has no audience field +// naming who the offer was issued to — and its expires_at is optional. So a +// row holder can present this same offer to its issuing Exchange and accept +// it under their OWN identity, and nothing inside the signed bytes +// contradicts them. Three things bound that, and none of them closes it: +// - expires_at ends the window, when the Exchange set one; +// - Offer.exchange names exactly one Exchange that will accept the offer, +// so a replay is confined to that Exchange's own terms and billing; +// - the network-layer reachability restriction on this plane (see the file +// header) decides who can read a row at all. +// Closing it needs a requester audience INSIDE the signed offer, which +// belongs upstream in ramp.v1 and is not something this plane can add. +// +// THE ACCEPTANCE IS NOT USEFULLY REPLAYABLE. The four fields of +// ramp.v1.AgentAcceptancePayload are offer_sig, requester_id, +// requester_domain and idempotency_key — the last of which this row stores +// under the name request_idempotency_key, to keep it distinct from the +// derived per-item key on TransactionState. agent_acceptance_signature is the +// signature over those four, so the row does hold a complete, resubmittable +// acceptance. Resubmitting it achieves nothing: it carries the same +// request-level idempotency key under the same acceptance identity, so it +// lands in the same dedupe namespace and the Exchange returns the original +// result instead of executing again. What the row does NOT hold is the +// agent's private key, so a holder cannot mint an acceptance for a different +// offer, identity, or key. The replay exposure here is the offer's, not the +// acceptance's. +type TransactionEvidence struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The evidenced transaction (Exchange-minted transaction identity). The + // format is implementation-defined, exactly as in ramp.v1 (the documented + // storage model mints a 26-char ULID). The 255 bound is NEW to this plane — + // ramp.v1 leaves transaction ids unconstrained — and is safe here because + // the Exchange mints the id itself, far below that bound; it exists so the + // selector stays storable and indexable. + TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` + // The tenant the transaction executed under. The admin plane is + // deployment-scoped (cross-tenant), so the row states its tenant. Same rule + // as ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated). + TenantId string `protobuf:"bytes,2,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + // The signed Offer.offer_id (which IS the catalog resource_id). Duplicated + // from the offer JSON so the row reads standalone, without parsing it. + OfferId string `protobuf:"bytes,3,opt,name=offer_id,json=offerId,proto3" json:"offer_id,omitempty"` + // The signed offer as a raw JSON string, for query and human audit. + // Deliberately NOT a Struct: a Struct re-normalizes, and the canonical + // bytes below remain the arbiter of what was signed. No upper bound, unlike + // this file's 255-capped ids: upstream ramp.v1 places no size bound on an + // offer, and the row must state whatever the parties actually signed — a + // cap here could make the row fail its own validation for a transaction + // that legitimately executed (the requester_id rationale). + OfferJson string `protobuf:"bytes,4,opt,name=offer_json,json=offerJson,proto3" json:"offer_json,omitempty"` + // Verbatim JCS bytes the Exchange's signature was computed over (the offer + // with its signature fields cleared). min_len only, no ceiling: same + // rationale as offer_json — the bytes under the signature are whatever size + // the signed offer was, and a bound could invalidate a legitimate row. + OfferCanonicalBytes []byte `protobuf:"bytes,5,opt,name=offer_canonical_bytes,json=offerCanonicalBytes,proto3" json:"offer_canonical_bytes,omitempty"` + // The Exchange's Ed25519 signature over offer_canonical_bytes, hex-encoded + // in the verbatim wire form (either case — hex decoding accepts both, and a + // dispute should read the same characters a request log holds). Named after + // ramp.v1.AgentAcceptancePayload.offer_sig: it is the same value, the one + // the agent's acceptance binds to. Same rule as ramp.v1.Offer.signature + // (drift-gated) — the field this row stores a copy of. + OfferSig string `protobuf:"bytes,6,opt,name=offer_sig,json=offerSig,proto3" json:"offer_sig,omitempty"` + // Signing-algorithm label, server-derived from the Exchange's own verify + // path — never echoed from the wire. The canonical payload clears the wire + // labels before signing, so an echoed label would sit outside signature + // coverage and could claim anything under an otherwise valid signature. + // Pinned to "EdDSA" — the content-signature label + // ramp.v1.Offer.signature_algorithm pins; "ed25519" is the separate label + // reserved for RFC 9421 HTTP request signatures and never appears here. + // const (not min_len) so a generated client also rejects a claimed "none" + // or "HS256". + // + // Spelled sig_algorithm, not signature_algorithm, which is how ramp.v1 and + // the sibling agent_acceptance_signature_algorithm spell it. The short form + // is INHERITED, not chosen: this label names the neighbouring offer_sig, and + // that field copies an upstream field name verbatim + // (ramp.v1.AgentAcceptancePayload.offer_sig). A label that renamed the field + // it describes would be the worse inconsistency. + // + // The long spelling is also not available: ramp.v1 retired a scalar + // offer-signature field (the execute request now reflects the + // full signed Offer instead), and scripts/check-doc-conformance.sh bans that + // identifier across the protos and the docs so the removed name cannot be + // read as live anywhere. A field named after it here would either fail that + // gate or force it open. + OfferSigAlgorithm string `protobuf:"bytes,7,opt,name=offer_sig_algorithm,json=offerSigAlgorithm,proto3" json:"offer_sig_algorithm,omitempty"` + // The Exchange verifying key itself (raw 32-byte Ed25519), not a key id. + ExchangeSigningPublicKey []byte `protobuf:"bytes,8,opt,name=exchange_signing_public_key,json=exchangeSigningPublicKey,proto3" json:"exchange_signing_public_key,omitempty"` + // The agent's Ed25519 signature over agent_acceptance_canonical_bytes, + // hex-encoded verbatim as it arrived on the wire (either case). Same rule + // as ramp.v1.AgentAcceptance.signature (drift-gated) — the live field this + // row stores a copy of. + // + // Both directives here point UPSTREAM into ramp.v1, which they did not + // always do. This pattern was pinned on the read plane first, while the + // agent plane still described the hex shape in prose and enforced nothing; + // the anchors sat inside this package because there was no upstream rule to + // point at. ramp.v1 now carries the rule on both signature fields, so the + // gate compares the two planes against each other and a future tightening + // on one side can no longer leave the other silently behind. + AgentAcceptanceSignature string `protobuf:"bytes,9,opt,name=agent_acceptance_signature,json=agentAcceptanceSignature,proto3" json:"agent_acceptance_signature,omitempty"` + // Verbatim JCS bytes of the AgentAcceptancePayload the agent signed. + // Unbounded for the same reason as offer_canonical_bytes. Same rule as + // ramp.admin.v1.TransactionEvidence.offer_canonical_bytes (drift-gated). + AgentAcceptanceCanonicalBytes []byte `protobuf:"bytes,10,opt,name=agent_acceptance_canonical_bytes,json=agentAcceptanceCanonicalBytes,proto3" json:"agent_acceptance_canonical_bytes,omitempty"` + // Signing-algorithm label, server-derived (see offer_sig_algorithm). + // Pinned to "EdDSA". Same rule as + // ramp.admin.v1.TransactionEvidence.offer_sig_algorithm (drift-gated). + AgentAcceptanceSignatureAlgorithm string `protobuf:"bytes,11,opt,name=agent_acceptance_signature_algorithm,json=agentAcceptanceSignatureAlgorithm,proto3" json:"agent_acceptance_signature_algorithm,omitempty"` + // The acceptance payload's remaining inputs (offer_sig above is the + // fourth), stored so the signed bytes can be independently rebuilt and + // audited rather than merely trusted. + // + // requester_id is the signed Requester.id VERBATIM — the bytes under the + // agent's signature, never rewritten. It NAMES the same agent as the + // Exchange's canonical agent identity but is not byte-equal to it: a signer + // may spell its directory any way it likes (the deployed identity service + // signs "scheme://host"), so the forensic join goes through directory-host + // normalization, not plain equality. No wire rule: the agent plane does not + // constrain Requester.id, and this row states what was signed. + RequesterId string `protobuf:"bytes,12,opt,name=requester_id,json=requesterId,proto3" json:"requester_id,omitempty"` + // The signed Requester.domain, verbatim. Unbounded HERE even though the + // agent plane bounds it — ramp.v1.Requester.domain carries max_len 260 and + // the bare-host pattern. Those rules govern what an Exchange may ACCEPT on + // the way in; they do not govern what this row may STATE after the fact. The + // row's job is to reproduce the bytes the acceptance actually signed, so a + // rule here could make the row fail its own validation for a transaction + // that legitimately executed — one accepted under an earlier rule set, or + // signed by a party that spelled the value some other way. Same conclusion + // as requester_id, reached differently: Requester.id genuinely carries no + // wire rule at all. + RequesterDomain string `protobuf:"bytes,13,opt,name=requester_domain,json=requesterDomain,proto3" json:"requester_domain,omitempty"` + // The REQUEST-level idempotency key the acceptance signs — NOT the derived + // per-item key that TransactionState.idempotency_key carries. Same rule as + // ramp.v1.TransactionRequest.idempotency_key (drift-gated). + RequestIdempotencyKey string `protobuf:"bytes,14,opt,name=request_idempotency_key,json=requestIdempotencyKey,proto3" json:"request_idempotency_key,omitempty"` + // The registry-pinned agent verifying key (raw 32-byte Ed25519) the + // acceptance verified against. This is the ACCEPTANCE key, which is the + // agent identity for the transaction — ramp.v1.AgentAcceptance defines that + // normatively under "Agent identity", and this row stores the key that + // definition names. It is deliberately NOT the transport signer: a Broker + // may author a re-packaged execute as sender, so the RFC 9421 signer on that + // leg is the broker, and a row anchored on it would name the wrong party. + // Same rule as + // ramp.admin.v1.TransactionEvidence.exchange_signing_public_key + // (drift-gated). + AgentPublicKey []byte `protobuf:"bytes,15,opt,name=agent_public_key,json=agentPublicKey,proto3" json:"agent_public_key,omitempty"` + // The anchored well-known directory agent_public_key was pinned from. The + // registry overwrites keys in place on rotation and keeps no history, so + // this — plus created_at — attests where and when this Exchange obtained + // the key. Empty when the agent carries no directory anchor: an append-once + // row states a value for every column, so ” is a stated fact, not a gap. + // + // PROVENANCE, NOT AUTHORITY. This field is covered by neither signature and + // is written by the same party as the rest of the row, so it can never + // establish that agent_public_key is authentic — see TRUST BOUNDARY above, + // which says where the agent anchor must come from instead. Verification + // tooling MUST NOT treat this value as a fetch target it can trust: the row + // author chose it, so following it hands them the choice of what the + // "independent" copy says. + // + // The rules below bound the damage from tooling that follows the field + // anyway; they do not make following it safe. The value must be ” or an + // https URL whose host uses the same recipient-host grammar as + // ramp.v1.Offer.exchange, with an optional port and an ASCII-printable path, + // within 512 bytes. Stated precisely, because a rule that sounds stronger + // than it is would be worse than none: this refuses a plaintext or non-http + // scheme, embedded userinfo or whitespace, and anything that is not a + // host-plus-path shape. It does NOT refuse an IPv4-literal host — the + // recipient-host grammar admits all-numeric labels, so https://169.254.169.254/ + // matches. Blocking link-local and private address space is the fetching + // tool's job, and it is one more reason this field is not a fetch target. + // + // Named directory, not discovery: ramp.v1 uses "discovery" for RESOURCE + // discovery (DiscoveryRequest, OfferGroup.discovery_method), a different + // thing entirely. This is the agent's well-known directory document, which + // is what every sentence describing the field already calls it. + AgentDirectoryUrl string `protobuf:"bytes,16,opt,name=agent_directory_url,json=agentDirectoryUrl,proto3" json:"agent_directory_url,omitempty"` + // Correlation id joining this row outward to whatever else recorded the + // same X-Request-ID for this execute call, with its provenance. One + // message, not two + // sibling fields: presence of the message is the pairing — id and + // provenance flag arrive together or not at all, a constraint two + // optional siblings could not express without message-level CEL (which + // this file forbids). Absent when the Exchange recorded no correlation id. + RequestCorrelation *RequestCorrelation `protobuf:"bytes,17,opt,name=request_correlation,json=requestCorrelation,proto3" json:"request_correlation,omitempty"` + // When the Exchange wrote this row (server clock). + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,18,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // The relay hop that presented this request to the Exchange, if the + // Exchange records one. A transport fact the Exchange observed, covered by + // neither signature — which is why it sits in this section and not on + // TransactionState: TransactionState projects transaction-log columns, and + // broker routing is an execute-time observation about the connection, not + // a property of the transaction's operational state. + // + // Three states, and the `optional` keyword is what makes them distinct: + // ABSENT means this Exchange does not record routing at all; ” means it + // does record it AND the acceptance arrived direct; a value means it + // arrived through that hop. Without explicit presence the field would + // default to ”, so an Exchange with nothing to say would state "arrived + // direct" for every row — a forensic plane asserting a transport fact it + // never observed. + // + // WHAT THE VALUE IS: implementation-defined provenance for the outermost + // hop, not a resolvable identity. The reference Exchange serves the + // verified RFC 7638 key thumbprint of the hop that presented the request. + // It deliberately does not resolve that key to a directory host: the relay + // hop is not re-identified against any registry, and the recipient tenant's + // own relay-permission setting is the gate instead. So a reader may compare + // this value for equality and may check it against a thumbprint it already + // holds, but must not expect a hostname, and must not treat it as an + // identity the Exchange vouched for. Only the outermost hop is classified; + // per-hop identity for a longer chain is out of scope here. + // + // The rule bounds the SHAPE without pinning the format. A ledger renders + // this value, so an unbounded string here would re-open on a new field + // exactly the surface request_id's printable-ASCII bound closes — control + // characters, terminal escapes and newlines reaching a rendered forensic + // row. Printable ASCII and 255 characters admit every provenance form a + // server might reasonably record (a thumbprint, a host, an opaque id) while + // refusing the shapes that only matter to a renderer. It is deliberately + // NOT a thumbprint pattern: the value is implementation-defined, and a + // format rule here could invalidate a row for a transaction that + // legitimately executed under a server that spells it some other way — the + // requester_id reasoning. The pattern admits the EMPTY string explicitly, + // because ” is one of the three states — recorded, and the acceptance + // arrived direct. A bare ^[!-~]+$ would need at least one character and + // would delete that state, leaving absence to mean both "not recorded" and + // "arrived direct". Same alternation shape agent_directory_url uses above, + // for the same reason. + Broker *string `protobuf:"bytes,19,opt,name=broker,proto3,oneof" json:"broker,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransactionEvidence) Reset() { + *x = TransactionEvidence{} + mi := &file_ramp_admin_v1_admin_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransactionEvidence) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransactionEvidence) ProtoMessage() {} + +func (x *TransactionEvidence) ProtoReflect() protoreflect.Message { + mi := &file_ramp_admin_v1_admin_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransactionEvidence.ProtoReflect.Descriptor instead. +func (*TransactionEvidence) Descriptor() ([]byte, []int) { + return file_ramp_admin_v1_admin_proto_rawDescGZIP(), []int{6} +} + +func (x *TransactionEvidence) GetTransactionId() string { + if x != nil { + return x.TransactionId + } + return "" +} + +func (x *TransactionEvidence) GetTenantId() string { + if x != nil { + return x.TenantId + } + return "" +} + +func (x *TransactionEvidence) GetOfferId() string { + if x != nil { + return x.OfferId + } + return "" +} + +func (x *TransactionEvidence) GetOfferJson() string { + if x != nil { + return x.OfferJson + } + return "" +} + +func (x *TransactionEvidence) GetOfferCanonicalBytes() []byte { + if x != nil { + return x.OfferCanonicalBytes + } + return nil +} + +func (x *TransactionEvidence) GetOfferSig() string { + if x != nil { + return x.OfferSig + } + return "" +} + +func (x *TransactionEvidence) GetOfferSigAlgorithm() string { + if x != nil { + return x.OfferSigAlgorithm + } + return "" +} + +func (x *TransactionEvidence) GetExchangeSigningPublicKey() []byte { + if x != nil { + return x.ExchangeSigningPublicKey + } + return nil +} + +func (x *TransactionEvidence) GetAgentAcceptanceSignature() string { + if x != nil { + return x.AgentAcceptanceSignature + } + return "" +} + +func (x *TransactionEvidence) GetAgentAcceptanceCanonicalBytes() []byte { + if x != nil { + return x.AgentAcceptanceCanonicalBytes + } + return nil +} + +func (x *TransactionEvidence) GetAgentAcceptanceSignatureAlgorithm() string { + if x != nil { + return x.AgentAcceptanceSignatureAlgorithm + } + return "" +} + +func (x *TransactionEvidence) GetRequesterId() string { + if x != nil { + return x.RequesterId + } + return "" +} + +func (x *TransactionEvidence) GetRequesterDomain() string { + if x != nil { + return x.RequesterDomain + } + return "" +} + +func (x *TransactionEvidence) GetRequestIdempotencyKey() string { + if x != nil { + return x.RequestIdempotencyKey + } + return "" +} + +func (x *TransactionEvidence) GetAgentPublicKey() []byte { + if x != nil { + return x.AgentPublicKey + } + return nil +} + +func (x *TransactionEvidence) GetAgentDirectoryUrl() string { + if x != nil { + return x.AgentDirectoryUrl + } + return "" +} + +func (x *TransactionEvidence) GetRequestCorrelation() *RequestCorrelation { + if x != nil { + return x.RequestCorrelation + } + return nil +} + +func (x *TransactionEvidence) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *TransactionEvidence) GetBroker() string { + if x != nil && x.Broker != nil { + return *x.Broker + } + return "" +} + +// RequestCorrelation — the recorded X-Request-ID correlation for one +// evidence row, with its provenance. See TransactionEvidence +// .request_correlation for why this is a message rather than two sibling +// fields. +type RequestCorrelation struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The correlation id as persisted. GOVERNING INVARIANT, established on the + // WRITE path: a persisted request_id always conforms to the rules below — + // printable ASCII, 1..255 — so a present value has already passed the check + // on the way in, and these rules are not a read-side filter over a laxer + // stored value. HOW a server reaches that invariant is its own choice, and + // two mechanisms both conform: reject the nonconforming header and record a + // server-derived id in its place (minted = true), or record no correlation + // at all (the wrapping message stays absent). The first keeps a correlation + // key for a request whose header was bad, the second states that nothing + // trustworthy arrived; neither can put a nonconforming value in the store, + // which is the only property this contract needs. A server that accepts a + // narrower charset than the rules below still satisfies the invariant. + // Background, for a reader tracing where the value comes from: a propagated + // id is caller-influenceable, which is what `minted` below exists to record. + // Which component performs the check is deliberately not stated here. It is + // server behaviour, this file cannot gate it, and an earlier revision of this + // comment described a particular SDK's middleware and was made wrong by a + // change to that SDK three commits later. + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Provenance: true = the id is SERVER-DERIVED, false = propagated verbatim + // from a caller-supplied header. True covers both ways a server derives + // one — the header was absent, or it was present but nonconforming and was + // replaced — because the property this flag exists for is INFLUENCE, not + // origin story: false means a caller chose these characters, true means no + // caller did. The two are byte-indistinguishable in request_id alone, so a + // forensic read needs this flag to tell a server-derived correlation key + // from an attacker-influenceable one. + Minted bool `protobuf:"varint,2,opt,name=minted,proto3" json:"minted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestCorrelation) Reset() { + *x = RequestCorrelation{} + mi := &file_ramp_admin_v1_admin_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestCorrelation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestCorrelation) ProtoMessage() {} + +func (x *RequestCorrelation) ProtoReflect() protoreflect.Message { + mi := &file_ramp_admin_v1_admin_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RequestCorrelation.ProtoReflect.Descriptor instead. +func (*RequestCorrelation) Descriptor() ([]byte, []int) { + return file_ramp_admin_v1_admin_proto_rawDescGZIP(), []int{7} +} + +func (x *RequestCorrelation) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *RequestCorrelation) GetMinted() bool { + if x != nil { + return x.Minted + } + return false +} + +// TransactionState — the thin transaction-log facts a ledger renderer needs +// next to the evidence row. The log row is the operational record (updated +// when a usage report lands); the evidence row is the append-once proof. +// There is deliberately no status field: a denied execute aborts before any +// row is written, so evidence only ever describes a successful execute — +// existence is the status, and a renderer derives its status cell from it. +type TransactionState struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The transaction's per-item idempotency key as logged. The Exchange + // derives it as TransactionEvidence.request_idempotency_key + ":" + + // offer_id — unconditionally, single-item requests included — so distinct + // items of a batch dedupe independently, and this value is NEVER byte-equal + // to the request-level key. A ledger joining this row against a log export + // matches on this derived form, not on the bare request key, and it reaches + // the TRANSACTION-side events only: a usage-report event stores the report's + // own idempotency key, because a report addresses a whole transaction and + // has no offer id to derive with. Join a usage report on transaction_id + // instead. No upper + // bound: the derivation appends an id whose length nothing constrains. + IdempotencyKey string `protobuf:"bytes,1,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + // When the signed retrieval URL expires. Named to pair with signed_url_hash + // below, so the two fields describing one minted URL read as a pair and + // neither can be mistaken for a property of the transaction itself. Do not + // read the name as a column name: stores spell this one differently + // (TransactionResultItem.expires_at on the wire, and the reference + // Exchange's transaction log calls the column plainly `expiry`), so a + // ledger joining to a log matches this field by MEANING, not by name. + // signed_url_hash is the one that happens to match a real column name. + // + // Absent when the transaction minted no signed URL: DELIVERY_METHOD_DIRECT + // returns the resource inline or from the Exchange's own endpoint, so there + // is nothing to expire. DELIVERY_METHOD_INSTRUCTIONS and + // DELIVERY_METHOD_STREAMING both mint one and always carry this field. + // Absence is a stated fact about the delivery method, not missing data: a + // direct delivery has no value to state here, so there is nothing an empty + // value could honestly mean. + SignedUrlExpiry *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=signed_url_expiry,json=signedUrlExpiry,proto3" json:"signed_url_expiry,omitempty"` + // sha256 of the signed retrieval URL — the join key against the transaction + // log's signed_url_hash column, which holds the same digest as 32 raw bytes. + // The join is byte-to-byte; nothing needs normalizing. Text only appears + // when a store is rendered — protojson base64s this field, and a log export + // picks its own spelling — so it is exports, not stores, that a join has to + // reconcile. Hash-only by design: the full URL is a live + // bearer capability until expiry and is deliberately absent from this + // plane (see TransactionEvidence's delivery section). Absent exactly when + // signed_url_expiry is, and for the same reason: no signed URL, nothing to + // hash. The + // `optional` keyword is load-bearing — it gives this scalar explicit + // presence, so protovalidate skips the length rule on an unset value, while + // a PRESENT hash must still be exactly 32 bytes. + SignedUrlHash []byte `protobuf:"bytes,3,opt,name=signed_url_hash,json=signedUrlHash,proto3,oneof" json:"signed_url_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransactionState) Reset() { + *x = TransactionState{} + mi := &file_ramp_admin_v1_admin_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransactionState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransactionState) ProtoMessage() {} + +func (x *TransactionState) ProtoReflect() protoreflect.Message { + mi := &file_ramp_admin_v1_admin_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransactionState.ProtoReflect.Descriptor instead. +func (*TransactionState) Descriptor() ([]byte, []int) { + return file_ramp_admin_v1_admin_proto_rawDescGZIP(), []int{8} +} + +func (x *TransactionState) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *TransactionState) GetSignedUrlExpiry() *timestamppb.Timestamp { + if x != nil { + return x.SignedUrlExpiry + } + return nil +} + +func (x *TransactionState) GetSignedUrlHash() []byte { + if x != nil { + return x.SignedUrlHash + } + return nil +} + +// ReportingObligationState — the server-side lifecycle record of the +// transaction's reporting obligation, as persisted. Named apart from +// ramp.v1.ReportingObligation, which is the agent-facing requirements +// contract; this is the state those requirements minted. +// +// EVERY field here is backed by a column on the obligation row, with no +// exceptions and no translation step. The timestamp fields carry the store's +// own column names (WindowEnd, FulfilledAt, CreatedAt) in snake_case, so a +// reader can join this record against the storage model by name, and +// consumed_quantity is a column too — written in the same statement as the +// state transition when a report validates. +// +// That completeness is the property worth having, and it is what makes this +// message a projection rather than an assembly. A single field sourced +// elsewhere would mean a reader could not tell, from the message alone, which +// values a server had to go looking for. +type ReportingObligationState struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Lifecycle state. Always a real persisted state, never UNSPECIFIED. + // Server-output enum: {defined_only, not_in: [0]} — a reader must never + // see a number its schema cannot name. Same rule as + // ramp.v1.TransactionDenial.reason (drift-gated) — the discipline the + // ErrorDetail reason discriminators establish for server-output enums. + State ObligationState `protobuf:"varint,1,opt,name=state,proto3,enum=ramp.admin.v1.ObligationState" json:"state,omitempty"` + // Reported consumed quantity, in the metering unit from the Offer's + // Pricing — the value the accepted usage report carried. Mirrors + // ramp.v1.Usage.consumed_quantity's wire type (int32, unconstrained) + // exactly: this view must be able to state whatever the report stated, + // and a decimal-string shape here could express values (e.g. "3.5") no + // report can produce. Absent until a usage report has been accepted. + ConsumedQuantity *int32 `protobuf:"varint,2,opt,name=consumed_quantity,json=consumedQuantity,proto3,oneof" json:"consumed_quantity,omitempty"` + // When the usage report is due (the store's WindowEnd). An absolute + // instant, not the ramp.v1.ReportingObligation.window Duration it was + // derived from: this record states what the store holds, and the store + // resolved the window against created_at when it minted the obligation. + WindowEnd *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=window_end,json=windowEnd,proto3" json:"window_end,omitempty"` + // When a usage report was ACCEPTED (the store's FulfilledAt) — the same + // event that moves state to OBLIGATION_STATE_FULFILLED. Not "when a report + // arrived": a report that arrived and was rejected leaves this absent, and + // the obligation still expires on window_end. + FulfilledAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=fulfilled_at,json=fulfilledAt,proto3,oneof" json:"fulfilled_at,omitempty"` + // When the obligation was minted (the store's CreatedAt). + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportingObligationState) Reset() { + *x = ReportingObligationState{} + mi := &file_ramp_admin_v1_admin_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportingObligationState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportingObligationState) ProtoMessage() {} + +func (x *ReportingObligationState) ProtoReflect() protoreflect.Message { + mi := &file_ramp_admin_v1_admin_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportingObligationState.ProtoReflect.Descriptor instead. +func (*ReportingObligationState) Descriptor() ([]byte, []int) { + return file_ramp_admin_v1_admin_proto_rawDescGZIP(), []int{9} +} + +func (x *ReportingObligationState) GetState() ObligationState { + if x != nil { + return x.State + } + return ObligationState_OBLIGATION_STATE_UNSPECIFIED +} + +func (x *ReportingObligationState) GetConsumedQuantity() int32 { + if x != nil && x.ConsumedQuantity != nil { + return *x.ConsumedQuantity + } + return 0 +} + +func (x *ReportingObligationState) GetWindowEnd() *timestamppb.Timestamp { + if x != nil { + return x.WindowEnd + } + return nil +} + +func (x *ReportingObligationState) GetFulfilledAt() *timestamppb.Timestamp { + if x != nil { + return x.FulfilledAt + } + return nil +} + +func (x *ReportingObligationState) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +type GetTransactionEvidenceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // RAMP protocol version — "1.0". Stamped by the sender from a single + // constant; advisory on receive. See "Protocol version" in ramp.proto. + Ver string `protobuf:"bytes,1,opt,name=ver,proto3" json:"ver,omitempty"` + // The transaction whose evidence row to fetch. Same rule as + // ramp.admin.v1.TransactionEvidence.transaction_id (drift-gated) — the row + // identity this request selects by. + TransactionId string `protobuf:"bytes,2,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` + // The tenant the transaction must belong to — the second half of the + // selector, matched against TransactionEvidence.tenant_id. Required: + // counterparty agents legitimately hold transaction ids, so the id alone + // must not be enough to read the row. Naming the tenant narrows what a + // leaked id is worth; it does not authenticate the caller, and nothing on + // this plane does. A mismatch is NOT_FOUND, + // byte-identical to an unknown transaction_id, so existence under another + // tenant is not revealed. Same rule as + // ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated). + TenantId string `protobuf:"bytes,3,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTransactionEvidenceRequest) Reset() { + *x = GetTransactionEvidenceRequest{} + mi := &file_ramp_admin_v1_admin_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTransactionEvidenceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTransactionEvidenceRequest) ProtoMessage() {} + +func (x *GetTransactionEvidenceRequest) ProtoReflect() protoreflect.Message { + mi := &file_ramp_admin_v1_admin_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTransactionEvidenceRequest.ProtoReflect.Descriptor instead. +func (*GetTransactionEvidenceRequest) Descriptor() ([]byte, []int) { + return file_ramp_admin_v1_admin_proto_rawDescGZIP(), []int{10} +} + +func (x *GetTransactionEvidenceRequest) GetVer() string { + if x != nil { + return x.Ver + } + return "" +} + +func (x *GetTransactionEvidenceRequest) GetTransactionId() string { + if x != nil { + return x.TransactionId + } + return "" +} + +func (x *GetTransactionEvidenceRequest) GetTenantId() string { + if x != nil { + return x.TenantId + } + return "" +} + +type GetTransactionEvidenceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // RAMP protocol version — "1.0". Stamped by the sender from a single + // constant; advisory on receive. See "Protocol version" in ramp.proto. + Ver string `protobuf:"bytes,1,opt,name=ver,proto3" json:"ver,omitempty"` + // The append-once evidence row. Required: it exists 1:1 for every found + // transaction — an unknown transaction_id is NOT_FOUND, never an empty + // response. + Evidence *TransactionEvidence `protobuf:"bytes,2,opt,name=evidence,proto3" json:"evidence,omitempty"` + // The transaction-log facts next to it. Required for the same 1:1 reason. + TransactionState *TransactionState `protobuf:"bytes,3,opt,name=transaction_state,json=transactionState,proto3" json:"transaction_state,omitempty"` + // The transaction's reporting obligation record, as persisted. The store + // keeps ONE obligation per transaction (keyed on the transaction id, + // transitioning in place — see the storage model), so this is the record + // the Exchange's own reporting path acts on, not a "latest of several". + // Absent when the transaction minted none. + ObligationState *ReportingObligationState `protobuf:"bytes,4,opt,name=obligation_state,json=obligationState,proto3" json:"obligation_state,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTransactionEvidenceResponse) Reset() { + *x = GetTransactionEvidenceResponse{} + mi := &file_ramp_admin_v1_admin_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTransactionEvidenceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTransactionEvidenceResponse) ProtoMessage() {} + +func (x *GetTransactionEvidenceResponse) ProtoReflect() protoreflect.Message { + mi := &file_ramp_admin_v1_admin_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTransactionEvidenceResponse.ProtoReflect.Descriptor instead. +func (*GetTransactionEvidenceResponse) Descriptor() ([]byte, []int) { + return file_ramp_admin_v1_admin_proto_rawDescGZIP(), []int{11} +} + +func (x *GetTransactionEvidenceResponse) GetVer() string { + if x != nil { + return x.Ver + } + return "" +} + +func (x *GetTransactionEvidenceResponse) GetEvidence() *TransactionEvidence { + if x != nil { + return x.Evidence + } + return nil +} + +func (x *GetTransactionEvidenceResponse) GetTransactionState() *TransactionState { + if x != nil { + return x.TransactionState + } + return nil +} + +func (x *GetTransactionEvidenceResponse) GetObligationState() *ReportingObligationState { + if x != nil { + return x.ObligationState + } + return nil +} + var File_ramp_admin_v1_admin_proto protoreflect.FileDescriptor const file_ramp_admin_v1_admin_proto_rawDesc = "" + "\n" + - "\x19ramp/admin/v1/admin.proto\x12\rramp.admin.v1\x1a\x1bbuf/validate/validate.proto\"\x95\x01\n" + + "\x19ramp/admin/v1/admin.proto\x12\rramp.admin.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x95\x01\n" + "\rTenantFeeRate\x12'\n" + "\ttenant_id\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\btenantId\x12,\n" + @@ -463,10 +1522,79 @@ const file_ramp_admin_v1_admin_proto_rawDesc = "" + "\x06policy\x18\x02 \x01(\v2\x1e.ramp.admin.v1.ReportingPolicyB\x06\xbaH\x03\xc8\x01\x01R\x06policy\"n\n" + "\x1aSetReportingPolicyResponse\x12\x10\n" + "\x03ver\x18\x01 \x01(\tR\x03ver\x12>\n" + - "\x06policy\x18\x02 \x01(\v2\x1e.ramp.admin.v1.ReportingPolicyB\x06\xbaH\x03\xc8\x01\x01R\x06policy2\xde\x01\n" + + "\x06policy\x18\x02 \x01(\v2\x1e.ramp.admin.v1.ReportingPolicyB\x06\xbaH\x03\xc8\x01\x01R\x06policy\"\xdd\n" + + "\n" + + "\x13TransactionEvidence\x121\n" + + "\x0etransaction_id\x18\x01 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\rtransactionId\x12'\n" + + "\ttenant_id\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\btenantId\x12\"\n" + + "\boffer_id\x18\x03 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aofferId\x12&\n" + + "\n" + + "offer_json\x18\x04 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\tofferJson\x12;\n" + + "\x15offer_canonical_bytes\x18\x05 \x01(\fB\a\xbaH\x04z\x02\x10\x01R\x13offerCanonicalBytes\x126\n" + + "\toffer_sig\x18\x06 \x01(\tB\x19\xbaH\x16r\x142\x12^[0-9A-Fa-f]{128}$R\bofferSig\x12<\n" + + "\x13offer_sig_algorithm\x18\a \x01(\tB\f\xbaH\tr\a\n" + + "\x05EdDSAR\x11offerSigAlgorithm\x12F\n" + + "\x1bexchange_signing_public_key\x18\b \x01(\fB\a\xbaH\x04z\x02h R\x18exchangeSigningPublicKey\x12W\n" + + "\x1aagent_acceptance_signature\x18\t \x01(\tB\x19\xbaH\x16r\x142\x12^[0-9A-Fa-f]{128}$R\x18agentAcceptanceSignature\x12P\n" + + " agent_acceptance_canonical_bytes\x18\n" + + " \x01(\fB\a\xbaH\x04z\x02\x10\x01R\x1dagentAcceptanceCanonicalBytes\x12]\n" + + "$agent_acceptance_signature_algorithm\x18\v \x01(\tB\f\xbaH\tr\a\n" + + "\x05EdDSAR!agentAcceptanceSignatureAlgorithm\x12!\n" + + "\frequester_id\x18\f \x01(\tR\vrequesterId\x12)\n" + + "\x10requester_domain\x18\r \x01(\tR\x0frequesterDomain\x12B\n" + + "\x17request_idempotency_key\x18\x0e \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\x15requestIdempotencyKey\x121\n" + + "\x10agent_public_key\x18\x0f \x01(\fB\a\xbaH\x04z\x02h R\x0eagentPublicKey\x12\xfd\x01\n" + + "\x13agent_directory_url\x18\x10 \x01(\tB\xcc\x01\xbaH\xc8\x01r\xc5\x01\x18\x80\x042\xbf\x01^$|^https://[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?/[!-~]*$R\x11agentDirectoryUrl\x12R\n" + + "\x13request_correlation\x18\x11 \x01(\v2!.ramp.admin.v1.RequestCorrelationR\x12requestCorrelation\x12A\n" + + "\n" + + "created_at\x18\x12 \x01(\v2\x1a.google.protobuf.TimestampB\x06\xbaH\x03\xc8\x01\x01R\tcreatedAt\x122\n" + + "\x06broker\x18\x13 \x01(\tB\x15\xbaH\x12r\x10\x18\xff\x012\v^$|^[!-~]+$H\x00R\x06broker\x88\x01\x01B\t\n" + + "\a_broker\"a\n" + + "\x12RequestCorrelation\x123\n" + + "\n" + + "request_id\x18\x01 \x01(\tB\x14\xbaH\x11r\x0f\x10\x01\x18\xff\x012\b^[!-~]+$R\trequestId\x12\x16\n" + + "\x06minted\x18\x02 \x01(\bR\x06minted\"\xd6\x01\n" + + "\x10TransactionState\x120\n" + + "\x0fidempotency_key\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0eidempotencyKey\x12F\n" + + "\x11signed_url_expiry\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x0fsignedUrlExpiry\x124\n" + + "\x0fsigned_url_hash\x18\x03 \x01(\fB\a\xbaH\x04z\x02h H\x00R\rsignedUrlHash\x88\x01\x01B\x12\n" + + "\x10_signed_url_hash\"\xff\x02\n" + + "\x18ReportingObligationState\x12@\n" + + "\x05state\x18\x01 \x01(\x0e2\x1e.ramp.admin.v1.ObligationStateB\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x05state\x120\n" + + "\x11consumed_quantity\x18\x02 \x01(\x05H\x00R\x10consumedQuantity\x88\x01\x01\x12A\n" + + "\n" + + "window_end\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampB\x06\xbaH\x03\xc8\x01\x01R\twindowEnd\x12B\n" + + "\ffulfilled_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampH\x01R\vfulfilledAt\x88\x01\x01\x12A\n" + + "\n" + + "created_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x06\xbaH\x03\xc8\x01\x01R\tcreatedAtB\x14\n" + + "\x12_consumed_quantityB\x0f\n" + + "\r_fulfilled_at\"\x8d\x01\n" + + "\x1dGetTransactionEvidenceRequest\x12\x10\n" + + "\x03ver\x18\x01 \x01(\tR\x03ver\x121\n" + + "\x0etransaction_id\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\rtransactionId\x12'\n" + + "\ttenant_id\x18\x03 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\btenantId\"\xa4\x02\n" + + "\x1eGetTransactionEvidenceResponse\x12\x10\n" + + "\x03ver\x18\x01 \x01(\tR\x03ver\x12F\n" + + "\bevidence\x18\x02 \x01(\v2\".ramp.admin.v1.TransactionEvidenceB\x06\xbaH\x03\xc8\x01\x01R\bevidence\x12T\n" + + "\x11transaction_state\x18\x03 \x01(\v2\x1f.ramp.admin.v1.TransactionStateB\x06\xbaH\x03\xc8\x01\x01R\x10transactionState\x12R\n" + + "\x10obligation_state\x18\x04 \x01(\v2'.ramp.admin.v1.ReportingObligationStateR\x0fobligationState*\xca\x01\n" + + "\x0fObligationState\x12 \n" + + "\x1cOBLIGATION_STATE_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18OBLIGATION_STATE_PENDING\x10\x01\x12\x1e\n" + + "\x1aOBLIGATION_STATE_FULFILLED\x10\x02\x12\x1c\n" + + "\x18OBLIGATION_STATE_EXPIRED\x10\x03\x12\x1b\n" + + "\x17OBLIGATION_STATE_WAIVED\x10\x04\x12\x1c\n" + + "\x18OBLIGATION_STATE_BLOCKED\x10\x052\xd5\x02\n" + "\fAdminService\x12c\n" + "\x10SetTenantFeeRate\x12&.ramp.admin.v1.SetTenantFeeRateRequest\x1a'.ramp.admin.v1.SetTenantFeeRateResponse\x12i\n" + - "\x12SetReportingPolicy\x12(.ramp.admin.v1.SetReportingPolicyRequest\x1a).ramp.admin.v1.SetReportingPolicyResponseB\xb9\x01\n" + + "\x12SetReportingPolicy\x12(.ramp.admin.v1.SetReportingPolicyRequest\x1a).ramp.admin.v1.SetReportingPolicyResponse\x12u\n" + + "\x16GetTransactionEvidence\x12,.ramp.admin.v1.GetTransactionEvidenceRequest\x1a-.ramp.admin.v1.GetTransactionEvidenceResponseB\xb9\x01\n" + "\x11com.ramp.admin.v1B\n" + "AdminProtoP\x01ZBgithub.com/RAMP-Protocol/protocol/gen/go/ramp/admin/v1;rampadminv1\xa2\x02\x03RAX\xaa\x02\rRamp.Admin.V1\xca\x02\rRamp\\Admin\\V1\xe2\x02\x19Ramp\\Admin\\V1\\GPBMetadata\xea\x02\x0fRamp::Admin::V1b\x06proto3" @@ -482,29 +1610,50 @@ func file_ramp_admin_v1_admin_proto_rawDescGZIP() []byte { return file_ramp_admin_v1_admin_proto_rawDescData } -var file_ramp_admin_v1_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_ramp_admin_v1_admin_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_ramp_admin_v1_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_ramp_admin_v1_admin_proto_goTypes = []any{ - (*TenantFeeRate)(nil), // 0: ramp.admin.v1.TenantFeeRate - (*ReportingPolicy)(nil), // 1: ramp.admin.v1.ReportingPolicy - (*SetTenantFeeRateRequest)(nil), // 2: ramp.admin.v1.SetTenantFeeRateRequest - (*SetTenantFeeRateResponse)(nil), // 3: ramp.admin.v1.SetTenantFeeRateResponse - (*SetReportingPolicyRequest)(nil), // 4: ramp.admin.v1.SetReportingPolicyRequest - (*SetReportingPolicyResponse)(nil), // 5: ramp.admin.v1.SetReportingPolicyResponse + (ObligationState)(0), // 0: ramp.admin.v1.ObligationState + (*TenantFeeRate)(nil), // 1: ramp.admin.v1.TenantFeeRate + (*ReportingPolicy)(nil), // 2: ramp.admin.v1.ReportingPolicy + (*SetTenantFeeRateRequest)(nil), // 3: ramp.admin.v1.SetTenantFeeRateRequest + (*SetTenantFeeRateResponse)(nil), // 4: ramp.admin.v1.SetTenantFeeRateResponse + (*SetReportingPolicyRequest)(nil), // 5: ramp.admin.v1.SetReportingPolicyRequest + (*SetReportingPolicyResponse)(nil), // 6: ramp.admin.v1.SetReportingPolicyResponse + (*TransactionEvidence)(nil), // 7: ramp.admin.v1.TransactionEvidence + (*RequestCorrelation)(nil), // 8: ramp.admin.v1.RequestCorrelation + (*TransactionState)(nil), // 9: ramp.admin.v1.TransactionState + (*ReportingObligationState)(nil), // 10: ramp.admin.v1.ReportingObligationState + (*GetTransactionEvidenceRequest)(nil), // 11: ramp.admin.v1.GetTransactionEvidenceRequest + (*GetTransactionEvidenceResponse)(nil), // 12: ramp.admin.v1.GetTransactionEvidenceResponse + (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp } var file_ramp_admin_v1_admin_proto_depIdxs = []int32{ - 0, // 0: ramp.admin.v1.SetTenantFeeRateRequest.rate:type_name -> ramp.admin.v1.TenantFeeRate - 0, // 1: ramp.admin.v1.SetTenantFeeRateResponse.rate:type_name -> ramp.admin.v1.TenantFeeRate - 1, // 2: ramp.admin.v1.SetReportingPolicyRequest.policy:type_name -> ramp.admin.v1.ReportingPolicy - 1, // 3: ramp.admin.v1.SetReportingPolicyResponse.policy:type_name -> ramp.admin.v1.ReportingPolicy - 2, // 4: ramp.admin.v1.AdminService.SetTenantFeeRate:input_type -> ramp.admin.v1.SetTenantFeeRateRequest - 4, // 5: ramp.admin.v1.AdminService.SetReportingPolicy:input_type -> ramp.admin.v1.SetReportingPolicyRequest - 3, // 6: ramp.admin.v1.AdminService.SetTenantFeeRate:output_type -> ramp.admin.v1.SetTenantFeeRateResponse - 5, // 7: ramp.admin.v1.AdminService.SetReportingPolicy:output_type -> ramp.admin.v1.SetReportingPolicyResponse - 6, // [6:8] is the sub-list for method output_type - 4, // [4:6] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 1, // 0: ramp.admin.v1.SetTenantFeeRateRequest.rate:type_name -> ramp.admin.v1.TenantFeeRate + 1, // 1: ramp.admin.v1.SetTenantFeeRateResponse.rate:type_name -> ramp.admin.v1.TenantFeeRate + 2, // 2: ramp.admin.v1.SetReportingPolicyRequest.policy:type_name -> ramp.admin.v1.ReportingPolicy + 2, // 3: ramp.admin.v1.SetReportingPolicyResponse.policy:type_name -> ramp.admin.v1.ReportingPolicy + 8, // 4: ramp.admin.v1.TransactionEvidence.request_correlation:type_name -> ramp.admin.v1.RequestCorrelation + 13, // 5: ramp.admin.v1.TransactionEvidence.created_at:type_name -> google.protobuf.Timestamp + 13, // 6: ramp.admin.v1.TransactionState.signed_url_expiry:type_name -> google.protobuf.Timestamp + 0, // 7: ramp.admin.v1.ReportingObligationState.state:type_name -> ramp.admin.v1.ObligationState + 13, // 8: ramp.admin.v1.ReportingObligationState.window_end:type_name -> google.protobuf.Timestamp + 13, // 9: ramp.admin.v1.ReportingObligationState.fulfilled_at:type_name -> google.protobuf.Timestamp + 13, // 10: ramp.admin.v1.ReportingObligationState.created_at:type_name -> google.protobuf.Timestamp + 7, // 11: ramp.admin.v1.GetTransactionEvidenceResponse.evidence:type_name -> ramp.admin.v1.TransactionEvidence + 9, // 12: ramp.admin.v1.GetTransactionEvidenceResponse.transaction_state:type_name -> ramp.admin.v1.TransactionState + 10, // 13: ramp.admin.v1.GetTransactionEvidenceResponse.obligation_state:type_name -> ramp.admin.v1.ReportingObligationState + 3, // 14: ramp.admin.v1.AdminService.SetTenantFeeRate:input_type -> ramp.admin.v1.SetTenantFeeRateRequest + 5, // 15: ramp.admin.v1.AdminService.SetReportingPolicy:input_type -> ramp.admin.v1.SetReportingPolicyRequest + 11, // 16: ramp.admin.v1.AdminService.GetTransactionEvidence:input_type -> ramp.admin.v1.GetTransactionEvidenceRequest + 4, // 17: ramp.admin.v1.AdminService.SetTenantFeeRate:output_type -> ramp.admin.v1.SetTenantFeeRateResponse + 6, // 18: ramp.admin.v1.AdminService.SetReportingPolicy:output_type -> ramp.admin.v1.SetReportingPolicyResponse + 12, // 19: ramp.admin.v1.AdminService.GetTransactionEvidence:output_type -> ramp.admin.v1.GetTransactionEvidenceResponse + 17, // [17:20] is the sub-list for method output_type + 14, // [14:17] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_ramp_admin_v1_admin_proto_init() } @@ -514,18 +1663,22 @@ func file_ramp_admin_v1_admin_proto_init() { } file_ramp_admin_v1_admin_proto_msgTypes[0].OneofWrappers = []any{} file_ramp_admin_v1_admin_proto_msgTypes[1].OneofWrappers = []any{} + file_ramp_admin_v1_admin_proto_msgTypes[6].OneofWrappers = []any{} + file_ramp_admin_v1_admin_proto_msgTypes[8].OneofWrappers = []any{} + file_ramp_admin_v1_admin_proto_msgTypes[9].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ramp_admin_v1_admin_proto_rawDesc), len(file_ramp_admin_v1_admin_proto_rawDesc)), - NumEnums: 0, - NumMessages: 6, + NumEnums: 1, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, GoTypes: file_ramp_admin_v1_admin_proto_goTypes, DependencyIndexes: file_ramp_admin_v1_admin_proto_depIdxs, + EnumInfos: file_ramp_admin_v1_admin_proto_enumTypes, MessageInfos: file_ramp_admin_v1_admin_proto_msgTypes, }.Build() File_ramp_admin_v1_admin_proto = out.File diff --git a/gen/go/ramp/admin/v1/rampadminv1connect/admin.connect.go b/gen/go/ramp/admin/v1/rampadminv1connect/admin.connect.go index 65a9d5c3..7ee0ddd2 100644 --- a/gen/go/ramp/admin/v1/rampadminv1connect/admin.connect.go +++ b/gen/go/ramp/admin/v1/rampadminv1connect/admin.connect.go @@ -1,25 +1,54 @@ -// RAMP Admin v1 — operator-plane configuration service. +// RAMP Admin v1 — operator-plane configuration and forensics service. // -// AdminService carries Exchange operator overrides: the tenant fee rate and -// the tenant reporting policy. It is deliberately a separate package and -// service from ramp.v1.ExchangeService — the operator/config plane is not -// part of the agent hot-path contract, and keeping it out of ramp.v1 keeps -// the agent-facing surface unchanged. +// AdminService carries Exchange operator overrides — the tenant fee rate and +// the tenant reporting policy — plus one forensic read: the append-once +// evidence row the Exchange persists for every executed transaction. It is +// deliberately a separate package and service from ramp.v1.ExchangeService — +// the operator plane is not part of the agent hot-path contract, and keeping +// it out of ramp.v1 keeps the agent-facing surface unchanged. // // Trust model: deployments MUST NOT expose AdminService on the public // agent-facing listener. Reachability is restricted at the network layer // (an internal listener plus a source allowlist); there is no per-operator // identity inside the service in v1. Because the admin plane carries no // RFC 9421 request signing, there is no verified signer to deduplicate -// against — and both RPCs are full-replace overwrites, so they are naturally -// idempotent and carry no idempotency_key. +// against — and no RPC here needs one: the setters are full-replace +// overwrites and the evidence read is side-effect-free, so every RPC is +// naturally idempotent and carries no idempotency_key. // -// Message shape: each RPC takes a thin {ver, } envelope wrapping a +// The evidence read is keyed by the (tenant_id, transaction_id) PAIR. The +// tenant selector exists because transaction ids leave the deployment: +// every counterparty agent legitimately holds the ids of its own +// transactions, so an id alone must not act as a bearer capability for the +// forensic row. Naming the tenant narrows what a leaked id is worth; it is +// NOT an access control, and this plane has none — it carries no request +// signing and no per-operator identity, so there is no caller to attach a +// per-tenant rule to. A tenant +// mismatch is NOT_FOUND, byte-identical to an unknown id, so existence +// under another tenant is not revealed. The id format itself is +// implementation-defined (ramp.v1 places no entropy requirement on +// transaction ids); what bounds this read is the pair selector plus the +// network-layer reachability restriction above. +// +// Those two controls are not interchangeable, and the weaker one must not be +// mistaken for the stronger. The pair selector stops a transaction id ALONE +// from reading a row. It does NOT make enumeration infeasible: tenant ids are +// human brand slugs, and a tenant's slug is visible to every agent holding one +// of its offers, so a caller who reaches this plane can pair a known tenant +// with guessed ids. Enumeration is bounded by reachability — this service MUST +// NOT be exposed on the public agent-facing listener — which is why that +// restriction is the load-bearing control on this plane rather than a +// deployment convenience. +// +// Message shape: each setter takes a thin {ver, } envelope wrapping a // required payload message — TenantFeeRate or ReportingPolicy. The payload // type is shared by the request and its response, so every field rule is // stated ONCE; the read-back response cannot drift from the write. Responses // echo the payload as persisted, giving operator tooling a read-back -// confirmation of the applied values. +// confirmation of the applied values. The evidence read does not share this +// shape — its request carries only the (tenant_id, transaction_id) selector, +// and its response wraps read-only payloads that exist on no write path +// (TransactionEvidence, TransactionState, ReportingObligationState). // // Validation: every constraint here is a FIELD-level protovalidate rule so it // flows into the generated Pydantic/Zod types. Cross-field (message-level CEL) @@ -74,6 +103,9 @@ const ( // AdminServiceSetReportingPolicyProcedure is the fully-qualified name of the AdminService's // SetReportingPolicy RPC. AdminServiceSetReportingPolicyProcedure = "/ramp.admin.v1.AdminService/SetReportingPolicy" + // AdminServiceGetTransactionEvidenceProcedure is the fully-qualified name of the AdminService's + // GetTransactionEvidence RPC. + AdminServiceGetTransactionEvidenceProcedure = "/ramp.admin.v1.AdminService/GetTransactionEvidence" ) // AdminServiceClient is a client for the ramp.admin.v1.AdminService service. @@ -85,6 +117,20 @@ type AdminServiceClient interface { // tolerance, reporting window). Full replace: omitted optional fields clear // their value so the receiving Exchange's defaults apply. SetReportingPolicy(context.Context, *connect.Request[v1.SetReportingPolicyRequest]) (*connect.Response[v1.SetReportingPolicyResponse], error) + // Returns the append-once evidence row for one executed transaction — the + // full signed offer, both Ed25519 proofs and the verbatim bytes each was + // computed over — plus the transaction-log and reporting-obligation state + // needed to render it. Read-only: it exposes what the execute path already + // persisted and writes nothing. Selection is by (tenant_id, transaction_id) + // pair: an unknown transaction_id AND a transaction that exists under a + // different tenant are both NOT_FOUND, indistinguishably — a transaction id + // alone must not act as a bearer capability for another tenant's forensic + // row. What the pair selector buys is exactly that and no more: it narrows + // what a leaked id is worth. It is not an access control. This plane carries + // no request signing and no per-operator identity, so there is no caller to + // attach a per-tenant rule to; the network allowlist is the only gate in + // front of this RPC. + GetTransactionEvidence(context.Context, *connect.Request[v1.GetTransactionEvidenceRequest]) (*connect.Response[v1.GetTransactionEvidenceResponse], error) } // NewAdminServiceClient constructs a client for the ramp.admin.v1.AdminService service. By default, @@ -110,13 +156,20 @@ func NewAdminServiceClient(httpClient connect.HTTPClient, baseURL string, opts . connect.WithSchema(adminServiceMethods.ByName("SetReportingPolicy")), connect.WithClientOptions(opts...), ), + getTransactionEvidence: connect.NewClient[v1.GetTransactionEvidenceRequest, v1.GetTransactionEvidenceResponse]( + httpClient, + baseURL+AdminServiceGetTransactionEvidenceProcedure, + connect.WithSchema(adminServiceMethods.ByName("GetTransactionEvidence")), + connect.WithClientOptions(opts...), + ), } } // adminServiceClient implements AdminServiceClient. type adminServiceClient struct { - setTenantFeeRate *connect.Client[v1.SetTenantFeeRateRequest, v1.SetTenantFeeRateResponse] - setReportingPolicy *connect.Client[v1.SetReportingPolicyRequest, v1.SetReportingPolicyResponse] + setTenantFeeRate *connect.Client[v1.SetTenantFeeRateRequest, v1.SetTenantFeeRateResponse] + setReportingPolicy *connect.Client[v1.SetReportingPolicyRequest, v1.SetReportingPolicyResponse] + getTransactionEvidence *connect.Client[v1.GetTransactionEvidenceRequest, v1.GetTransactionEvidenceResponse] } // SetTenantFeeRate calls ramp.admin.v1.AdminService.SetTenantFeeRate. @@ -129,6 +182,11 @@ func (c *adminServiceClient) SetReportingPolicy(ctx context.Context, req *connec return c.setReportingPolicy.CallUnary(ctx, req) } +// GetTransactionEvidence calls ramp.admin.v1.AdminService.GetTransactionEvidence. +func (c *adminServiceClient) GetTransactionEvidence(ctx context.Context, req *connect.Request[v1.GetTransactionEvidenceRequest]) (*connect.Response[v1.GetTransactionEvidenceResponse], error) { + return c.getTransactionEvidence.CallUnary(ctx, req) +} + // AdminServiceHandler is an implementation of the ramp.admin.v1.AdminService service. type AdminServiceHandler interface { // Sets the tenant's fee rate (basis points) and optional operator note. @@ -138,6 +196,20 @@ type AdminServiceHandler interface { // tolerance, reporting window). Full replace: omitted optional fields clear // their value so the receiving Exchange's defaults apply. SetReportingPolicy(context.Context, *connect.Request[v1.SetReportingPolicyRequest]) (*connect.Response[v1.SetReportingPolicyResponse], error) + // Returns the append-once evidence row for one executed transaction — the + // full signed offer, both Ed25519 proofs and the verbatim bytes each was + // computed over — plus the transaction-log and reporting-obligation state + // needed to render it. Read-only: it exposes what the execute path already + // persisted and writes nothing. Selection is by (tenant_id, transaction_id) + // pair: an unknown transaction_id AND a transaction that exists under a + // different tenant are both NOT_FOUND, indistinguishably — a transaction id + // alone must not act as a bearer capability for another tenant's forensic + // row. What the pair selector buys is exactly that and no more: it narrows + // what a leaked id is worth. It is not an access control. This plane carries + // no request signing and no per-operator identity, so there is no caller to + // attach a per-tenant rule to; the network allowlist is the only gate in + // front of this RPC. + GetTransactionEvidence(context.Context, *connect.Request[v1.GetTransactionEvidenceRequest]) (*connect.Response[v1.GetTransactionEvidenceResponse], error) } // NewAdminServiceHandler builds an HTTP handler from the service implementation. It returns the @@ -159,12 +231,20 @@ func NewAdminServiceHandler(svc AdminServiceHandler, opts ...connect.HandlerOpti connect.WithSchema(adminServiceMethods.ByName("SetReportingPolicy")), connect.WithHandlerOptions(opts...), ) + adminServiceGetTransactionEvidenceHandler := connect.NewUnaryHandler( + AdminServiceGetTransactionEvidenceProcedure, + svc.GetTransactionEvidence, + connect.WithSchema(adminServiceMethods.ByName("GetTransactionEvidence")), + connect.WithHandlerOptions(opts...), + ) return "/ramp.admin.v1.AdminService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case AdminServiceSetTenantFeeRateProcedure: adminServiceSetTenantFeeRateHandler.ServeHTTP(w, r) case AdminServiceSetReportingPolicyProcedure: adminServiceSetReportingPolicyHandler.ServeHTTP(w, r) + case AdminServiceGetTransactionEvidenceProcedure: + adminServiceGetTransactionEvidenceHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -181,3 +261,7 @@ func (UnimplementedAdminServiceHandler) SetTenantFeeRate(context.Context, *conne func (UnimplementedAdminServiceHandler) SetReportingPolicy(context.Context, *connect.Request[v1.SetReportingPolicyRequest]) (*connect.Response[v1.SetReportingPolicyResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ramp.admin.v1.AdminService.SetReportingPolicy is not implemented")) } + +func (UnimplementedAdminServiceHandler) GetTransactionEvidence(context.Context, *connect.Request[v1.GetTransactionEvidenceRequest]) (*connect.Response[v1.GetTransactionEvidenceResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ramp.admin.v1.AdminService.GetTransactionEvidence is not implemented")) +} diff --git a/gen/go/ramp/v1/ramp.pb.go b/gen/go/ramp/v1/ramp.pb.go index 5e3ce975..ab67c98f 100644 --- a/gen/go/ramp/v1/ramp.pb.go +++ b/gen/go/ramp/v1/ramp.pb.go @@ -1852,12 +1852,15 @@ func (RetrievalAuthFailureReason) EnumDescriptor() ([]byte, []int) { } // UsageReportRejectionReason — why a ReportUsage filing was rejected. Replaces -// the free-text UsageReportResponse.rejection_reason string. +// the free-text UsageReportResponse.rejection_reason string. There is no +// "not authorized" value on purpose; see the who-may-file rule on +// UsageReport.idempotency_key for why an unauthorized filing reports as +// TRANSACTION_NOT_FOUND instead. type UsageReportRejectionReason int32 const ( UsageReportRejectionReason_USAGE_REPORT_REJECTION_REASON_UNSPECIFIED UsageReportRejectionReason = 0 // unset — rejected at ingest - UsageReportRejectionReason_USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND UsageReportRejectionReason = 1 // transaction_id is unknown + UsageReportRejectionReason_USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND UsageReportRejectionReason = 1 // transaction_id is unknown, or the filer is not bound to it UsageReportRejectionReason_USAGE_REPORT_REJECTION_REASON_DUPLICATE UsageReportRejectionReason = 2 // a report was already filed for this transaction UsageReportRejectionReason_USAGE_REPORT_REJECTION_REASON_WINDOW_EXPIRED UsageReportRejectionReason = 3 // filed outside the reporting window UsageReportRejectionReason_USAGE_REPORT_REJECTION_REASON_MISSING_REQUIRED_FIELDS UsageReportRejectionReason = 4 // ReportingObligation.required_fields not satisfied @@ -2559,9 +2562,20 @@ type Offer struct { // relaying Broker has nothing to group or dial on, and the swap-protection // above is vacuous when the signed bytes carry no recipient at all. Exchange string `protobuf:"bytes,8,opt,name=exchange,proto3" json:"exchange,omitempty"` - // REQUIRED. JWS (alg=EdDSA) over the canonical serialization of the ENTIRE - // Offer — every field, including `pricing`, `terms` (the full licensing - // payload), `expires_at`, and `exchange`. Only `signature` and + // REQUIRED. A DETACHED Ed25519 signature over the canonical serialization of + // the ENTIRE Offer, carried as the raw 64 signature bytes in lowercase or + // uppercase hex — 128 hex characters, NOT a JWS. There is no JOSE header and + // no compact serialization here: the signed bytes are defined by the + // canonical-signing recipe below, and this field carries only the signature + // itself. `signature_algorithm` names the algorithm separately. + // Same convention as `AgentAcceptance.signature`, and it is what the admin + // plane's offline verification recipe replays. The hex shape is STATED here + // and ENFORCED there: this field carries no schema rule, while + // ramp.admin.v1.TransactionEvidence.offer_sig — the same value, copied into + // the evidence row — is pattern-bound to 128 hex characters. + // + // The signature covers every field, including `pricing`, `terms` (the full + // licensing payload), `expires_at`, and `exchange`. Only `signature` and // `signature_algorithm` are excluded from the signed bytes. `expires_at` is // signed so the offer's validity window is integrity-protected: a relaying // Broker cannot extend (or shorten) the TTL of a signed offer to replay it @@ -2625,8 +2639,19 @@ type Offer struct { // licensing term without invalidating it. // Agent SHOULD verify the signature (RFC 2119) against the Exchange's public // key, and MUST reject an offer whose `expires_at` is in the past. + // + // The rule is the hex shape this comment already describes: 128 characters, + // either case, which is one Ed25519 signature (64 bytes) hex-encoded. Either + // case is accepted because hex decoding accepts both and a dispute should + // read the same characters a request log holds; every SDK in this repo emits + // lowercase. The pattern also makes the field mandatory in practice — the + // empty string does not match it — which restates what this message already + // requires: an unsigned Offer is not an Offer, since the signature is what + // makes its terms, pricing and expiry non-repudiable. Signature string `protobuf:"bytes,9,opt,name=signature,proto3" json:"signature,omitempty"` - // JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization. + // Signature algorithm. Always 'EdDSA' — the JOSE algorithm identifier for + // Ed25519 (RFC 8032). Only the identifier is borrowed from JOSE: `signature` + // is a detached hex signature, not a JWS. 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. @@ -2928,7 +2953,6 @@ type ResourceIdentity struct { HashMethod *string `protobuf:"bytes,6,opt,name=hash_method,json=hashMethod,proto3,oneof" json:"hash_method,omitempty"` // Signals whether this resource's content is stable, changes over time, // or does not exist at offer time (live streaming). - // // Drives hash verification behavior: // // STATIC: content_hash is stable. Agent SHOULD verify delivered content matches. @@ -2959,7 +2983,6 @@ type ResourceIdentity struct { // Populated by the Exchange or a verification vendor after validating // the C2PA manifest. Enables agents to filter for provenance-verified // content without parsing JUMBF/COSE themselves. - // // The full C2PA validation details (signer identity, trust list, // action history, training/mining status) are carried in a // ResourceAttestation with c2pa.* claims — see ramp-c2pa-v1 profile. @@ -3186,6 +3209,23 @@ type ResourceAttestation struct { // ECMAScript number serialization, strict string escaping, no whitespace. // Each attestation is self-contained — new claim fields do not invalidate // old attestations because the signature covers the specific claims instance. + // + // HEX, like every other detached signature in this contract: 128 characters, + // either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as + // ramp.v1.Offer.signature — the same shape, for the same reason. + // + // The encoding was previously unstated, which was the real defect — the + // comment described the signed BYTES precisely and never said how the + // signature itself is written, so a vendor had to guess. Two vendors guessing + // differently is exactly the failure the hex settlement exists to prevent, and + // an attestation is the worst place for it: the verifying party is a third + // party who never negotiated with the reader. + // + // The rule also makes the field mandatory in practice, since the empty string + // does not match. That restates what this message already means. An + // attestation is a signed third-party claim; without the signature it is an + // unverifiable assertion by an unproven author, which is Level 0 — no + // attestation present — rather than an attestation with a field missing. Signature string `protobuf:"bytes,6,opt,name=signature,proto3" json:"signature,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -4428,7 +4468,49 @@ func (x *Delegation) GetExtCritical() []string { // many brokers relay the request, and binds the agent to THIS specific offer + // requester + transaction. It travels in the execute body alongside the // reflected Offer; the Exchange verifies it and binds the delivery URL to the -// agent's key (RFC 7638 thumbprint of the acceptance key). +// agent's key. +// +// AGENT IDENTITY (normative; every other site cites this one). The agent +// identity for a transaction is the key that proved AGENT authorship of the +// request: +// +// - when an acceptance is present, the ACCEPTANCE key — the key whose +// signature over AgentAcceptancePayload the Exchange verified; +// - otherwise the verified RFC 9421 request signer, which is the agent only +// because an acceptance-less request cannot have been relayed. +// +// `agent_identity_hash` (TransactionResponse, and the value embedded in a bound +// retrieval URL) is the RFC 7638 JWK Thumbprint (SHA-256) of that key. +// +// The transport signer alone is not a usable identity here. A Broker may author +// a re-packaged transaction AS SENDER (see `exchange` in the file header), and +// on that leg the RFC 9421 signer is the broker while the in-body acceptance is +// the only agent-authored signature in the request. Anchoring on the acceptance +// makes the identity the same value whether the request arrived direct or +// through a broker. Where both exist and agree — the ordinary direct hop — the +// two readings coincide, which is why older text called this the +// "request-signing key". +// +// One acceptance key per request: `TransactionResponse.agent_identity_hash` is +// a single per-request value, so a batch whose items were accepted by DIFFERENT +// keys has no one identity to bind its delivery URLs to. Every acceptance in +// one TransactionRequest MUST be signed by the same key. +// +// ONE-KEY RULE. An agent MUST accept an offer and fetch the delivered resource +// with the SAME key. The Exchange derives `agent_identity_hash` from the +// acceptance key, and an enforcing delivery endpoint requires the fetcher to +// present exactly that key, so accepting with one key and fetching with another +// yields a transaction that succeeds and a retrieval that is refused. Two +// qualifiers bound the rule: +// +// - It binds only where proof-of-possession is enforced. A bearer-only +// signed-URL CDN that cannot run code keeps the bearer posture — see +// "Retrieval-URL identity binding" in the file header, which states that +// enforcement is not mandatory. The rule is what an agent must do to be +// servable by an enforcing endpoint, not a universal precondition for +// retrieval. +// - A custodial registry that holds the agent's single key and performs the +// bound fetch itself satisfies the rule with nothing extra to do. // // `signature` is a hex-encoded detached Ed25519 signature (NOT a JWS) over the // CANONICAL SIGNING form of `AgentAcceptancePayload` — RFC 8785 JCS over canonical @@ -4441,7 +4523,16 @@ func (x *Delegation) GetExtCritical() []string { type AgentAcceptance struct { state protoimpl.MessageState `protogen:"open.v1"` // Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload - // bytes (see the canonical-signing definition on Offer.signature). + // bytes (see the canonical-signing definition on Offer.signature). Same rule + // as ramp.v1.Offer.signature — the same 128-character hex shape, either + // case, because it is the same kind of value produced by the same + // convention. + // + // The pattern replaced a bare min_len: 1, which it subsumes: a 128-character + // string cannot be empty. Nothing conformant is refused that was accepted + // before — a signature outside this shape could never hex-decode into 64 + // bytes and so could never verify, so it failed at the verify step instead, + // later and with a worse error. Signature string `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"` // Signature algorithm; "EdDSA" for Ed25519. SignatureAlgorithm string `protobuf:"bytes,2,opt,name=signature_algorithm,json=signatureAlgorithm,proto3" json:"signature_algorithm,omitempty"` @@ -4600,9 +4691,18 @@ type TransactionRequest struct { // Idempotency key (REQUIRED). The server MUST dedupe on this: a replay returns // the original result rather than re-executing. The transaction's durable // identity is the Exchange-assigned transaction_id in the response. - // Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per - // (authenticated caller, key), never globally, so a key chosen by one caller - // cannot collide with another's cached result. + // + // DEDUPE SCOPE — the invariant, stated here once and cited by every other RPC + // that carries an idempotency_key: a key chosen by one caller MUST NEVER + // collide with another caller's cached result. The server dedupes within a + // namespace, never globally. What that namespace IS differs per RPC, because + // the RPCs do not authenticate the same way; each states its own, and each + // namespace has to make the invariant true on its own terms. + // + // For this RPC the namespace is the ACCEPTANCE IDENTITY — the agent key + // defined under "Agent identity" on AgentAcceptance — never the transport + // sender, which may be a Broker relaying many agents behind one key. The + // server dedupes per (acceptance identity, key). IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` // Requester identity — forwarded for authorization and audit. Requester *Requester `protobuf:"bytes,4,opt,name=requester,proto3" json:"requester,omitempty"` @@ -4771,8 +4871,12 @@ type TransactionResponse struct { // constant; advisory on receive. See "Protocol version" in the file header. Ver string `protobuf:"bytes,1,opt,name=ver,proto3" json:"ver,omitempty"` // Identity that a delivered retrieval_endpoint is bound to: the RFC 7638 JWK - // Thumbprint of the agent's Ed25519 request-signing key (see "Retrieval-URL - // identity binding" above). Shared across the request; set once. + // Thumbprint of the agent's Ed25519 key, as "Agent identity" on + // AgentAcceptance defines it — the acceptance key, not the transport signer, + // which may be a Broker. See "Retrieval-URL identity binding" in the file + // header for how a delivery endpoint checks the binding. Shared across the + // request; set once, which is why every acceptance in one request must be + // signed by the same key. AgentIdentityHash string `protobuf:"bytes,10,opt,name=agent_identity_hash,json=agentIdentityHash,proto3" json:"agent_identity_hash,omitempty"` // Per-offer results (one entry per committed item, in original order). Items []*TransactionResultItem `protobuf:"bytes,13,rep,name=items,proto3" json:"items,omitempty"` @@ -4877,7 +4981,35 @@ type TransactionResultItem struct { state protoimpl.MessageState `protogen:"open.v1"` // The offer_id this result is for. OfferId string `protobuf:"bytes,1,opt,name=offer_id,json=offerId,proto3" json:"offer_id,omitempty"` - // Exchange-assigned transaction identifier. + // Exchange-assigned transaction identifier. Opaque to agents; the format + // is implementation-defined (the documented storage model mints a + // time-ordered ULID as the record's primary key). + // + // ENTROPY. RAMP places no entropy requirement on this value. An implementer + // choosing a sequential id should know precisely what that does and does not + // cost, because the protection here is narrower than "unguessable ids are + // unnecessary". + // + // WHAT IS GUARANTEED. The admin plane's evidence read + // (ramp.admin.v1.GetTransactionEvidence) selects by the + // (tenant_id, transaction_id) PAIR, so a transaction id ALONE is never a + // bearer capability for the forensic row. Counterparty agents legitimately + // hold the ids of their own transactions, and that pairing is what stops one + // of those ids from reading the row on its own. That is the whole guarantee, + // and a conformance guard fails if the selector stops being a pair. + // + // WHAT IS NOT GUARANTEED: resistance to ENUMERATION. The tenant half of the + // pair is not a secret. Deployments use human brand slugs, and the slug is + // handed to every agent that holds an offer from that tenant — it prefixes + // the offer id inside the signed offer. So a caller who can reach the admin + // plane at all, and who has done business with a tenant, already knows one + // valid tenant value and can walk sequential transaction ids against it. + // What bounds that is the network-layer reachability restriction on the + // admin plane (see the ramp.admin.v1 file header): that plane must not be + // exposed on the public agent-facing listener, and it is the outer control + // an operator must not relax. An Exchange that wants enumeration resistance + // in depth should mint unguessable ids; RAMP does not require it, and no + // agent-plane behavior depends on this format either way. TransactionId string `protobuf:"bytes,2,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` // Billing record identifier minted by the Exchange's billing adapter for // this transaction (not the account handle — see RegisterResponse.billing_ref). @@ -5862,11 +5994,44 @@ type UsageReport struct { // Idempotency key (REQUIRED). The server MUST dedupe on this so a replayed // report does not double-count usage. The report's durable identity is the // Exchange-assigned report_id in UsageReportResponse. - // Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per - // (authenticated caller, key), never globally, so a key chosen by one caller - // cannot collide with another's cached result. + // + // DEDUPE SCOPE. The invariant is the one stated on + // TransactionRequest.idempotency_key. This message carries no acceptance + // payload, so there is no in-body agent signature to anchor on; the namespace + // is instead the TRANSACTION the report addresses, and the server dedupes per + // (transaction_id, key). That satisfies the invariant without depending on + // the transport signer: the transaction was bound to exactly one agent by its + // acceptance at execute time, so two agents relayed by the same Broker report + // against different transactions and never share a namespace. + // Leaving the signer out is also what makes the relay work. "Filed by the + // agent or Broker" above means the Broker FORWARDS the agent's report, not + // that it authors one of its own: the body is unchanged and carries this same + // key, so a direct submission and a relayed copy are one report on two paths + // and MUST collapse. Adding the verified signer to the namespace would split + // them and count the usage twice. + // + // WHO MAY FILE. Dropping the signer from the namespace removes a protection + // that has to be restored explicitly, so the rule is stated rather than + // implied: only the agent the transaction was bound to at execute time may + // file against it, or a Broker relaying that agent's report unchanged. The + // argument above is about honest filers — it shows two legitimate parties + // never collide by accident, which is a different claim from who is allowed + // to write. A filing from any other party MUST be rejected, never deduped: + // the slot is now shared, so an accepted filing from an unbound party would + // occupy the one the bound agent's report needs, and the real usage would + // collapse into it and go uncounted. + // + // An unauthorized filing is reported as USAGE_REPORT_REJECTION_REASON_ + // TRANSACTION_NOT_FOUND, deliberately. There is no distinct "not authorized" + // reason and there should not be one: it would confirm to a party not bound + // to the transaction that the transaction exists, which turns the rejection + // into an oracle for probing transaction ids. IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` - // Transaction ID from the delivery. + // Transaction ID from the delivery. MUST be non-empty. It is also the dedupe + // namespace for `idempotency_key` above, so a report that names no + // transaction has no namespace to dedupe within — the rule below is what + // makes that namespace exist, not a shape preference. No upper bound: the + // Exchange assigns this id and nothing upstream constrains its length. TransactionId string `protobuf:"bytes,3,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` // Billing record identifier from the delivery (TransactionResultItem.billing_id). BillingId string `protobuf:"bytes,4,opt,name=billing_id,json=billingId,proto3" json:"billing_id,omitempty"` @@ -7466,7 +7631,6 @@ type DiscoveryResponse struct { // per-axis detail: DiscoveryResponse has no restriction_filters companion // (unlike OfferGroup). A consumer needing the filtered axes calls // DiscoverResources. - // // Existence-oracle note: an authorization-flavored reason (SCOPE_INSUFFICIENT, // NOT_AUTHORIZED, NOT_IN_CATALOG, CONTENT_BLOCKED) confirms a resource exists // and why access was refused. Resolve surfaces the same oracle at the broker @@ -7559,11 +7723,20 @@ type DisputeRequest struct { // Idempotency key (REQUIRED). The server MUST dedupe on this so a replayed // filing does not open a duplicate case. The dispute's durable identity is the // Exchange-assigned dispute_id in DisputeResponse. - // Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per - // (authenticated caller, key), never globally, so a key chosen by one caller - // cannot collide with another's cached result. + // + // DEDUPE SCOPE. The invariant is the one stated on + // TransactionRequest.idempotency_key, and the namespace is the same as + // UsageReport's and for the same reason: this message carries no acceptance + // payload, so the namespace is the TRANSACTION being disputed and the server + // dedupes per (transaction_id, key). The "who may file" rule stated there + // applies here unchanged, and for the same reason — a shared slot needs an + // explicit rule about who may write into it. IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` - // Transaction being disputed. + // Transaction being disputed. MUST be non-empty. It is also the dedupe + // namespace for `idempotency_key` above, so a filing that names no + // transaction has no namespace to dedupe within — the rule below is what + // makes that namespace exist, not a shape preference. Same rule as + // ramp.v1.UsageReport.transaction_id, for the same reason. TransactionId string `protobuf:"bytes,3,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"` // Billing record identifier from the disputed transaction // (TransactionResultItem.billing_id). @@ -9324,7 +9497,7 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\x04unit\x18\x06 \x01(\tH\x01R\x04unit\x88\x01\x01B\f\n" + "\n" + "_resets_atB\a\n" + - "\x05_unit\"\xb5\t\n" + + "\x05_unit\"\xd0\t\n" + "\x05Offer\x12\x19\n" + "\boffer_id\x18\x01 \x01(\tR\aofferId\x12\x19\n" + "\x05title\x18\x02 \x01(\tH\x00R\x05title\x88\x01\x01\x12*\n" + @@ -9334,8 +9507,8 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\n" + "expires_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampH\x02R\texpiresAt\x88\x01\x01\x12:\n" + "\bidentity\x18\a \x01(\v2\x19.ramp.v1.ResourceIdentityH\x03R\bidentity\x88\x01\x01\x12\xd7\x01\n" + - "\bexchange\x18\b \x01(\tB\xba\x01\xbaH\xb6\x01r\xb3\x01\x18\x84\x022\xad\x01^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$R\bexchange\x12\x1c\n" + - "\tsignature\x18\t \x01(\tR\tsignature\x12/\n" + + "\bexchange\x18\b \x01(\tB\xba\x01\xbaH\xb6\x01r\xb3\x01\x18\x84\x022\xad\x01^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$R\bexchange\x127\n" + + "\tsignature\x18\t \x01(\tB\x19\xbaH\x16r\x142\x12^[0-9A-Fa-f]{128}$R\tsignature\x12/\n" + "\x13signature_algorithm\x18\n" + " \x01(\tR\x12signatureAlgorithm\x12,\n" + "\x0fsubscription_id\x18\v \x01(\tH\x04R\x0esubscriptionId\x88\x01\x01\x12%\n" + @@ -9382,15 +9555,15 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\x0e_c2pa_manifestB\x0e\n" + "\f_c2pa_statusB\x0f\n" + "\r_soft_bindingB\x16\n" + - "\x14_soft_binding_method\"\xe5\x01\n" + + "\x14_soft_binding_method\"\x80\x02\n" + "\x13ResourceAttestation\x12\x1a\n" + "\bverifier\x18\x01 \x01(\tR\bverifier\x12\x14\n" + "\x05keyid\x18\x02 \x01(\tR\x05keyid\x12;\n" + "\vattested_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" + "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\"\x92\x03\n" + + "\x06claims\x18\x05 \x01(\v2\x17.google.protobuf.StructR\x06claims\x127\n" + + "\tsignature\x18\x06 \x01(\tB\x19\xbaH\x16r\x142\x12^[0-9A-Fa-f]{128}$R\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" + @@ -9507,9 +9680,9 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\r_max_accessesB\x0f\n" + "\r_quota_periodB\x11\n" + "\x0f_revocation_uriB\t\n" + - "\a_issuer\"i\n" + - "\x0fAgentAcceptance\x12%\n" + - "\tsignature\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\tsignature\x12/\n" + + "\a_issuer\"{\n" + + "\x0fAgentAcceptance\x127\n" + + "\tsignature\x18\x01 \x01(\tB\x19\xbaH\x16r\x142\x12^[0-9A-Fa-f]{128}$R\tsignature\x12/\n" + "\x13signature_algorithm\x18\x02 \x01(\tR\x12signatureAlgorithm\"\xac\x01\n" + "\x16AgentAcceptancePayload\x12\x1b\n" + "\toffer_sig\x18\x01 \x01(\tR\bofferSig\x12!\n" + @@ -9638,12 +9811,12 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\x03ext\x18\x0f \x01(\v2\x17.google.protobuf.StructR\x03ext\x12!\n" + "\fext_critical\x18Z \x03(\tR\vextCriticalB\t\n" + "\a_windowB\v\n" + - "\t_endpoint\"\xcf\x04\n" + + "\t_endpoint\"\xd8\x04\n" + "\vUsageReport\x12\x10\n" + "\x03ver\x18\x01 \x01(\tR\x03ver\x123\n" + "\x0fidempotency_key\x18\x02 \x01(\tB\n" + - "\xbaH\ar\x05\x10\x01\x18\xff\x01R\x0eidempotencyKey\x12%\n" + - "\x0etransaction_id\x18\x03 \x01(\tR\rtransactionId\x12\x1d\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\x0eidempotencyKey\x12.\n" + + "\x0etransaction_id\x18\x03 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\rtransactionId\x12\x1d\n" + "\n" + "billing_id\x18\x04 \x01(\tR\tbillingId\x12$\n" + "\x05usage\x18\x05 \x01(\v2\x0e.ramp.v1.UsageR\x05usage\x128\n" + @@ -9807,12 +9980,12 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\x0eabsence_reason\x18\x10 \x01(\x0e2\x1b.ramp.v1.OfferAbsenceReasonH\x00R\rabsenceReason\x88\x01\x01\x12)\n" + "\x03ext\x18\x0f \x01(\v2\x17.google.protobuf.StructR\x03ext\x12!\n" + "\fext_critical\x18Z \x03(\tR\vextCriticalB\x11\n" + - "\x0f_absence_reason\"\xf6\x05\n" + + "\x0f_absence_reason\"\xff\x05\n" + "\x0eDisputeRequest\x12\x10\n" + "\x03ver\x18\x01 \x01(\tR\x03ver\x123\n" + "\x0fidempotency_key\x18\x02 \x01(\tB\n" + - "\xbaH\ar\x05\x10\x01\x18\xff\x01R\x0eidempotencyKey\x12%\n" + - "\x0etransaction_id\x18\x03 \x01(\tR\rtransactionId\x12\x1d\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\x0eidempotencyKey\x12.\n" + + "\x0etransaction_id\x18\x03 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\rtransactionId\x12\x1d\n" + "\n" + "billing_id\x18\x04 \x01(\tR\tbillingId\x128\n" + "\x06reason\x18\x05 \x01(\x0e2\x16.ramp.v1.DisputeReasonB\b\xbaH\x05\x82\x01\x02 \x00R\x06reason\x12%\n" + diff --git a/gen/go/ramp/v1/rampv1connect/ramp.connect.go b/gen/go/ramp/v1/rampv1connect/ramp.connect.go index f312e6b5..5d4176c8 100644 --- a/gen/go/ramp/v1/rampv1connect/ramp.connect.go +++ b/gen/go/ramp/v1/rampv1connect/ramp.connect.go @@ -538,7 +538,7 @@ type BrokerServiceClient interface { // licensable (not in catalog, no offers, entitlement/budget absence, upstream // temporarily unavailable) returns OK with DiscoveryResponse.absence_reason // set and empty offer_groups — "no result" is a successful answer, mirroring - // DiscoverResources (ADR-019 §2). Here "authz" means resource entitlement + // DiscoverResources. Here "authz" means resource entitlement // (→ OK + absence); transport authentication failures are a different axis and, // like malformed requests and internal faults, are non-OK transport errors // carrying an ErrorDetail. @@ -589,7 +589,7 @@ type BrokerServiceHandler interface { // licensable (not in catalog, no offers, entitlement/budget absence, upstream // temporarily unavailable) returns OK with DiscoveryResponse.absence_reason // set and empty offer_groups — "no result" is a successful answer, mirroring - // DiscoverResources (ADR-019 §2). Here "authz" means resource entitlement + // DiscoverResources. Here "authz" means resource entitlement // (→ OK + absence); transport authentication failures are a different axis and, // like malformed requests and internal faults, are non-OK transport errors // carrying an ErrorDetail. diff --git a/gen/python/tests/test_bytes_wire_forms.py b/gen/python/tests/test_bytes_wire_forms.py new file mode 100644 index 00000000..92e1c49a --- /dev/null +++ b/gen/python/tests/test_bytes_wire_forms.py @@ -0,0 +1,62 @@ +"""Direct behavioral regression for the base64 wire forms of a bytes-rule field. + +protoschema renders a bytes field too loose in both rule kinds, so +merge_schema.tighten_bytes_len rewrites them from the bytesgen manifest: + + - bytes.len=32 (the Ed25519 keys, signed_url_hash): protoschema's 43..44 + CHARACTER window also admits a 33-byte value (44 unpadded chars) and a + 31-byte padded value (44 chars with '=='). The rewrite pins the payload to + exactly 43 chars of ONE alphabet plus optional exact padding. + - bytes.min_len=1 (the canonical-bytes fields): protoschema's pattern counts + padding as content, so "==" (zero payload bytes, which Go protojson refuses + to decode) passed. The rewrite requires the encoded payload chars of at + least 1 byte BEFORE the padding tail. + +The rows live in conformance/testdata/bytes_wire_forms.json, shared with the Zod +harness (gen/ts/tests/bytes_wire_forms.test.ts) and pinned against Go itself by +conformance/bytes_wire_forms_test.go — Go protojson + protovalidate is the oracle +the generated patterns mirror. See that file's $comment for why the generated +corpus cannot cover this axis (protojson re-encodes every bytes value into one +canonical form, so no unpadded, url-safe or malformed string ever appears in a +corpus case). + +Run: PYTHONPATH=gen/python python3 -m pytest gen/python/tests/test_bytes_wire_forms.py -q +""" +import json +import pathlib + +import pytest +from pydantic import ValidationError + +import wire.models as models + +VECTORS = json.loads( + (pathlib.Path(__file__).resolve().parents[3] + / "conformance" / "testdata" / "bytes_wire_forms.json").read_text() +) + +CASES = [ + (f["message"], f["field"], form["value"], form["accepted"]) + for f in VECTORS["fields"] + for form in VECTORS["form_sets"][f["form_set"]]["forms"] +] + + +def test_vectors_are_nonempty(): + # Guard against a renamed key or a moved file making the suite vacuous. + assert CASES, "no bytes wire-form vectors loaded" + + +@pytest.mark.parametrize( + "cls_name,field,value,accepted", + CASES, + ids=[f"{m}.{f}={v!r}" for m, f, v, _ in CASES], +) +def test_bytes_rules_decide_every_base64_wire_form(cls_name, field, value, accepted): + cls = getattr(models, cls_name) + instance = {**VECTORS["bases"][cls_name], field: value} + if accepted: + assert cls.model_validate(instance) is not None + else: + with pytest.raises(ValidationError): + cls.model_validate(instance) diff --git a/gen/python/wire/models.py b/gen/python/wire/models.py index bf4e1f94..6d54bcfd 100644 --- a/gen/python/wire/models.py +++ b/gen/python/wire/models.py @@ -18,9 +18,9 @@ class AccountRegistration(WireModel): class AgentAcceptance(WireModel): - signature: constr(min_length=1) = Field( + signature: constr(pattern=r'^[0-9A-Fa-f]{128}$') = Field( ..., - description='Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload\n bytes (see the canonical-signing definition on Offer.signature).', + description='Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload\n bytes (see the canonical-signing definition on Offer.signature). Same rule\n as ramp.v1.Offer.signature — the same 128-character hex shape, either\n case, because it is the same kind of value produced by the same\n convention.\n\nThe pattern replaced a bare min_len: 1, which it subsumes: a 128-character\n string cannot be empty. Nothing conformant is refused that was accepted\n before — a signature outside this shape could never hex-decode into 64\n bytes and so could never verify, so it failed at the verify step instead,\n later and with a worse error.', ) signature_algorithm: str | None = Field( '', description='Signature algorithm; "EdDSA" for Ed25519.' @@ -237,7 +237,7 @@ class DisputeRequest(WireModel): ) 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.", + 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\nDEDUPE SCOPE. The invariant is the one stated on\n TransactionRequest.idempotency_key, and the namespace is the same as\n UsageReport\'s and for the same reason: this message carries no acceptance\n payload, so the namespace is the TRANSACTION being disputed and the server\n dedupes per (transaction_id, key). The "who may file" rule stated there\n applies here unchanged, and for the same reason — a shared slot needs an\n explicit rule about who may write into it.', ) reason: DisputeReason = Field(..., description='Reason for the dispute.') received_content_hash: str | None = Field( @@ -251,7 +251,10 @@ class DisputeRequest(WireModel): '', 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.') + transaction_id: constr(min_length=1) = Field( + ..., + description='Transaction being disputed. MUST be non-empty. It is also the dedupe\n namespace for `idempotency_key` above, so a filing that names no\n transaction has no namespace to dedupe within — the rule below is what\n makes that namespace exist, not a shape preference. Same rule as\n ramp.v1.UsageReport.transaction_id, for the same reason.', + ) ver: str | None = Field( '', description='RAMP protocol version — "1.0". Stamped by the sender from a single\n constant; advisory on receive. See "Protocol version" in the file header.', @@ -425,6 +428,21 @@ class GetAccountStatusResponse(WireModel): ) +class GetTransactionEvidenceRequest(WireModel): + tenant_id: constr(min_length=1, max_length=255) = Field( + ..., + description='The tenant the transaction must belong to — the second half of the\n selector, matched against TransactionEvidence.tenant_id. Required:\n counterparty agents legitimately hold transaction ids, so the id alone\n must not be enough to read the row. Naming the tenant narrows what a\n leaked id is worth; it does not authenticate the caller, and nothing on\n this plane does. A mismatch is NOT_FOUND,\n byte-identical to an unknown transaction_id, so existence under another\n tenant is not revealed. Same rule as\n ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated).', + ) + transaction_id: constr(min_length=1, max_length=255) = Field( + ..., + description='The transaction whose evidence row to fetch. Same rule as\n ramp.admin.v1.TransactionEvidence.transaction_id (drift-gated) — the row\n identity this request selects by.', + ) + ver: str | None = Field( + '', + description='RAMP protocol version — "1.0". Stamped by the sender from a single\n constant; advisory on receive. See "Protocol version" in ramp.proto.', + ) + + class IngestionSource(Enum): INGESTION_SOURCE_RAMP_SITEMAP = 'INGESTION_SOURCE_RAMP_SITEMAP' INGESTION_SOURCE_RSL = 'INGESTION_SOURCE_RSL' @@ -481,7 +499,7 @@ class License(WireModel): ) 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.', + description='Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n "https://creativecommons.org/licenses/by/4.0/"\n "https://techcrunch.com/licensing/ai-terms-2026"\n\n"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( @@ -490,7 +508,7 @@ class License(WireModel): | 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.', + description='Cryptographic digest of the document at `uri`, in "method:hexdigest" form\n (e.g. "sha256:9f86d081..."). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.', ) @@ -503,6 +521,14 @@ class ObligationKind(Enum): OBLIGATION_KIND_OTHER = 'OBLIGATION_KIND_OTHER' +class ObligationState(Enum): + OBLIGATION_STATE_PENDING = 'OBLIGATION_STATE_PENDING' + OBLIGATION_STATE_FULFILLED = 'OBLIGATION_STATE_FULFILLED' + OBLIGATION_STATE_EXPIRED = 'OBLIGATION_STATE_EXPIRED' + OBLIGATION_STATE_WAIVED = 'OBLIGATION_STATE_WAIVED' + OBLIGATION_STATE_BLOCKED = 'OBLIGATION_STATE_BLOCKED' + + class ObligationTrigger(Enum): OBLIGATION_TRIGGER_ON_USE = 'OBLIGATION_TRIGGER_ON_USE' OBLIGATION_TRIGGER_ON_DISTRIBUTION = 'OBLIGATION_TRIGGER_ON_DISTRIBUTION' @@ -770,6 +796,28 @@ class ReportingObligation(WireModel): ) +class ReportingObligationState(WireModel): + consumed_quantity: conint(ge=-2147483648, le=2147483647) | None = Field( + None, + description='Reported consumed quantity, in the metering unit from the Offer\'s\n Pricing — the value the accepted usage report carried. Mirrors\n ramp.v1.Usage.consumed_quantity\'s wire type (int32, unconstrained)\n exactly: this view must be able to state whatever the report stated,\n and a decimal-string shape here could express values (e.g. "3.5") no\n report can produce. Absent until a usage report has been accepted.', + ) + created_at: AwareDatetime = Field( + ..., description="When the obligation was minted (the store's CreatedAt)." + ) + fulfilled_at: AwareDatetime | None = Field( + None, + description='When a usage report was ACCEPTED (the store\'s FulfilledAt) — the same\n event that moves state to OBLIGATION_STATE_FULFILLED. Not "when a report\n arrived": a report that arrived and was rejected leaves this absent, and\n the obligation still expires on window_end.', + ) + state: ObligationState = Field( + ..., + description='Lifecycle state. Always a real persisted state, never UNSPECIFIED.\n Server-output enum: {defined_only, not_in: [0]} — a reader must never\n see a number its schema cannot name. Same rule as\n ramp.v1.TransactionDenial.reason (drift-gated) — the discipline the\n ErrorDetail reason discriminators establish for server-output enums.', + ) + window_end: AwareDatetime = Field( + ..., + description="When the usage report is due (the store's WindowEnd). An absolute\n instant, not the ramp.v1.ReportingObligation.window Duration it was\n derived from: this record states what the store holds, and the store\n resolved the window against created_at when it minted the obligation.", + ) + + class ReportingPolicy(WireModel): quantity_tolerance: confloat(ge=0.0, le=1.0) | None = Field( None, @@ -783,7 +831,8 @@ class ReportingPolicy(WireModel): max_length=32, ) tenant_id: constr(min_length=1, max_length=255) = Field( - ..., description='The tenant whose reporting policy is being replaced.' + ..., + description='The tenant whose reporting policy is being replaced. Same rule as\n ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated).', ) window_seconds: conint(le=31536000, gt=0) | None = Field( None, @@ -817,7 +866,7 @@ class RequestConstraints(WireModel): ) 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"', + description='Maximum acceptable age of resource data. The Broker SHOULD\n exclude offers where (now - Offer.data_as_of) exceeds this duration.\n\nOnly 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, @@ -853,6 +902,17 @@ class RequestConstraints(WireModel): ) +class RequestCorrelation(WireModel): + minted: bool | None = Field( + False, + description='Provenance: true = the id is SERVER-DERIVED, false = propagated verbatim\n from a caller-supplied header. True covers both ways a server derives\n one — the header was absent, or it was present but nonconforming and was\n replaced — because the property this flag exists for is INFLUENCE, not\n origin story: false means a caller chose these characters, true means no\n caller did. The two are byte-indistinguishable in request_id alone, so a\n forensic read needs this flag to tell a server-derived correlation key\n from an attacker-influenceable one.', + ) + request_id: constr(pattern=r'^[!-~]+$', min_length=1, max_length=255) = Field( + ..., + description="The correlation id as persisted. GOVERNING INVARIANT, established on the\n WRITE path: a persisted request_id always conforms to the rules below —\n printable ASCII, 1..255 — so a present value has already passed the check\n on the way in, and these rules are not a read-side filter over a laxer\n stored value. HOW a server reaches that invariant is its own choice, and\n two mechanisms both conform: reject the nonconforming header and record a\n server-derived id in its place (minted = true), or record no correlation\n at all (the wrapping message stays absent). The first keeps a correlation\n key for a request whose header was bad, the second states that nothing\n trustworthy arrived; neither can put a nonconforming value in the store,\n which is the only property this contract needs. A server that accepts a\n narrower charset than the rules below still satisfies the invariant.\n Background, for a reader tracing where the value comes from: a propagated\n id is caller-influenceable, which is what `minted` below exists to record.\n Which component performs the check is deliberately not stated here. It is\n server behaviour, this file cannot gate it, and an earlier revision of this\n comment described a particular SDK's middleware and was made wrong by a\n change to that SDK three commits later.", + ) + + class RequesterType(Enum): REQUESTER_TYPE_AGENT = 'REQUESTER_TYPE_AGENT' REQUESTER_TYPE_HUMAN_TOOL = 'REQUESTER_TYPE_HUMAN_TOOL' @@ -881,9 +941,9 @@ class ResourceAttestation(WireModel): '', 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.', + signature: constr(pattern=r'^[0-9A-Fa-f]{128}$') = 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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing.', ) uri: str | None = Field( '', @@ -1039,6 +1099,112 @@ class TransactionDenial(WireModel): ) +class AgentAcceptanceSignatureAlgorithm(Enum): + EdDSA = 'EdDSA' + + +class OfferSigAlgorithm(Enum): + EdDSA = 'EdDSA' + + +class TransactionEvidence(WireModel): + agent_acceptance_canonical_bytes: constr( + pattern=r'^(?:[A-Za-z0-9+/]{4}(?:[A-Za-z0-9+/]{4})*|[A-Za-z0-9+/]{2}(?:[A-Za-z0-9+/]{4})*(?:==)?|[A-Za-z0-9+/]{3}(?:[A-Za-z0-9+/]{4})*=?|[A-Za-z0-9_-]{4}(?:[A-Za-z0-9_-]{4})*|[A-Za-z0-9_-]{2}(?:[A-Za-z0-9_-]{4})*(?:==)?|[A-Za-z0-9_-]{3}(?:[A-Za-z0-9_-]{4})*=?)$', + min_length=2, + ) = Field( + ..., + description='Verbatim JCS bytes of the AgentAcceptancePayload the agent signed.\n Unbounded for the same reason as offer_canonical_bytes. Same rule as\n ramp.admin.v1.TransactionEvidence.offer_canonical_bytes (drift-gated).', + ) + agent_acceptance_signature: constr(pattern=r'^[0-9A-Fa-f]{128}$') = Field( + ..., + description="The agent's Ed25519 signature over agent_acceptance_canonical_bytes,\n hex-encoded verbatim as it arrived on the wire (either case). Same rule\n as ramp.v1.AgentAcceptance.signature (drift-gated) — the live field this\n row stores a copy of.\n\nBoth directives here point UPSTREAM into ramp.v1, which they did not\n always do. This pattern was pinned on the read plane first, while the\n agent plane still described the hex shape in prose and enforced nothing;\n the anchors sat inside this package because there was no upstream rule to\n point at. ramp.v1 now carries the rule on both signature fields, so the\n gate compares the two planes against each other and a future tightening\n on one side can no longer leave the other silently behind.", + ) + agent_acceptance_signature_algorithm: AgentAcceptanceSignatureAlgorithm = Field( + ..., + description='Signing-algorithm label, server-derived (see offer_sig_algorithm).\n Pinned to "EdDSA". Same rule as\n ramp.admin.v1.TransactionEvidence.offer_sig_algorithm (drift-gated).', + ) + agent_directory_url: ( + constr( + pattern=r'^$|^https://[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?/[!-~]*$', + max_length=512, + ) + | None + ) = Field( + '', + description='The anchored well-known directory agent_public_key was pinned from. The\n registry overwrites keys in place on rotation and keeps no history, so\n this — plus created_at — attests where and when this Exchange obtained\n the key. Empty when the agent carries no directory anchor: an append-once\n row states a value for every column, so \'\' is a stated fact, not a gap.\n\nPROVENANCE, NOT AUTHORITY. This field is covered by neither signature and\n is written by the same party as the rest of the row, so it can never\n establish that agent_public_key is authentic — see TRUST BOUNDARY above,\n which says where the agent anchor must come from instead. Verification\n tooling MUST NOT treat this value as a fetch target it can trust: the row\n author chose it, so following it hands them the choice of what the\n "independent" copy says.\n\n The rules below bound the damage from tooling that follows the field\n anyway; they do not make following it safe. The value must be \'\' or an\n https URL whose host uses the same recipient-host grammar as\n ramp.v1.Offer.exchange, with an optional port and an ASCII-printable path,\n within 512 bytes. Stated precisely, because a rule that sounds stronger\n than it is would be worse than none: this refuses a plaintext or non-http\n scheme, embedded userinfo or whitespace, and anything that is not a\n host-plus-path shape. It does NOT refuse an IPv4-literal host — the\n recipient-host grammar admits all-numeric labels, so https://169.254.169.254/\n matches. Blocking link-local and private address space is the fetching\n tool\'s job, and it is one more reason this field is not a fetch target.\n\n Named directory, not discovery: ramp.v1 uses "discovery" for RESOURCE\n discovery (DiscoveryRequest, OfferGroup.discovery_method), a different\n thing entirely. This is the agent\'s well-known directory document, which\n is what every sentence describing the field already calls it.', + ) + agent_public_key: constr( + pattern=r'^(?:[A-Za-z0-9+/]{43}=?|[A-Za-z0-9_-]{43}=?)$', + min_length=43, + max_length=44, + ) = Field( + ..., + description='The registry-pinned agent verifying key (raw 32-byte Ed25519) the\n acceptance verified against. This is the ACCEPTANCE key, which is the\n agent identity for the transaction — ramp.v1.AgentAcceptance defines that\n normatively under "Agent identity", and this row stores the key that\n definition names. It is deliberately NOT the transport signer: a Broker\n may author a re-packaged execute as sender, so the RFC 9421 signer on that\n leg is the broker, and a row anchored on it would name the wrong party.\n Same rule as\n ramp.admin.v1.TransactionEvidence.exchange_signing_public_key\n (drift-gated).', + ) + broker: constr(pattern=r'^$|^[!-~]+$', max_length=255) | None = Field( + None, + description='The relay hop that presented this request to the Exchange, if the\n Exchange records one. A transport fact the Exchange observed, covered by\n neither signature — which is why it sits in this section and not on\n TransactionState: TransactionState projects transaction-log columns, and\n broker routing is an execute-time observation about the connection, not\n a property of the transaction\'s operational state.\n\nThree states, and the `optional` keyword is what makes them distinct:\n ABSENT means this Exchange does not record routing at all; \'\' means it\n does record it AND the acceptance arrived direct; a value means it\n arrived through that hop. Without explicit presence the field would\n default to \'\', so an Exchange with nothing to say would state "arrived\n direct" for every row — a forensic plane asserting a transport fact it\n never observed.\n\n WHAT THE VALUE IS: implementation-defined provenance for the outermost\n hop, not a resolvable identity. The reference Exchange serves the\n verified RFC 7638 key thumbprint of the hop that presented the request.\n It deliberately does not resolve that key to a directory host: the relay\n hop is not re-identified against any registry, and the recipient tenant\'s\n own relay-permission setting is the gate instead. So a reader may compare\n this value for equality and may check it against a thumbprint it already\n holds, but must not expect a hostname, and must not treat it as an\n identity the Exchange vouched for. Only the outermost hop is classified;\n per-hop identity for a longer chain is out of scope here.\n\n The rule bounds the SHAPE without pinning the format. A ledger renders\n this value, so an unbounded string here would re-open on a new field\n exactly the surface request_id\'s printable-ASCII bound closes — control\n characters, terminal escapes and newlines reaching a rendered forensic\n row. Printable ASCII and 255 characters admit every provenance form a\n server might reasonably record (a thumbprint, a host, an opaque id) while\n refusing the shapes that only matter to a renderer. It is deliberately\n NOT a thumbprint pattern: the value is implementation-defined, and a\n format rule here could invalidate a row for a transaction that\n legitimately executed under a server that spells it some other way — the\n requester_id reasoning. The pattern admits the EMPTY string explicitly,\n because \'\' is one of the three states — recorded, and the acceptance\n arrived direct. A bare ^[!-~]+$ would need at least one character and\n would delete that state, leaving absence to mean both "not recorded" and\n "arrived direct". Same alternation shape agent_directory_url uses above,\n for the same reason.', + ) + created_at: AwareDatetime = Field( + ..., description='When the Exchange wrote this row (server clock).' + ) + exchange_signing_public_key: constr( + pattern=r'^(?:[A-Za-z0-9+/]{43}=?|[A-Za-z0-9_-]{43}=?)$', + min_length=43, + max_length=44, + ) = Field( + ..., + description='The Exchange verifying key itself (raw 32-byte Ed25519), not a key id.', + ) + offer_canonical_bytes: constr( + pattern=r'^(?:[A-Za-z0-9+/]{4}(?:[A-Za-z0-9+/]{4})*|[A-Za-z0-9+/]{2}(?:[A-Za-z0-9+/]{4})*(?:==)?|[A-Za-z0-9+/]{3}(?:[A-Za-z0-9+/]{4})*=?|[A-Za-z0-9_-]{4}(?:[A-Za-z0-9_-]{4})*|[A-Za-z0-9_-]{2}(?:[A-Za-z0-9_-]{4})*(?:==)?|[A-Za-z0-9_-]{3}(?:[A-Za-z0-9_-]{4})*=?)$', + min_length=2, + ) = Field( + ..., + description="Verbatim JCS bytes the Exchange's signature was computed over (the offer\n with its signature fields cleared). min_len only, no ceiling: same\n rationale as offer_json — the bytes under the signature are whatever size\n the signed offer was, and a bound could invalidate a legitimate row.", + ) + offer_id: constr(min_length=1) = Field( + ..., + description='The signed Offer.offer_id (which IS the catalog resource_id). Duplicated\n from the offer JSON so the row reads standalone, without parsing it.', + ) + offer_json: constr(min_length=1) = Field( + ..., + description="The signed offer as a raw JSON string, for query and human audit.\n Deliberately NOT a Struct: a Struct re-normalizes, and the canonical\n bytes below remain the arbiter of what was signed. No upper bound, unlike\n this file's 255-capped ids: upstream ramp.v1 places no size bound on an\n offer, and the row must state whatever the parties actually signed — a\n cap here could make the row fail its own validation for a transaction\n that legitimately executed (the requester_id rationale).", + ) + offer_sig: constr(pattern=r'^[0-9A-Fa-f]{128}$') = Field( + ..., + description="The Exchange's Ed25519 signature over offer_canonical_bytes, hex-encoded\n in the verbatim wire form (either case — hex decoding accepts both, and a\n dispute should read the same characters a request log holds). Named after\n ramp.v1.AgentAcceptancePayload.offer_sig: it is the same value, the one\n the agent's acceptance binds to. Same rule as ramp.v1.Offer.signature\n (drift-gated) — the field this row stores a copy of.", + ) + offer_sig_algorithm: OfferSigAlgorithm = Field( + ..., + description='Signing-algorithm label, server-derived from the Exchange\'s own verify\n path — never echoed from the wire. The canonical payload clears the wire\n labels before signing, so an echoed label would sit outside signature\n coverage and could claim anything under an otherwise valid signature.\n Pinned to "EdDSA" — the content-signature label\n ramp.v1.Offer.signature_algorithm pins; "ed25519" is the separate label\n reserved for RFC 9421 HTTP request signatures and never appears here.\n const (not min_len) so a generated client also rejects a claimed "none"\n or "HS256".\n\nSpelled sig_algorithm, not signature_algorithm, which is how ramp.v1 and\n the sibling agent_acceptance_signature_algorithm spell it. The short form\n is INHERITED, not chosen: this label names the neighbouring offer_sig, and\n that field copies an upstream field name verbatim\n (ramp.v1.AgentAcceptancePayload.offer_sig). A label that renamed the field\n it describes would be the worse inconsistency.\n\n The long spelling is also not available: ramp.v1 retired a scalar\n offer-signature field (the execute request now reflects the\n full signed Offer instead), and scripts/check-doc-conformance.sh bans that\n identifier across the protos and the docs so the removed name cannot be\n read as live anywhere. A field named after it here would either fail that\n gate or force it open.', + ) + request_correlation: RequestCorrelation | None = Field( + None, + description='Correlation id joining this row outward to whatever else recorded the\n same X-Request-ID for this execute call, with its provenance. One\n message, not two\n sibling fields: presence of the message is the pairing — id and\n provenance flag arrive together or not at all, a constraint two\n optional siblings could not express without message-level CEL (which\n this file forbids). Absent when the Exchange recorded no correlation id.', + ) + request_idempotency_key: constr(min_length=1, max_length=255) = Field( + ..., + description='The REQUEST-level idempotency key the acceptance signs — NOT the derived\n per-item key that TransactionState.idempotency_key carries. Same rule as\n ramp.v1.TransactionRequest.idempotency_key (drift-gated).', + ) + requester_domain: str | None = Field( + '', + description="The signed Requester.domain, verbatim. Unbounded HERE even though the\n agent plane bounds it — ramp.v1.Requester.domain carries max_len 260 and\n the bare-host pattern. Those rules govern what an Exchange may ACCEPT on\n the way in; they do not govern what this row may STATE after the fact. The\n row's job is to reproduce the bytes the acceptance actually signed, so a\n rule here could make the row fail its own validation for a transaction\n that legitimately executed — one accepted under an earlier rule set, or\n signed by a party that spelled the value some other way. Same conclusion\n as requester_id, reached differently: Requester.id genuinely carries no\n wire rule at all.", + ) + requester_id: str | None = Field( + '', + description='The acceptance payload\'s remaining inputs (offer_sig above is the\n fourth), stored so the signed bytes can be independently rebuilt and\n audited rather than merely trusted.\n\nrequester_id is the signed Requester.id VERBATIM — the bytes under the\n agent\'s signature, never rewritten. It NAMES the same agent as the\n Exchange\'s canonical agent identity but is not byte-equal to it: a signer\n may spell its directory any way it likes (the deployed identity service\n signs "scheme://host"), so the forensic join goes through directory-host\n normalization, not plain equality. No wire rule: the agent plane does not\n constrain Requester.id, and this row states what was signed.', + ) + tenant_id: constr(min_length=1, max_length=255) = Field( + ..., + description='The tenant the transaction executed under. The admin plane is\n deployment-scoped (cross-tenant), so the row states its tenant. Same rule\n as ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated).', + ) + transaction_id: constr(min_length=1, max_length=255) = Field( + ..., + description='The evidenced transaction (Exchange-minted transaction identity). The\n format is implementation-defined, exactly as in ramp.v1 (the documented\n storage model mints a 26-char ULID). The 255 bound is NEW to this plane —\n ramp.v1 leaves transaction ids unconstrained — and is safe here because\n the Exchange mints the id itself, far below that bound; it exists so the\n selector stays storable and indexable.', + ) + + class TransactionResultItem(WireModel): billing_id: str | None = Field( '', @@ -1080,12 +1246,36 @@ class TransactionResultItem(WireModel): 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.' + '', + description='Exchange-assigned transaction identifier. Opaque to agents; the format\n is implementation-defined (the documented storage model mints a\n time-ordered ULID as the record\'s primary key).\n\nENTROPY. RAMP places no entropy requirement on this value. An implementer\n choosing a sequential id should know precisely what that does and does not\n cost, because the protection here is narrower than "unguessable ids are\n unnecessary".\n\n WHAT IS GUARANTEED. The admin plane\'s evidence read\n (ramp.admin.v1.GetTransactionEvidence) selects by the\n (tenant_id, transaction_id) PAIR, so a transaction id ALONE is never a\n bearer capability for the forensic row. Counterparty agents legitimately\n hold the ids of their own transactions, and that pairing is what stops one\n of those ids from reading the row on its own. That is the whole guarantee,\n and a conformance guard fails if the selector stops being a pair.\n\n WHAT IS NOT GUARANTEED: resistance to ENUMERATION. The tenant half of the\n pair is not a secret. Deployments use human brand slugs, and the slug is\n handed to every agent that holds an offer from that tenant — it prefixes\n the offer id inside the signed offer. So a caller who can reach the admin\n plane at all, and who has done business with a tenant, already knows one\n valid tenant value and can walk sequential transaction ids against it.\n What bounds that is the network-layer reachability restriction on the\n admin plane (see the ramp.admin.v1 file header): that plane must not be\n exposed on the public agent-facing listener, and it is the outer control\n an operator must not relax. An Exchange that wants enumeration resistance\n in depth should mint unguessable ids; RAMP does not require it, and no\n agent-plane behavior depends on this format either way.', + ) + + +class TransactionState(WireModel): + idempotency_key: constr(min_length=1) = Field( + ..., + description='The transaction\'s per-item idempotency key as logged. The Exchange\n derives it as TransactionEvidence.request_idempotency_key + ":" +\n offer_id — unconditionally, single-item requests included — so distinct\n items of a batch dedupe independently, and this value is NEVER byte-equal\n to the request-level key. A ledger joining this row against a log export\n matches on this derived form, not on the bare request key, and it reaches\n the TRANSACTION-side events only: a usage-report event stores the report\'s\n own idempotency key, because a report addresses a whole transaction and\n has no offer id to derive with. Join a usage report on transaction_id\n instead. No upper\n bound: the derivation appends an id whose length nothing constrains.', + ) + signed_url_expiry: AwareDatetime | None = Field( + None, + description="When the signed retrieval URL expires. Named to pair with signed_url_hash\n below, so the two fields describing one minted URL read as a pair and\n neither can be mistaken for a property of the transaction itself. Do not\n read the name as a column name: stores spell this one differently\n (TransactionResultItem.expires_at on the wire, and the reference\n Exchange's transaction log calls the column plainly `expiry`), so a\n ledger joining to a log matches this field by MEANING, not by name.\n signed_url_hash is the one that happens to match a real column name.\n\nAbsent when the transaction minted no signed URL: DELIVERY_METHOD_DIRECT\n returns the resource inline or from the Exchange's own endpoint, so there\n is nothing to expire. DELIVERY_METHOD_INSTRUCTIONS and\n DELIVERY_METHOD_STREAMING both mint one and always carry this field.\n Absence is a stated fact about the delivery method, not missing data: a\n direct delivery has no value to state here, so there is nothing an empty\n value could honestly mean.", + ) + signed_url_hash: ( + constr( + pattern=r'^(?:[A-Za-z0-9+/]{43}=?|[A-Za-z0-9_-]{43}=?)$', + min_length=43, + max_length=44, + ) + | None + ) = Field( + None, + description="sha256 of the signed retrieval URL — the join key against the transaction\n log's signed_url_hash column, which holds the same digest as 32 raw bytes.\n The join is byte-to-byte; nothing needs normalizing. Text only appears\n when a store is rendered — protojson base64s this field, and a log export\n picks its own spelling — so it is exports, not stores, that a join has to\n reconcile. Hash-only by design: the full URL is a live\n bearer capability until expiry and is deliberately absent from this\n plane (see TransactionEvidence's delivery section). Absent exactly when\n signed_url_expiry is, and for the same reason: no signed URL, nothing to\n hash. The\n `optional` keyword is load-bearing — it gives this scalar explicit\n presence, so protovalidate skips the length rule on an unset value, while\n a PRESENT hash must still be exactly 32 bytes.", ) class UsageAsset(WireModel): package_id: str | None = Field(None, description='Package identifier') + title: str | None = Field(None, description='Asset title') uri: str | None = Field('', description='Asset URI') @@ -1233,6 +1423,25 @@ class DomainVerificationFailure(WireModel): ) +class GetTransactionEvidenceResponse(WireModel): + evidence: TransactionEvidence = Field( + ..., + description='The append-once evidence row. Required: it exists 1:1 for every found\n transaction — an unknown transaction_id is NOT_FOUND, never an empty\n response.', + ) + obligation_state: ReportingObligationState | None = Field( + None, + description='The transaction\'s reporting obligation record, as persisted. The store\n keeps ONE obligation per transaction (keyed on the transaction id,\n transitioning in place — see the storage model), so this is the record\n the Exchange\'s own reporting path acts on, not a "latest of several".\n Absent when the transaction minted none.', + ) + transaction_state: TransactionState = Field( + ..., + description='The transaction-log facts next to it. Required for the same 1:1 reason.', + ) + ver: str | None = Field( + '', + description='RAMP protocol version — "1.0". Stamped by the sender from a single\n constant; advisory on receive. See "Protocol version" in ramp.proto.', + ) + + class Obligation(WireModel): detail: str | None = Field( None, @@ -1276,7 +1485,7 @@ class Pricing(WireModel): | 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.', + description='Metering basis — the "per what" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as "vendor:unit". Ignored for\n FREE / FLAT.\n\nThe (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, @@ -1293,7 +1502,7 @@ class Quota(WireModel): 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.', + description='The unit being capped — an open vocabulary axis.\n\nThe (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.' @@ -1336,7 +1545,7 @@ class Requester(WireModel): ) 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).', + description='Entitlement scopes. Declare what the requester can access.\n\nThe 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( @@ -1347,11 +1556,11 @@ class Requester(WireModel): 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=...', + description='C2PA content credentials manifest URI.\n Points to a sidecar or embedded C2PA manifest for this resource.\n C2PA-aware agents MAY follow this URI to validate the full provenance\n chain (creator identity, transformation history, ingredient composition)\n using C2PA libraries (JUMBF/COSE Sign1). C2PA-unaware agents can rely\n on c2pa_status and c2pa-bridged attestation claims instead.\n\nFormats:\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.', + description='Summary validation status of the C2PA manifest.\n Populated by the Exchange or a verification vendor after validating\n the C2PA manifest. Enables agents to filter for provenance-verified\n content without parsing JUMBF/COSE themselves.\n 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, @@ -1359,7 +1568,7 @@ class ResourceIdentity(WireModel): ) 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.', + description='Hash of the content. Interpretation depends on hash_method:\n "simhash-v1" → locality-sensitive hash, for fuzzy dedup (Level 1)\n "sha256" → exact-match integrity hash (Level 2)\n\nLevel 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.' @@ -1382,11 +1591,11 @@ class ResourceIdentity(WireModel): ) 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).', + description='Signals whether this resource\'s content is stable, changes over time,\n or does not exist at offer time (live streaming).\n 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 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).', + description='Soft binding hash — content-derived identifier that survives format\n transcoding (resolution changes, compression, PDF-to-text extraction).\n Extracted from C2PA soft binding assertion when present.\n Enables post-delivery verification when the hard binding hash breaks\n due to legitimate format conversion.\n\nAlgorithm 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, @@ -1421,7 +1630,7 @@ class ResourceQuery(WireModel): ) 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"]', + description='Domain extension profiles the caller understands.\n\nDeclares 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 @@ -1484,7 +1693,7 @@ class SetTenantFeeRateResponse(WireModel): class TransactionResponse(WireModel): agent_identity_hash: str | None = Field( '', - description='Identity that a delivered retrieval_endpoint is bound to: the RFC 7638 JWK\n Thumbprint of the agent\'s Ed25519 request-signing key (see "Retrieval-URL\n identity binding" above). Shared across the request; set once.', + description='Identity that a delivered retrieval_endpoint is bound to: the RFC 7638 JWK\n Thumbprint of the agent\'s Ed25519 key, as "Agent identity" on\n AgentAcceptance defines it — the acceptance key, not the transport signer,\n which may be a Broker. See "Retrieval-URL identity binding" in the file\n header for how a delivery endpoint checks the binding. Shared across the\n request; set once, which is why every acceptance in one request must be\n signed by the same key.', ) ext: dict[str, Any] | None = Field(None, description='Extension point') ext_critical: list[str] | None = Field( @@ -1564,13 +1773,14 @@ class UsageReport(WireModel): ) 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.", + 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\nDEDUPE SCOPE. The invariant is the one stated on\n TransactionRequest.idempotency_key. This message carries no acceptance\n payload, so there is no in-body agent signature to anchor on; the namespace\n is instead the TRANSACTION the report addresses, and the server dedupes per\n (transaction_id, key). That satisfies the invariant without depending on\n the transport signer: the transaction was bound to exactly one agent by its\n acceptance at execute time, so two agents relayed by the same Broker report\n against different transactions and never share a namespace.\n Leaving the signer out is also what makes the relay work. "Filed by the\n agent or Broker" above means the Broker FORWARDS the agent\'s report, not\n that it authors one of its own: the body is unchanged and carries this same\n key, so a direct submission and a relayed copy are one report on two paths\n and MUST collapse. Adding the verified signer to the namespace would split\n them and count the usage twice.\n\n WHO MAY FILE. Dropping the signer from the namespace removes a protection\n that has to be restored explicitly, so the rule is stated rather than\n implied: only the agent the transaction was bound to at execute time may\n file against it, or a Broker relaying that agent\'s report unchanged. The\n argument above is about honest filers — it shows two legitimate parties\n never collide by accident, which is a different claim from who is allowed\n to write. A filing from any other party MUST be rejected, never deduped:\n the slot is now shared, so an accepted filing from an unbound party would\n occupy the one the bound agent\'s report needs, and the real usage would\n collapse into it and go uncounted.\n\n An unauthorized filing is reported as USAGE_REPORT_REJECTION_REASON_\n TRANSACTION_NOT_FOUND, deliberately. There is no distinct "not authorized"\n reason and there should not be one: it would confirm to a party not bound\n to the transaction that the transaction exists, which turns the rejection\n into an oracle for probing transaction ids.', ) timestamp: AwareDatetime | None = Field( None, description='When the resource was used (ISO 8601).' ) - transaction_id: str | None = Field( - '', description='Transaction ID from the delivery.' + transaction_id: constr(min_length=1) = Field( + ..., + description='Transaction ID from the delivery. MUST be non-empty. It is also the dedupe\n namespace for `idempotency_key` above, so a report that names no\n transaction has no namespace to dedupe within — the rule below is what\n makes that namespace exist, not a shape preference. No upper bound: the\n Exchange assigns this id and nothing upstream constrains its length.', ) usage: Usage | None = Field(None, description='How the resource was actually used.') ver: str | None = Field( @@ -1719,7 +1929,7 @@ class DiscoveryRequest(WireModel): ) 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', + description='Domain extension profiles the agent understands.\n\nThe 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, @@ -1794,7 +2004,7 @@ class LicenseTerm(WireModel): ) 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.', + description='Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent\'s delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:["subscription:..."].\n\nCoverage 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( @@ -1805,11 +2015,11 @@ class LicenseTerm(WireModel): 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.', + description='Signed attestations about the resource at this URI.\n Attestations provide cryptographic proof of\n resource properties from trusted parties (providers or verification vendors).\n\nThree 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.", + description='When the offered data was current. For dynamic resources\n (resource_mutability = DYNAMIC), this is the snapshot timestamp.\n Enables the Broker to evaluate freshness: "this credit report\n reflects data as of March 18" or "this drug database was updated today."\n\nNot 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$') @@ -1845,7 +2055,7 @@ class Offer(WireModel): ) 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).', + description="Lightweight previews for offer evaluation.\n The Exchange holds URLs (50–200 bytes each); the provider's CDN serves\n the actual bytes. Agents fetch previews only when evaluating offers —\n not on every discovery query. Multiple previews at different sizes\n allow agents to pick the cheapest fetch for their evaluation needs.\n\nPer 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, @@ -1854,13 +2064,13 @@ class Offer(WireModel): reporting: ReportingObligation | None = Field( None, description='Post-usage reporting requirements for this offer.' ) - signature: str | None = Field( - '', - description="CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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: constr(pattern=r'^[0-9A-Fa-f]{128}$') = Field( + ..., + description="REQUIRED. A DETACHED Ed25519 signature over the canonical serialization of\n the ENTIRE Offer, carried as the raw 64 signature bytes in lowercase or\n uppercase hex — 128 hex characters, NOT a JWS. There is no JOSE header and\n no compact serialization here: the signed bytes are defined by the\n canonical-signing recipe below, and this field carries only the signature\n itself. `signature_algorithm` names the algorithm separately.\n Same convention as `AgentAcceptance.signature`, and it is what the admin\n plane's offline verification recipe replays. The hex shape is STATED here\n and ENFORCED there: this field carries no schema rule, while\n ramp.admin.v1.TransactionEvidence.offer_sig — the same value, copied into\n the evidence row — is pattern-bound to 128 hex characters.\n\nThe signature covers every field, including `pricing`, `terms` (the full\n licensing payload), `expires_at`, and `exchange`. Only `signature` and\n `signature_algorithm` are excluded from the signed bytes. `expires_at` is\n signed so the offer's validity window is integrity-protected: a relaying\n Broker cannot extend (or shorten) the TTL of a signed offer to replay it\n outside the window the Exchange intended.\n\n CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.\n\n The rule is the hex shape this comment already describes: 128 characters,\n either case, which is one Ed25519 signature (64 bytes) hex-encoded. Either\n case is accepted because hex decoding accepts both and a dispute should\n read the same characters a request log holds; every SDK in this repo emits\n lowercase. The pattern also makes the field mandatory in practice — the\n empty string does not match it — which restates what this message already\n requires: an unsigned Offer is not an Offer, since the signature is what\n makes its terms, pricing and expiry non-repudiable.", ) signature_algorithm: str | None = Field( '', - description="JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization.", + description="Signature algorithm. Always 'EdDSA' — the JOSE algorithm identifier for\n Ed25519 (RFC 8032). Only the identifier is borrowed from JOSE: `signature`\n is a detached hex signature, not a JWS.", ) subscription_id: str | None = Field( None, @@ -1874,6 +2084,9 @@ class Offer(WireModel): 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.", ) + title: str | None = Field( + None, description='Resource title (human-readable, for display/logging).' + ) class OfferGroup(WireModel): @@ -1935,6 +2148,7 @@ class ResourceEntry(WireModel): 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.', ) + title: str | None = Field(None, description='Content title') word_count: conint(ge=-2147483648, le=2147483647) | None = Field( None, description='Word count' ) @@ -1989,10 +2203,10 @@ class TransactionRequest(WireModel): ) 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.", + 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\nDEDUPE SCOPE — the invariant, stated here once and cited by every other RPC\n that carries an idempotency_key: a key chosen by one caller MUST NEVER\n collide with another caller\'s cached result. The server dedupes within a\n namespace, never globally. What that namespace IS differs per RPC, because\n the RPCs do not authenticate the same way; each states its own, and each\n namespace has to make the invariant true on its own terms.\n\n For this RPC the namespace is the ACCEPTANCE IDENTITY — the agent key\n defined under "Agent identity" on AgentAcceptance — never the transport\n sender, which may be a Broker relaying many agents behind one key. The\n server dedupes per (acceptance identity, key).', ) - items: list[TransactionItem] | None = Field( - None, + items: list[TransactionItem] = Field( + ..., description="The offers committed in this request (REQUIRED, min 1), each carrying its\n own reflected signed Offer + detached acceptance. A single offer is the\n degenerate 1-element list. The Exchange verifies each item's\n `offer.signature` (which covers pricing, terms, and expires_at) over the\n presented bytes against its own key — stateless, self-contained bearer\n tokens, with no reconstruct-from-catalog.", min_length=1, ) @@ -2008,7 +2222,7 @@ class TransactionRequest(WireModel): 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.', + description='Why the resolve produced no offers at all. Set (and offer_groups empty) on a\n successful "no result" answer; unset when offer_groups is non-empty. Same\n vocabulary DiscoverResources uses for OfferGroup.absence_reason.\n RESTRICTION_FILTERED may appear here, but Resolve does not surface the\n per-axis detail: DiscoveryResponse has no restriction_filters companion\n (unlike OfferGroup). A consumer needing the filtered axes calls\n DiscoverResources.\n 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( diff --git a/gen/ts/tests/bytes_wire_forms.test.ts b/gen/ts/tests/bytes_wire_forms.test.ts new file mode 100644 index 00000000..d046ca6d --- /dev/null +++ b/gen/ts/tests/bytes_wire_forms.test.ts @@ -0,0 +1,47 @@ +// Direct behavioral regression for the base64 wire forms of a bytes-rule field. +// +// protoschema renders a bytes field too loose in both rule kinds, so +// merge_schema.tighten_bytes_len rewrites them from the bytesgen manifest: +// - bytes.len=32 (the Ed25519 keys, signed_url_hash): protoschema's 43..44 +// CHARACTER window also admits a 33-byte value (44 unpadded chars) and a +// 31-byte padded value (44 chars with "=="). The rewrite pins the payload +// to exactly 43 chars of ONE alphabet plus optional exact padding. +// - bytes.min_len=1 (the canonical-bytes fields): protoschema's pattern counts +// padding as content, so "==" (zero payload bytes, which Go protojson +// refuses to decode) passed. The rewrite requires the encoded payload chars +// of at least 1 byte BEFORE the padding tail. +// +// The rows live in conformance/testdata/bytes_wire_forms.json, shared with the +// Pydantic harness (gen/python/tests/test_bytes_wire_forms.py) and pinned +// against Go itself by conformance/bytes_wire_forms_test.go — Go protojson + +// protovalidate is the oracle the generated patterns mirror. See that file's +// $comment for why the generated corpus cannot cover this axis. +import { describe, it, expect } from "vitest"; +import * as schemas from "../wire/schemas.ts"; +import vectors from "../../../conformance/testdata/bytes_wire_forms.json"; + +type Form = { value: string; accepted: boolean; why: string }; + +const CASES: Array<[message: string, field: string, value: string, accepted: boolean]> = + vectors.fields.flatMap((f) => + (vectors.form_sets as Record)[f.form_set].forms.map( + (form): [string, string, string, boolean] => [f.message, f.field, form.value, form.accepted], + ), + ); + +describe("bytes rules decide every base64 wire form", () => { + // Guard against a renamed key or a moved file making the suite vacuous. + it("vectors are non-empty", () => expect(CASES.length).toBeGreaterThan(0)); + + for (const [message, field, value, accepted] of CASES) { + const schema = (schemas as Record { success: boolean } }>)[ + `${message}Schema` + ]; + const base = (vectors.bases as Record>)[message]; + it(`${message}.${field} = ${JSON.stringify(value)} -> ${accepted ? "accept" : "reject"}`, () => { + expect(schema).toBeDefined(); + expect(base).toBeDefined(); + expect(schema.safeParse({ ...base, [field]: value }).success).toBe(accepted); + }); + } +}); diff --git a/gen/ts/wire/schemas.ts b/gen/ts/wire/schemas.ts index 1d38d638..00963852 100644 --- a/gen/ts/wire/schemas.ts +++ b/gen/ts/wire/schemas.ts @@ -10,9 +10,9 @@ export const AcceptableRestrictionSchema = wire(z.object({ "axis": z.union([z.st export const AccountRegistrationSchema = wire(z.object({ "data_schema": z.record(z.string(), z.any()).describe("JSON Schema (draft 2020-12) describing the RegisterRequest.registration_data\n object this Exchange expects. This field is the single home of the\n enforce/pass-through contract, and publishing it IS the enforcement switch.\n Present: this Exchange validates registration_data against the schema and\n refuses a non-conforming payload with\n REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, naming the offending\n members in RegistrationFailure.field_errors. Absent: registration_data is\n passed through to the system of record uninspected, so an Exchange that\n publishes no schema needs no change to stay conformant. Safety rules,\n because a consumer reads this schema out of a third party's manifest: it\n MUST be self-contained, and a consumer MUST NOT resolve a remote $ref out of\n it — doing so turns every reader into an SSRF vector aimed at a URL the\n schema's author chose. A consumer SHOULD bound validation time and recursion\n depth; draft 2020-12 `pattern` admits regexes with catastrophic\n backtracking. Size is capped at 16KB, measured as the UTF-8 bytes of this\n member as served in ramp.json; a consumer SHOULD reject an oversized schema\n and skip its local pre-check rather than truncate it, which leaves the\n Exchange's own enforcement the deciding check exactly as when no schema is\n published.").optional() }).describe("AccountRegistration — how to open an account at this Exchange. Published as an\n optional block on WellKnownManifest; an Exchange that omits the whole block\n keeps today's behaviour, accepting RegisterRequest.registration_data\n uninspected. The block exists rather than a flat field because registration\n has more than one publishable facet: `data_schema` describes the API mode\n below, and field 2 is deliberately left free for a future web mode — a URL to\n a registration page on the Exchange's own site, where a human completes the\n steps an API call cannot carry (explicit terms confirmation, identity checks,\n manual review). Flat siblings on a manifest this size would give a reader no\n signal that they are one subject. Precedence between the modes is fixed now,\n before both can be published: an Exchange that publishes `data_schema` MUST\n accept registration through the API, and a registration URL is an additional\n option an agent MAY offer its user instead — never a replacement for the API\n path. An Exchange that wants web-only registration publishes the URL and no\n schema. Note that terms versioning is NOT in this block: WellKnownManifest.\n terms_digest sits at the top level so an Exchange with pass-through\n registration can still pin which terms document it is serving.")); -export const AgentAcceptanceSchema = wire(z.object({ "signature": z.string().min(1).describe("Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload\n bytes (see the canonical-signing definition on Offer.signature)."), "signature_algorithm": z.string().describe("Signature algorithm; \"EdDSA\" for Ed25519.").default("") }).describe("`signature` is a hex-encoded detached Ed25519 signature (NOT a JWS) over the\n CANONICAL SIGNING form of `AgentAcceptancePayload` — RFC 8785 JCS over canonical\n proto-JSON. That form, including the pinned proto-JSON option set, is defined\n once on `Offer.signature` and is the single normative definition; there is no\n second recipe. `AgentAcceptancePayload` carries no signature fields, so the\n clear-then-render step of that definition reduces here to\n JCS(protojson(AgentAcceptancePayload)). Same hex/Ed25519 convention as\n `Offer.signature`; `signature_algorithm` is \"EdDSA\".")); +export const AgentAcceptanceSchema = wire(z.object({ "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload\n bytes (see the canonical-signing definition on Offer.signature). Same rule\n as ramp.v1.Offer.signature — the same 128-character hex shape, either\n case, because it is the same kind of value produced by the same\n convention.\n\nThe pattern replaced a bare min_len: 1, which it subsumes: a 128-character\n string cannot be empty. Nothing conformant is refused that was accepted\n before — a signature outside this shape could never hex-decode into 64\n bytes and so could never verify, so it failed at the verify step instead,\n later and with a worse error."), "signature_algorithm": z.string().describe("Signature algorithm; \"EdDSA\" for Ed25519.").default("") }).describe("AgentAcceptance — the agent's DETACHED acceptance signature over an accepted\n Offer. Distinct from the transport (RFC 9421) request signature:\n it is topology-independent and content-bound, so it stays valid no matter how\n many brokers relay the request, and binds the agent to THIS specific offer +\n requester + transaction. It travels in the execute body alongside the\n reflected Offer; the Exchange verifies it and binds the delivery URL to the\n agent's key.\n\nAGENT IDENTITY (normative; every other site cites this one). The agent\n identity for a transaction is the key that proved AGENT authorship of the\n request:\n\n - when an acceptance is present, the ACCEPTANCE key — the key whose\n signature over AgentAcceptancePayload the Exchange verified;\n - otherwise the verified RFC 9421 request signer, which is the agent only\n because an acceptance-less request cannot have been relayed.\n\n `agent_identity_hash` (TransactionResponse, and the value embedded in a bound\n retrieval URL) is the RFC 7638 JWK Thumbprint (SHA-256) of that key.\n\n The transport signer alone is not a usable identity here. A Broker may author\n a re-packaged transaction AS SENDER (see `exchange` in the file header), and\n on that leg the RFC 9421 signer is the broker while the in-body acceptance is\n the only agent-authored signature in the request. Anchoring on the acceptance\n makes the identity the same value whether the request arrived direct or\n through a broker. Where both exist and agree — the ordinary direct hop — the\n two readings coincide, which is why older text called this the\n \"request-signing key\".\n\n One acceptance key per request: `TransactionResponse.agent_identity_hash` is\n a single per-request value, so a batch whose items were accepted by DIFFERENT\n keys has no one identity to bind its delivery URLs to. Every acceptance in\n one TransactionRequest MUST be signed by the same key.\n\n ONE-KEY RULE. An agent MUST accept an offer and fetch the delivered resource\n with the SAME key. The Exchange derives `agent_identity_hash` from the\n acceptance key, and an enforcing delivery endpoint requires the fetcher to\n present exactly that key, so accepting with one key and fetching with another\n yields a transaction that succeeds and a retrieval that is refused. Two\n qualifiers bound the rule:\n\n - It binds only where proof-of-possession is enforced. A bearer-only\n signed-URL CDN that cannot run code keeps the bearer posture — see\n \"Retrieval-URL identity binding\" in the file header, which states that\n enforcement is not mandatory. The rule is what an agent must do to be\n servable by an enforcing endpoint, not a universal precondition for\n retrieval.\n - A custodial registry that holds the agent's single key and performs the\n bound fetch itself satisfies the rule with nothing extra to do.\n\n `signature` is a hex-encoded detached Ed25519 signature (NOT a JWS) over the\n CANONICAL SIGNING form of `AgentAcceptancePayload` — RFC 8785 JCS over canonical\n proto-JSON. That form, including the pinned proto-JSON option set, is defined\n once on `Offer.signature` and is the single normative definition; there is no\n second recipe. `AgentAcceptancePayload` carries no signature fields, so the\n clear-then-render step of that definition reduces here to\n JCS(protojson(AgentAcceptancePayload)). Same hex/Ed25519 convention as\n `Offer.signature`; `signature_algorithm` is \"EdDSA\".")); -export const AgentAcceptancePayloadSchema = wire(z.object({ "idempotency_key": z.string().describe("The transaction's idempotency key — binds the acceptance to a single\n execute so it cannot be replayed under a different transaction.").default(""), "offer_sig": z.string().describe("The accepted Offer's signature (Offer.signature). Anchors the whole signed\n offer without re-serializing its terms/pricing/expiry.").default(""), "requester_domain": z.string().describe("Requester domain (Requester.domain) the acceptance is bound to.").default(""), "requester_id": z.string().describe("Requester identity (Requester.id) the acceptance is bound to.").default("") }).describe("Field provenance when building the payload for an execute request:\n - offer_sig = the accepted Offer.signature (the Exchange's hex\n signature; transitively binds pricing, terms,\n expires_at, and — via the offer — the issuing Exchange)\n - requester_id = TransactionRequest.requester.id\n - requester_domain = TransactionRequest.requester.domain\n - idempotency_key = TransactionRequest.idempotency_key\n For batch mode, requester_* and idempotency_key come from the ENCLOSING\n TransactionRequest (a TransactionItem carries neither); offer_sig is the\n per-item Offer.signature.")); +export const AgentAcceptancePayloadSchema = wire(z.object({ "idempotency_key": z.string().describe("The transaction's idempotency key — binds the acceptance to a single\n execute so it cannot be replayed under a different transaction.").default(""), "offer_sig": z.string().describe("The accepted Offer's signature (Offer.signature). Anchors the whole signed\n offer without re-serializing its terms/pricing/expiry.").default(""), "requester_domain": z.string().describe("Requester domain (Requester.domain) the acceptance is bound to.").default(""), "requester_id": z.string().describe("Requester identity (Requester.id) the acceptance is bound to.").default("") }).describe("AgentAcceptancePayload — the canonical signing structure for AgentAcceptance.\n It is NEVER sent on the wire; it exists solely so the signer (SDK) and the\n verifier (Exchange) derive BYTE-IDENTICAL signed bytes from the same proto\n schema. This message fixes the FIELD SET; the byte layout is the canonical\n signing form defined on Offer.signature — RFC 8785 JCS over canonical\n proto-JSON with the pinned option set. Underspecifying either half is the top\n cross-implementation drift risk, so both are pinned normatively.\n\nField provenance when building the payload for an execute request:\n - offer_sig = the accepted Offer.signature (the Exchange's hex\n signature; transitively binds pricing, terms,\n expires_at, and — via the offer — the issuing Exchange)\n - requester_id = TransactionRequest.requester.id\n - requester_domain = TransactionRequest.requester.domain\n - idempotency_key = TransactionRequest.idempotency_key\n For batch mode, requester_* and idempotency_key come from the ENCLOSING\n TransactionRequest (a TransactionItem carries neither); offer_sig is the\n per-item Offer.signature.")); export const AttributionDetailSchema = wire(z.object({ "displayed_url": z.string().describe("URL displayed to the user as the attribution link.").optional(), "format": z.enum(["CITATION_FORMAT_LINK","CITATION_FORMAT_FOOTNOTE","CITATION_FORMAT_INLINE"]).describe("How the citation was presented.").optional(), "visible_to_user": z.boolean().describe("Whether the attribution was visible to the end user.").optional() }).describe("AttributionDetail — Structured attribution metadata for usage reporting.")); @@ -32,7 +32,7 @@ export const CitationFormatSchema = wire(z.enum(["CITATION_FORMAT_LINK","CITATIO export const CostSchema = wire(z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Cost — Actual transaction cost.")); -export const DelegationSchema = wire(z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this delegation expires. Exchange MUST reject expired tokens.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "issuer": z.string().describe("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.").optional(), "max_accesses": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "max_spend_cents": z.coerce.number().int().describe("Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.").optional(), "principal_domain": z.string().describe("Who granted this delegation (domain for public key lookup).").default(""), "principal_id": z.string().describe("Principal's identifier (e.g., \"user@acme.com\", \"marketdata.example.com\").").default(""), "quota_period": z.string().describe("Quota reset period. How often the access/spend counters reset.\n Example: 30 days for monthly subscriptions — \"2592000s\" on the wire\n (proto-JSON encodes Duration as seconds; \"720h\" is not accepted).\n When absent, the quota is lifetime (bounded only by expires_at).").optional(), "revocation_uri": z.string().describe("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).").optional(), "scopes": z.array(z.string()).describe("Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).").optional(), "token": z.string().regex(new RegExp("^[A-Za-z0-9+/]*={0,2}$")).describe("Token bytes. A JWT (base64url-encoded JWS).").default(""), "token_format": z.string().describe("Token format: \"jwt\" (default). Empty is treated as \"jwt\". The field stays\n open for a future format.").default("") }).describe("The token is an opaque, signed credential. The format is a JWT (token_format\n \"jwt\"); the field stays open for a future format. Its claims are the\n AUTHORITATIVE source of the grant; the\n plaintext fields below (scopes, expires_at, max_spend_cents, …) are a\n convenience mirror the Exchange MAY use for fast pre-filtering before it\n verifies the token.\n\n Holder binding (the load-bearing property). The grant is bound to a holder key\n via the RFC 7800 `cnf` confirmation claim — `cnf.jkt`, the RFC 7638 JWK\n thumbprint of the holder's key. Verification MUST check that the key signing\n the request (RFC 9421) hashes to that thumbprint; a token possessed without\n the matching private key is rejected. This is what makes a leaked token NOT\n bearer-usable. Delegation is a chain of cnf-linked JWTs: a principal narrows a\n grant by issuing a child JWT (cnf = the next holder, scopes ⊆ parent), signed\n by the key the parent's cnf named — the chain-linkage invariant. Verifiers\n need only the root issuer's public key; intermediate keys ride inside the\n chain (JOSE header `jwk`), so verification is offline.\n\n The claim schema is a small registered vocabulary — see the RAMP\n delegation-claims profile in the auth spec. All claims are OPTIONAL except the\n holder binding (cnf). Vendors MAY add namespaced claims (their own \"vendor:\"\n namespace; \"ramp_\"-prefixed names are reserved for the registered vocabulary\n and MUST NOT be redefined); any constraint a verifier cannot evaluate is\n binding by default (fail closed) unless the issuer marks it advisory —\n mirroring Restriction.advisory.\n\n Scope/time/spend caps are defense-in-depth that bound the blast radius only in\n the residual case where the holder's signing key is also compromised; the\n primary protection against theft is the holder binding above.")); +export const DelegationSchema = wire(z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this delegation expires. Exchange MUST reject expired tokens.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "issuer": z.string().describe("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.").optional(), "max_accesses": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "max_spend_cents": z.coerce.number().int().describe("Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.").optional(), "principal_domain": z.string().describe("Who granted this delegation (domain for public key lookup).").default(""), "principal_id": z.string().describe("Principal's identifier (e.g., \"user@acme.com\", \"marketdata.example.com\").").default(""), "quota_period": z.string().describe("Quota reset period. How often the access/spend counters reset.\n Example: 30 days for monthly subscriptions — \"2592000s\" on the wire\n (proto-JSON encodes Duration as seconds; \"720h\" is not accepted).\n When absent, the quota is lifetime (bounded only by expires_at).").optional(), "revocation_uri": z.string().describe("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).").optional(), "scopes": z.array(z.string()).describe("Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).").optional(), "token": z.string().regex(new RegExp("^[A-Za-z0-9+/]*={0,2}$")).describe("Token bytes. A JWT (base64url-encoded JWS).").default(""), "token_format": z.string().describe("Token format: \"jwt\" (default). Empty is treated as \"jwt\". The field stays\n open for a future format.").default("") }).describe("Delegation — Scoped, time-limited, spend-capped credential.\n\nThe token is an opaque, signed credential. The format is a JWT (token_format\n \"jwt\"); the field stays open for a future format. Its claims are the\n AUTHORITATIVE source of the grant; the\n plaintext fields below (scopes, expires_at, max_spend_cents, …) are a\n convenience mirror the Exchange MAY use for fast pre-filtering before it\n verifies the token.\n\n Holder binding (the load-bearing property). The grant is bound to a holder key\n via the RFC 7800 `cnf` confirmation claim — `cnf.jkt`, the RFC 7638 JWK\n thumbprint of the holder's key. Verification MUST check that the key signing\n the request (RFC 9421) hashes to that thumbprint; a token possessed without\n the matching private key is rejected. This is what makes a leaked token NOT\n bearer-usable. Delegation is a chain of cnf-linked JWTs: a principal narrows a\n grant by issuing a child JWT (cnf = the next holder, scopes ⊆ parent), signed\n by the key the parent's cnf named — the chain-linkage invariant. Verifiers\n need only the root issuer's public key; intermediate keys ride inside the\n chain (JOSE header `jwk`), so verification is offline.\n\n The claim schema is a small registered vocabulary — see the RAMP\n delegation-claims profile in the auth spec. All claims are OPTIONAL except the\n holder binding (cnf). Vendors MAY add namespaced claims (their own \"vendor:\"\n namespace; \"ramp_\"-prefixed names are reserved for the registered vocabulary\n and MUST NOT be redefined); any constraint a verifier cannot evaluate is\n binding by default (fail closed) unless the issuer marks it advisory —\n mirroring Restriction.advisory.\n\n Scope/time/spend caps are defense-in-depth that bound the blast radius only in\n the residual case where the holder's signing key is also compromised; the\n primary protection against theft is the holder binding above.")); export const DeliveryMethodSchema = wire(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])); @@ -40,9 +40,9 @@ export const DenialReasonSchema = wire(z.enum(["DENIAL_REASON_ACCOUNT_INACTIVE", export const DiscoveryMethodSchema = wire(z.enum(["DISCOVERY_METHOD_EXCHANGE","DISCOVERY_METHOD_SEARCH","DISCOVERY_METHOD_RECOMMENDATION","DISCOVERY_METHOD_SYNDICATION"])); -export const DiscoveryRequestSchema = wire(z.object({ "acceptable_restrictions": z.array(z.object({ "axis": z.union([z.string().regex(new RegExp("^RESTRICTION_KIND_UNSPECIFIED$")), z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("Which axis (same enum as Restriction.kind): FUNCTION / GEOGRAPHY /\n USER_TYPE / OTHER.").default(0), "values": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("The values the query operates within on this axis — same token vocabulary\n as the terms (e.g. FUNCTION [\"ai-train\"], GEOGRAPHY [\"US\", \"EU\"]).").optional() }).describe("AcceptableRestriction — the limits a query operates within on one restriction\n axis, expressed in the same RestrictionKind vocabulary that terms use. The\n Exchange/Broker MAY pre-select offers whose term restrictions fall within\n these as a convenience (see Restriction); it is NOT enforcement — the agent\n self-selects and bears compliance.")).describe("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.").optional(), "constraints": z.object({ "budget_period": z.string().describe("Budget period (e.g. \"2592000s\" = 30 days; proto-JSON encodes Duration\n as seconds). Resets at period boundary.").optional(), "budget_scope": z.string().describe("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.").optional(), "delivery_preference": z.array(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])).describe("Preferred delivery methods, in order of preference.").optional(), "exchanges": z.array(z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260)).describe("Authorized Exchange domains, in the shape \"Request recipient\" defines in the\n file header. Broker queries only these. This is a FILTER over third parties,\n not an address — the recipient of the request carrying it is a separate\n question.").optional(), "max_data_age": z.string().describe("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\"").optional(), "max_hops": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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).").optional(), "max_price": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Maximum price the agent is willing to pay.").optional(), "max_unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Maximum effective cost per unit, as an exact decimal string (not a float).").optional(), "period_budget": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Per-period budget limit. The Broker tracks spend against this\n for the budget_scope. Transactions that would exceed are denied.").optional(), "preferred_exchanges": z.array(z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260)).describe("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.").optional(), "reporting_capable": z.boolean().describe("Whether the agent supports post-usage reporting.").optional() }).describe("Constraints for exchange filtering and offer selection.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "query": z.string().describe("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).").optional(), "requester": z.object({ "delegation": z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this delegation expires. Exchange MUST reject expired tokens.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "issuer": z.string().describe("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.").optional(), "max_accesses": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "max_spend_cents": z.coerce.number().int().describe("Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.").optional(), "principal_domain": z.string().describe("Who granted this delegation (domain for public key lookup).").default(""), "principal_id": z.string().describe("Principal's identifier (e.g., \"user@acme.com\", \"marketdata.example.com\").").default(""), "quota_period": z.string().describe("Quota reset period. How often the access/spend counters reset.\n Example: 30 days for monthly subscriptions — \"2592000s\" on the wire\n (proto-JSON encodes Duration as seconds; \"720h\" is not accepted).\n When absent, the quota is lifetime (bounded only by expires_at).").optional(), "revocation_uri": z.string().describe("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).").optional(), "scopes": z.array(z.string()).describe("Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).").optional(), "token": z.string().regex(new RegExp("^[A-Za-z0-9+/]*={0,2}$")).describe("Token bytes. A JWT (base64url-encoded JWS).").default(""), "token_format": z.string().describe("Token format: \"jwt\" (default). Empty is treated as \"jwt\". The field stays\n open for a future format.").default("") }).describe("Optional delegation — present when the requester acts on behalf of\n another entity (user, organization, upstream agent).").optional(), "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Domain the requester belongs to — used for public key lookup, so the value\n is concatenated into a URL the verifier fetches ({domain}/.well-known/ramp.json,\n WellKnownManifest with role=ROLE_AGENT). It carries the same bare-host shape\n \"Request recipient\" defines in the file header, for the same structural\n reason: a scheme, path or query smuggled in here would choose what gets\n fetched, not merely from where."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "id": z.string().describe("Unique requester identifier (e.g., \"agent-research-bot-001\").").default(""), "name": z.string().describe("Human-readable name (e.g., \"Acme Research Assistant\").").optional(), "scopes": z.array(z.string()).max(64).describe("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).").optional(), "type": z.enum(["REQUESTER_TYPE_AGENT","REQUESTER_TYPE_HUMAN_TOOL","REQUESTER_TYPE_SERVICE","REQUESTER_TYPE_DELEGATED","REQUESTER_TYPE_RESEARCH"]).describe("What kind of entity is making this request.") }).describe("Requester identity — who is making this request, what scopes they have.\n The Broker forwards this to Exchanges in ResourceQuery.requester.").optional(), "search_filters": z.record(z.string(), z.any()).describe("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.").optional(), "supported_profiles": z.array(z.string()).describe("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").optional(), "uris": z.array(z.string()).max(256).describe("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.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("DiscoveryRequest — Agent sends to Broker (Step 1).")); +export const DiscoveryRequestSchema = wire(z.object({ "acceptable_restrictions": z.array(z.object({ "axis": z.union([z.string().regex(new RegExp("^RESTRICTION_KIND_UNSPECIFIED$")), z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("Which axis (same enum as Restriction.kind): FUNCTION / GEOGRAPHY /\n USER_TYPE / OTHER.").default(0), "values": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("The values the query operates within on this axis — same token vocabulary\n as the terms (e.g. FUNCTION [\"ai-train\"], GEOGRAPHY [\"US\", \"EU\"]).").optional() }).describe("AcceptableRestriction — the limits a query operates within on one restriction\n axis, expressed in the same RestrictionKind vocabulary that terms use. The\n Exchange/Broker MAY pre-select offers whose term restrictions fall within\n these as a convenience (see Restriction); it is NOT enforcement — the agent\n self-selects and bears compliance.")).describe("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.").optional(), "constraints": z.object({ "budget_period": z.string().describe("Budget period (e.g. \"2592000s\" = 30 days; proto-JSON encodes Duration\n as seconds). Resets at period boundary.").optional(), "budget_scope": z.string().describe("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.").optional(), "delivery_preference": z.array(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])).describe("Preferred delivery methods, in order of preference.").optional(), "exchanges": z.array(z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260)).describe("Authorized Exchange domains, in the shape \"Request recipient\" defines in the\n file header. Broker queries only these. This is a FILTER over third parties,\n not an address — the recipient of the request carrying it is a separate\n question.").optional(), "max_data_age": z.string().describe("Maximum acceptable age of resource data. The Broker SHOULD\n exclude offers where (now - Offer.data_as_of) exceeds this duration.\n\nOnly 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\"").optional(), "max_hops": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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).").optional(), "max_price": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Maximum price the agent is willing to pay.").optional(), "max_unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Maximum effective cost per unit, as an exact decimal string (not a float).").optional(), "period_budget": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Per-period budget limit. The Broker tracks spend against this\n for the budget_scope. Transactions that would exceed are denied.").optional(), "preferred_exchanges": z.array(z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260)).describe("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.").optional(), "reporting_capable": z.boolean().describe("Whether the agent supports post-usage reporting.").optional() }).describe("Constraints for exchange filtering and offer selection.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "query": z.string().describe("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).").optional(), "requester": z.object({ "delegation": z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this delegation expires. Exchange MUST reject expired tokens.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "issuer": z.string().describe("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.").optional(), "max_accesses": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "max_spend_cents": z.coerce.number().int().describe("Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.").optional(), "principal_domain": z.string().describe("Who granted this delegation (domain for public key lookup).").default(""), "principal_id": z.string().describe("Principal's identifier (e.g., \"user@acme.com\", \"marketdata.example.com\").").default(""), "quota_period": z.string().describe("Quota reset period. How often the access/spend counters reset.\n Example: 30 days for monthly subscriptions — \"2592000s\" on the wire\n (proto-JSON encodes Duration as seconds; \"720h\" is not accepted).\n When absent, the quota is lifetime (bounded only by expires_at).").optional(), "revocation_uri": z.string().describe("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).").optional(), "scopes": z.array(z.string()).describe("Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).").optional(), "token": z.string().regex(new RegExp("^[A-Za-z0-9+/]*={0,2}$")).describe("Token bytes. A JWT (base64url-encoded JWS).").default(""), "token_format": z.string().describe("Token format: \"jwt\" (default). Empty is treated as \"jwt\". The field stays\n open for a future format.").default("") }).describe("Optional delegation — present when the requester acts on behalf of\n another entity (user, organization, upstream agent).").optional(), "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Domain the requester belongs to — used for public key lookup, so the value\n is concatenated into a URL the verifier fetches ({domain}/.well-known/ramp.json,\n WellKnownManifest with role=ROLE_AGENT). It carries the same bare-host shape\n \"Request recipient\" defines in the file header, for the same structural\n reason: a scheme, path or query smuggled in here would choose what gets\n fetched, not merely from where."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "id": z.string().describe("Unique requester identifier (e.g., \"agent-research-bot-001\").").default(""), "name": z.string().describe("Human-readable name (e.g., \"Acme Research Assistant\").").optional(), "scopes": z.array(z.string()).max(64).describe("Entitlement scopes. Declare what the requester can access.\n\nThe 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).").optional(), "type": z.enum(["REQUESTER_TYPE_AGENT","REQUESTER_TYPE_HUMAN_TOOL","REQUESTER_TYPE_SERVICE","REQUESTER_TYPE_DELEGATED","REQUESTER_TYPE_RESEARCH"]).describe("What kind of entity is making this request.") }).describe("Requester identity — who is making this request, what scopes they have.\n The Broker forwards this to Exchanges in ResourceQuery.requester.").optional(), "search_filters": z.record(z.string(), z.any()).describe("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.").optional(), "supported_profiles": z.array(z.string()).describe("Domain extension profiles the agent understands.\n\nThe 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").optional(), "uris": z.array(z.string()).max(256).describe("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.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("DiscoveryRequest — Agent sends to Broker (Step 1).")); -export const DiscoveryResponseSchema = wire(z.object({ "absence_reason": z.enum(["OFFER_ABSENCE_REASON_NOT_IN_CATALOG","OFFER_ABSENCE_REASON_CONTENT_BLOCKED","OFFER_ABSENCE_REASON_RESTRICTION_FILTERED","OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE","OFFER_ABSENCE_REASON_NOT_AUTHORIZED","OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT","OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION","OFFER_ABSENCE_REASON_BUDGET_EXCEEDED"]).describe("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.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "offer_groups": z.array(z.object({ "absence_reason": z.enum(["OFFER_ABSENCE_REASON_NOT_IN_CATALOG","OFFER_ABSENCE_REASON_CONTENT_BLOCKED","OFFER_ABSENCE_REASON_RESTRICTION_FILTERED","OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE","OFFER_ABSENCE_REASON_NOT_AUTHORIZED","OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT","OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION","OFFER_ABSENCE_REASON_BUDGET_EXCEEDED"]).describe("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.").optional(), "discovery_method": z.enum(["DISCOVERY_METHOD_EXCHANGE","DISCOVERY_METHOD_SEARCH","DISCOVERY_METHOD_RECOMMENDATION","DISCOVERY_METHOD_SYNDICATION"]).describe("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.").optional(), "offers": z.array(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().describe("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.").default(""), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("A provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("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": z.string().describe("Algorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("The Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().describe("CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.").default(""), "signature_algorithm": z.string().describe("JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("Analogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("One LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional() }).describe("Combines pricing, delivery method, resource identity, and reporting terms.\n CoMP-specific metadata (Package, Function) available via ramp-comp-v1 extension profile.")).describe("Zero or more offers for this URI. Empty = resource not available.").optional(), "restriction_filters": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("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.").optional(), "uri": z.string().describe("The URI this group of offers is for (echoed from ResourceQuery.uris).").default("") }).describe("OfferGroup — Offers for a single requested URI.\n Enables multi-URI batch queries where the caller needs to know\n which offers correspond to which requested resource.")).describe("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.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("Carries discovery results only: the offers the Broker gathered across\n Exchanges, grouped by the URI they were requested for. Committing to an offer\n is a separate exchange on the execute path; that per-transaction result\n (transaction_id, billing_id, cost, delivery_method, retrieval endpoint, …)\n is returned by TransactionResponse, not here.")); +export const DiscoveryResponseSchema = wire(z.object({ "absence_reason": z.enum(["OFFER_ABSENCE_REASON_NOT_IN_CATALOG","OFFER_ABSENCE_REASON_CONTENT_BLOCKED","OFFER_ABSENCE_REASON_RESTRICTION_FILTERED","OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE","OFFER_ABSENCE_REASON_NOT_AUTHORIZED","OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT","OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION","OFFER_ABSENCE_REASON_BUDGET_EXCEEDED"]).describe("Why the resolve produced no offers at all. Set (and offer_groups empty) on a\n successful \"no result\" answer; unset when offer_groups is non-empty. Same\n vocabulary DiscoverResources uses for OfferGroup.absence_reason.\n RESTRICTION_FILTERED may appear here, but Resolve does not surface the\n per-axis detail: DiscoveryResponse has no restriction_filters companion\n (unlike OfferGroup). A consumer needing the filtered axes calls\n DiscoverResources.\n 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.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "offer_groups": z.array(z.object({ "absence_reason": z.enum(["OFFER_ABSENCE_REASON_NOT_IN_CATALOG","OFFER_ABSENCE_REASON_CONTENT_BLOCKED","OFFER_ABSENCE_REASON_RESTRICTION_FILTERED","OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE","OFFER_ABSENCE_REASON_NOT_AUTHORIZED","OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT","OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION","OFFER_ABSENCE_REASON_BUDGET_EXCEEDED"]).describe("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.").optional(), "discovery_method": z.enum(["DISCOVERY_METHOD_EXCHANGE","DISCOVERY_METHOD_SEARCH","DISCOVERY_METHOD_RECOMMENDATION","DISCOVERY_METHOD_SYNDICATION"]).describe("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.").optional(), "offers": z.array(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing."), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("ResourceAttestation — Signed envelope of claims from a trusted party.\n\nA provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("Signed attestations about the resource at this URI.\n Attestations provide cryptographic proof of\n resource properties from trusted parties (providers or verification vendors).\n\nThree 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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("When the offered data was current. For dynamic resources\n (resource_mutability = DYNAMIC), this is the snapshot timestamp.\n Enables the Broker to evaluate freshness: \"this credit report\n reflects data as of March 18\" or \"this drug database was updated today.\"\n\nNot 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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("C2PA content credentials manifest URI.\n Points to a sidecar or embedded C2PA manifest for this resource.\n C2PA-aware agents MAY follow this URI to validate the full provenance\n chain (creator identity, transformation history, ingredient composition)\n using C2PA libraries (JUMBF/COSE Sign1). C2PA-unaware agents can rely\n on c2pa_status and c2pa-bridged attestation claims instead.\n\nFormats:\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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("Summary validation status of the C2PA manifest.\n Populated by the Exchange or a verification vendor after validating\n the C2PA manifest. Enables agents to filter for provenance-verified\n content without parsing JUMBF/COSE themselves.\n 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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("Hash of the content. Interpretation depends on hash_method:\n \"simhash-v1\" → locality-sensitive hash, for fuzzy dedup (Level 1)\n \"sha256\" → exact-match integrity hash (Level 2)\n\nLevel 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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Signals whether this resource's content is stable, changes over time,\n or does not exist at offer time (live streaming).\n 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 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": z.string().describe("Soft binding hash — content-derived identifier that survives format\n transcoding (resolution changes, compression, PDF-to-text extraction).\n Extracted from C2PA soft binding assertion when present.\n Enables post-delivery verification when the hard binding hash breaks\n due to legitimate format conversion.\n\nAlgorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("Preview — Lightweight resource preview for offer evaluation.\n\nThe Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("Lightweight previews for offer evaluation.\n The Exchange holds URLs (50–200 bytes each); the provider's CDN serves\n the actual bytes. Agents fetch previews only when evaluating offers —\n not on every discovery query. Multiple previews at different sizes\n allow agents to pick the cheapest fetch for their evaluation needs.\n\nPer 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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("REQUIRED. A DETACHED Ed25519 signature over the canonical serialization of\n the ENTIRE Offer, carried as the raw 64 signature bytes in lowercase or\n uppercase hex — 128 hex characters, NOT a JWS. There is no JOSE header and\n no compact serialization here: the signed bytes are defined by the\n canonical-signing recipe below, and this field carries only the signature\n itself. `signature_algorithm` names the algorithm separately.\n Same convention as `AgentAcceptance.signature`, and it is what the admin\n plane's offline verification recipe replays. The hex shape is STATED here\n and ENFORCED there: this field carries no schema rule, while\n ramp.admin.v1.TransactionEvidence.offer_sig — the same value, copied into\n the evidence row — is pattern-bound to 128 hex characters.\n\nThe signature covers every field, including `pricing`, `terms` (the full\n licensing payload), `expires_at`, and `exchange`. Only `signature` and\n `signature_algorithm` are excluded from the signed bytes. `expires_at` is\n signed so the offer's validity window is integrity-protected: a relaying\n Broker cannot extend (or shorten) the TTL of a signed offer to replay it\n outside the window the Exchange intended.\n\n CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.\n\n The rule is the hex shape this comment already describes: 128 characters,\n either case, which is one Ed25519 signature (64 bytes) hex-encoded. Either\n case is accepted because hex decoding accepts both and a dispute should\n read the same characters a request log holds; every SDK in this repo emits\n lowercase. The pattern also makes the field mandatory in practice — the\n empty string does not match it — which restates what this message already\n requires: an unsigned Offer is not an Offer, since the signature is what\n makes its terms, pricing and expiry non-repudiable."), "signature_algorithm": z.string().describe("Signature algorithm. Always 'EdDSA' — the JOSE algorithm identifier for\n Ed25519 (RFC 8032). Only the identifier is borrowed from JOSE: `signature`\n is a detached hex signature, not a JWS.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("SubscriptionQuotaInfo — Proactive quota signaling for subscription access.\n\nAnalogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent's delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:[\"subscription:...\"].\n\nCoverage 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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("LicenseTerm — Universal licensing unit.\n\nOne LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "title": z.string().describe("Resource title (human-readable, for display/logging).").optional() }).describe("Offer — A single resource offer from an Exchange.\n\nCombines pricing, delivery method, resource identity, and reporting terms.\n CoMP-specific metadata (Package, Function) available via ramp-comp-v1 extension profile.")).describe("Zero or more offers for this URI. Empty = resource not available.").optional(), "restriction_filters": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("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.").optional(), "uri": z.string().describe("The URI this group of offers is for (echoed from ResourceQuery.uris).").default("") }).describe("OfferGroup — Offers for a single requested URI.\n Enables multi-URI batch queries where the caller needs to know\n which offers correspond to which requested resource.")).describe("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.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("DiscoveryResponse — Broker returns to Agent (Step 6).\n\nCarries discovery results only: the offers the Broker gathered across\n Exchanges, grouped by the URI they were requested for. Committing to an offer\n is a separate exchange on the execute path; that per-transaction result\n (transaction_id, billing_id, cost, delivery_method, retrieval endpoint, …)\n is returned by TransactionResponse, not here.")); export const DisputeFailureSchema = wire(z.object({ "reason": z.enum(["DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUND","DISPUTE_FAILURE_REASON_REPORT_NOT_FILED","DISPUTE_FAILURE_REASON_WINDOW_EXPIRED","DISPUTE_FAILURE_REASON_DUPLICATE","DISPUTE_FAILURE_REASON_INELIGIBLE"]).describe("The failure reason (defined-only, non-zero)") }).describe("DisputeFailure — a dispute could not be filed.")); @@ -50,7 +50,7 @@ export const DisputeFailureReasonSchema = wire(z.enum(["DISPUTE_FAILURE_REASON_T export const DisputeReasonSchema = wire(z.enum(["DISPUTE_REASON_CONTENT_MISMATCH","DISPUTE_REASON_DELIVERY_FAILED","DISPUTE_REASON_WRONG_CONTENT","DISPUTE_REASON_EXPIRED_BEFORE_FETCH","DISPUTE_REASON_INCOMPLETE_CONTENT"])); -export const DisputeRequestSchema = wire(z.object({ "billing_id": z.string().describe("Billing record identifier from the disputed transaction\n (TransactionResultItem.billing_id).").default(""), "description": z.string().describe("Human-readable description of the issue.").optional(), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the recipient this request is addressed to (e.g.\n \"exchange.example\" or \"exchange.example:8081\"). See \"Request recipient\" in\n the file header. The dispute's subject identifiers above cannot stand in for\n it: transaction_id, billing_id and report_id are opaque and Exchange-scoped,\n so verifying one means a database lookup, while the recipient check must run\n before any lookup happens."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "idempotency_key": z.string().min(1).max(255).describe("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": z.enum(["DISPUTE_REASON_CONTENT_MISMATCH","DISPUTE_REASON_DELIVERY_FAILED","DISPUTE_REASON_WRONG_CONTENT","DISPUTE_REASON_EXPIRED_BEFORE_FETCH","DISPUTE_REASON_INCOMPLETE_CONTENT"]).describe("Reason for the dispute."), "received_content_hash": z.string().describe("Evidence: content hash of what was actually received.\n Exchange compares against the hash promised in ResourceIdentity.").optional(), "received_hash_method": z.string().describe("Hash algorithm the agent used").optional(), "report_id": z.string().describe("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.").default(""), "transaction_id": z.string().describe("Transaction being disputed.").default(""), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("DisputeRequest — Agent signals a problem with delivered resource.")); +export const DisputeRequestSchema = wire(z.object({ "billing_id": z.string().describe("Billing record identifier from the disputed transaction\n (TransactionResultItem.billing_id).").default(""), "description": z.string().describe("Human-readable description of the issue.").optional(), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the recipient this request is addressed to (e.g.\n \"exchange.example\" or \"exchange.example:8081\"). See \"Request recipient\" in\n the file header. The dispute's subject identifiers above cannot stand in for\n it: transaction_id, billing_id and report_id are opaque and Exchange-scoped,\n so verifying one means a database lookup, while the recipient check must run\n before any lookup happens."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "idempotency_key": z.string().min(1).max(255).describe("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\nDEDUPE SCOPE. The invariant is the one stated on\n TransactionRequest.idempotency_key, and the namespace is the same as\n UsageReport's and for the same reason: this message carries no acceptance\n payload, so the namespace is the TRANSACTION being disputed and the server\n dedupes per (transaction_id, key). The \"who may file\" rule stated there\n applies here unchanged, and for the same reason — a shared slot needs an\n explicit rule about who may write into it."), "reason": z.enum(["DISPUTE_REASON_CONTENT_MISMATCH","DISPUTE_REASON_DELIVERY_FAILED","DISPUTE_REASON_WRONG_CONTENT","DISPUTE_REASON_EXPIRED_BEFORE_FETCH","DISPUTE_REASON_INCOMPLETE_CONTENT"]).describe("Reason for the dispute."), "received_content_hash": z.string().describe("Evidence: content hash of what was actually received.\n Exchange compares against the hash promised in ResourceIdentity.").optional(), "received_hash_method": z.string().describe("Hash algorithm the agent used").optional(), "report_id": z.string().describe("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.").default(""), "transaction_id": z.string().min(1).describe("Transaction being disputed. MUST be non-empty. It is also the dedupe\n namespace for `idempotency_key` above, so a filing that names no\n transaction has no namespace to dedupe within — the rule below is what\n makes that namespace exist, not a shape preference. Same rule as\n ramp.v1.UsageReport.transaction_id, for the same reason."), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("DisputeRequest — Agent signals a problem with delivered resource.")); export const DisputeResponseSchema = wire(z.object({ "dispute_id": z.string().describe("Exchange-assigned dispute case identifier.").optional(), "estimated_resolution": z.string().describe("Expected resolution timeline.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "resolution": z.enum(["RESOLUTION_TYPE_CREDIT","RESOLUTION_TYPE_REDELIVERY","RESOLUTION_TYPE_REJECTED","RESOLUTION_TYPE_INVESTIGATION"]).describe("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.).").optional(), "status": z.union([z.string().regex(new RegExp("^DISPUTE_STATUS_UNSPECIFIED$")), z.enum(["DISPUTE_STATUS_FILED","DISPUTE_STATUS_AUTO_RESOLVED","DISPUTE_STATUS_EVIDENCE_NEEDED","DISPUTE_STATUS_UNDER_REVIEW","DISPUTE_STATUS_ESCALATED","DISPUTE_STATUS_RESOLVED","DISPUTE_STATUS_APPEALED","DISPUTE_STATUS_SETTLED","DISPUTE_STATUS_FINAL"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("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.").default(0), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("DisputeResponse — Exchange acknowledges the dispute.")); @@ -74,31 +74,37 @@ export const GetAccountStatusRequestSchema = wire(z.object({ "exchange": z.strin export const GetAccountStatusResponseSchema = wire(z.object({ "active": z.boolean().describe("Whether the account is currently active.").default(false), "billing_ref": z.string().describe("The account handle minted at registration (see RegisterResponse.billing_ref).\n Empty when the calling agent has no account yet.").default(""), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("GetAccountStatusResponse — Exchange reports the account's current state.")); +export const GetTransactionEvidenceRequestSchema = wire(z.object({ "tenant_id": z.string().min(1).max(255).describe("The tenant the transaction must belong to — the second half of the\n selector, matched against TransactionEvidence.tenant_id. Required:\n counterparty agents legitimately hold transaction ids, so the id alone\n must not be enough to read the row. Naming the tenant narrows what a\n leaked id is worth; it does not authenticate the caller, and nothing on\n this plane does. A mismatch is NOT_FOUND,\n byte-identical to an unknown transaction_id, so existence under another\n tenant is not revealed. Same rule as\n ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated)."), "transaction_id": z.string().min(1).max(255).describe("The transaction whose evidence row to fetch. Same rule as\n ramp.admin.v1.TransactionEvidence.transaction_id (drift-gated) — the row\n identity this request selects by."), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in ramp.proto.").default("") })); + +export const GetTransactionEvidenceResponseSchema = wire(z.object({ "evidence": z.object({ "agent_acceptance_canonical_bytes": z.string().regex(new RegExp("^(?:[A-Za-z0-9+/]{4}(?:[A-Za-z0-9+/]{4})*|[A-Za-z0-9+/]{2}(?:[A-Za-z0-9+/]{4})*(?:==)?|[A-Za-z0-9+/]{3}(?:[A-Za-z0-9+/]{4})*=?|[A-Za-z0-9_-]{4}(?:[A-Za-z0-9_-]{4})*|[A-Za-z0-9_-]{2}(?:[A-Za-z0-9_-]{4})*(?:==)?|[A-Za-z0-9_-]{3}(?:[A-Za-z0-9_-]{4})*=?)$")).min(2).describe("Verbatim JCS bytes of the AgentAcceptancePayload the agent signed.\n Unbounded for the same reason as offer_canonical_bytes. Same rule as\n ramp.admin.v1.TransactionEvidence.offer_canonical_bytes (drift-gated)."), "agent_acceptance_signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("The agent's Ed25519 signature over agent_acceptance_canonical_bytes,\n hex-encoded verbatim as it arrived on the wire (either case). Same rule\n as ramp.v1.AgentAcceptance.signature (drift-gated) — the live field this\n row stores a copy of.\n\nBoth directives here point UPSTREAM into ramp.v1, which they did not\n always do. This pattern was pinned on the read plane first, while the\n agent plane still described the hex shape in prose and enforced nothing;\n the anchors sat inside this package because there was no upstream rule to\n point at. ramp.v1 now carries the rule on both signature fields, so the\n gate compares the two planes against each other and a future tightening\n on one side can no longer leave the other silently behind."), "agent_acceptance_signature_algorithm": z.literal("EdDSA").describe("Signing-algorithm label, server-derived (see offer_sig_algorithm).\n Pinned to \"EdDSA\". Same rule as\n ramp.admin.v1.TransactionEvidence.offer_sig_algorithm (drift-gated)."), "agent_directory_url": z.string().regex(new RegExp("^$|^https://[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?/[!-~]*$")).max(512).describe("The anchored well-known directory agent_public_key was pinned from. The\n registry overwrites keys in place on rotation and keeps no history, so\n this — plus created_at — attests where and when this Exchange obtained\n the key. Empty when the agent carries no directory anchor: an append-once\n row states a value for every column, so '' is a stated fact, not a gap.\n\nPROVENANCE, NOT AUTHORITY. This field is covered by neither signature and\n is written by the same party as the rest of the row, so it can never\n establish that agent_public_key is authentic — see TRUST BOUNDARY above,\n which says where the agent anchor must come from instead. Verification\n tooling MUST NOT treat this value as a fetch target it can trust: the row\n author chose it, so following it hands them the choice of what the\n \"independent\" copy says.\n\n The rules below bound the damage from tooling that follows the field\n anyway; they do not make following it safe. The value must be '' or an\n https URL whose host uses the same recipient-host grammar as\n ramp.v1.Offer.exchange, with an optional port and an ASCII-printable path,\n within 512 bytes. Stated precisely, because a rule that sounds stronger\n than it is would be worse than none: this refuses a plaintext or non-http\n scheme, embedded userinfo or whitespace, and anything that is not a\n host-plus-path shape. It does NOT refuse an IPv4-literal host — the\n recipient-host grammar admits all-numeric labels, so https://169.254.169.254/\n matches. Blocking link-local and private address space is the fetching\n tool's job, and it is one more reason this field is not a fetch target.\n\n Named directory, not discovery: ramp.v1 uses \"discovery\" for RESOURCE\n discovery (DiscoveryRequest, OfferGroup.discovery_method), a different\n thing entirely. This is the agent's well-known directory document, which\n is what every sentence describing the field already calls it.").default(""), "agent_public_key": z.string().regex(new RegExp("^(?:[A-Za-z0-9+/]{43}=?|[A-Za-z0-9_-]{43}=?)$")).min(43).max(44).describe("The registry-pinned agent verifying key (raw 32-byte Ed25519) the\n acceptance verified against. This is the ACCEPTANCE key, which is the\n agent identity for the transaction — ramp.v1.AgentAcceptance defines that\n normatively under \"Agent identity\", and this row stores the key that\n definition names. It is deliberately NOT the transport signer: a Broker\n may author a re-packaged execute as sender, so the RFC 9421 signer on that\n leg is the broker, and a row anchored on it would name the wrong party.\n Same rule as\n ramp.admin.v1.TransactionEvidence.exchange_signing_public_key\n (drift-gated)."), "broker": z.string().regex(new RegExp("^$|^[!-~]+$")).max(255).describe("The relay hop that presented this request to the Exchange, if the\n Exchange records one. A transport fact the Exchange observed, covered by\n neither signature — which is why it sits in this section and not on\n TransactionState: TransactionState projects transaction-log columns, and\n broker routing is an execute-time observation about the connection, not\n a property of the transaction's operational state.\n\nThree states, and the `optional` keyword is what makes them distinct:\n ABSENT means this Exchange does not record routing at all; '' means it\n does record it AND the acceptance arrived direct; a value means it\n arrived through that hop. Without explicit presence the field would\n default to '', so an Exchange with nothing to say would state \"arrived\n direct\" for every row — a forensic plane asserting a transport fact it\n never observed.\n\n WHAT THE VALUE IS: implementation-defined provenance for the outermost\n hop, not a resolvable identity. The reference Exchange serves the\n verified RFC 7638 key thumbprint of the hop that presented the request.\n It deliberately does not resolve that key to a directory host: the relay\n hop is not re-identified against any registry, and the recipient tenant's\n own relay-permission setting is the gate instead. So a reader may compare\n this value for equality and may check it against a thumbprint it already\n holds, but must not expect a hostname, and must not treat it as an\n identity the Exchange vouched for. Only the outermost hop is classified;\n per-hop identity for a longer chain is out of scope here.\n\n The rule bounds the SHAPE without pinning the format. A ledger renders\n this value, so an unbounded string here would re-open on a new field\n exactly the surface request_id's printable-ASCII bound closes — control\n characters, terminal escapes and newlines reaching a rendered forensic\n row. Printable ASCII and 255 characters admit every provenance form a\n server might reasonably record (a thumbprint, a host, an opaque id) while\n refusing the shapes that only matter to a renderer. It is deliberately\n NOT a thumbprint pattern: the value is implementation-defined, and a\n format rule here could invalidate a row for a transaction that\n legitimately executed under a server that spells it some other way — the\n requester_id reasoning. The pattern admits the EMPTY string explicitly,\n because '' is one of the three states — recorded, and the acceptance\n arrived direct. A bare ^[!-~]+$ would need at least one character and\n would delete that state, leaving absence to mean both \"not recorded\" and\n \"arrived direct\". Same alternation shape agent_directory_url uses above,\n for the same reason.").optional(), "created_at": z.string().datetime({ offset: true }).describe("When the Exchange wrote this row (server clock)."), "exchange_signing_public_key": z.string().regex(new RegExp("^(?:[A-Za-z0-9+/]{43}=?|[A-Za-z0-9_-]{43}=?)$")).min(43).max(44).describe("The Exchange verifying key itself (raw 32-byte Ed25519), not a key id."), "offer_canonical_bytes": z.string().regex(new RegExp("^(?:[A-Za-z0-9+/]{4}(?:[A-Za-z0-9+/]{4})*|[A-Za-z0-9+/]{2}(?:[A-Za-z0-9+/]{4})*(?:==)?|[A-Za-z0-9+/]{3}(?:[A-Za-z0-9+/]{4})*=?|[A-Za-z0-9_-]{4}(?:[A-Za-z0-9_-]{4})*|[A-Za-z0-9_-]{2}(?:[A-Za-z0-9_-]{4})*(?:==)?|[A-Za-z0-9_-]{3}(?:[A-Za-z0-9_-]{4})*=?)$")).min(2).describe("Verbatim JCS bytes the Exchange's signature was computed over (the offer\n with its signature fields cleared). min_len only, no ceiling: same\n rationale as offer_json — the bytes under the signature are whatever size\n the signed offer was, and a bound could invalidate a legitimate row."), "offer_id": z.string().min(1).describe("The signed Offer.offer_id (which IS the catalog resource_id). Duplicated\n from the offer JSON so the row reads standalone, without parsing it."), "offer_json": z.string().min(1).describe("The signed offer as a raw JSON string, for query and human audit.\n Deliberately NOT a Struct: a Struct re-normalizes, and the canonical\n bytes below remain the arbiter of what was signed. No upper bound, unlike\n this file's 255-capped ids: upstream ramp.v1 places no size bound on an\n offer, and the row must state whatever the parties actually signed — a\n cap here could make the row fail its own validation for a transaction\n that legitimately executed (the requester_id rationale)."), "offer_sig": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("The Exchange's Ed25519 signature over offer_canonical_bytes, hex-encoded\n in the verbatim wire form (either case — hex decoding accepts both, and a\n dispute should read the same characters a request log holds). Named after\n ramp.v1.AgentAcceptancePayload.offer_sig: it is the same value, the one\n the agent's acceptance binds to. Same rule as ramp.v1.Offer.signature\n (drift-gated) — the field this row stores a copy of."), "offer_sig_algorithm": z.literal("EdDSA").describe("Signing-algorithm label, server-derived from the Exchange's own verify\n path — never echoed from the wire. The canonical payload clears the wire\n labels before signing, so an echoed label would sit outside signature\n coverage and could claim anything under an otherwise valid signature.\n Pinned to \"EdDSA\" — the content-signature label\n ramp.v1.Offer.signature_algorithm pins; \"ed25519\" is the separate label\n reserved for RFC 9421 HTTP request signatures and never appears here.\n const (not min_len) so a generated client also rejects a claimed \"none\"\n or \"HS256\".\n\nSpelled sig_algorithm, not signature_algorithm, which is how ramp.v1 and\n the sibling agent_acceptance_signature_algorithm spell it. The short form\n is INHERITED, not chosen: this label names the neighbouring offer_sig, and\n that field copies an upstream field name verbatim\n (ramp.v1.AgentAcceptancePayload.offer_sig). A label that renamed the field\n it describes would be the worse inconsistency.\n\n The long spelling is also not available: ramp.v1 retired a scalar\n offer-signature field (the execute request now reflects the\n full signed Offer instead), and scripts/check-doc-conformance.sh bans that\n identifier across the protos and the docs so the removed name cannot be\n read as live anywhere. A field named after it here would either fail that\n gate or force it open."), "request_correlation": z.object({ "minted": z.boolean().describe("Provenance: true = the id is SERVER-DERIVED, false = propagated verbatim\n from a caller-supplied header. True covers both ways a server derives\n one — the header was absent, or it was present but nonconforming and was\n replaced — because the property this flag exists for is INFLUENCE, not\n origin story: false means a caller chose these characters, true means no\n caller did. The two are byte-indistinguishable in request_id alone, so a\n forensic read needs this flag to tell a server-derived correlation key\n from an attacker-influenceable one.").default(false), "request_id": z.string().regex(new RegExp("^[!-~]+$")).min(1).max(255).describe("The correlation id as persisted. GOVERNING INVARIANT, established on the\n WRITE path: a persisted request_id always conforms to the rules below —\n printable ASCII, 1..255 — so a present value has already passed the check\n on the way in, and these rules are not a read-side filter over a laxer\n stored value. HOW a server reaches that invariant is its own choice, and\n two mechanisms both conform: reject the nonconforming header and record a\n server-derived id in its place (minted = true), or record no correlation\n at all (the wrapping message stays absent). The first keeps a correlation\n key for a request whose header was bad, the second states that nothing\n trustworthy arrived; neither can put a nonconforming value in the store,\n which is the only property this contract needs. A server that accepts a\n narrower charset than the rules below still satisfies the invariant.\n Background, for a reader tracing where the value comes from: a propagated\n id is caller-influenceable, which is what `minted` below exists to record.\n Which component performs the check is deliberately not stated here. It is\n server behaviour, this file cannot gate it, and an earlier revision of this\n comment described a particular SDK's middleware and was made wrong by a\n change to that SDK three commits later.") }).describe("Correlation id joining this row outward to whatever else recorded the\n same X-Request-ID for this execute call, with its provenance. One\n message, not two\n sibling fields: presence of the message is the pairing — id and\n provenance flag arrive together or not at all, a constraint two\n optional siblings could not express without message-level CEL (which\n this file forbids). Absent when the Exchange recorded no correlation id.").optional(), "request_idempotency_key": z.string().min(1).max(255).describe("The REQUEST-level idempotency key the acceptance signs — NOT the derived\n per-item key that TransactionState.idempotency_key carries. Same rule as\n ramp.v1.TransactionRequest.idempotency_key (drift-gated)."), "requester_domain": z.string().describe("The signed Requester.domain, verbatim. Unbounded HERE even though the\n agent plane bounds it — ramp.v1.Requester.domain carries max_len 260 and\n the bare-host pattern. Those rules govern what an Exchange may ACCEPT on\n the way in; they do not govern what this row may STATE after the fact. The\n row's job is to reproduce the bytes the acceptance actually signed, so a\n rule here could make the row fail its own validation for a transaction\n that legitimately executed — one accepted under an earlier rule set, or\n signed by a party that spelled the value some other way. Same conclusion\n as requester_id, reached differently: Requester.id genuinely carries no\n wire rule at all.").default(""), "requester_id": z.string().describe("The acceptance payload's remaining inputs (offer_sig above is the\n fourth), stored so the signed bytes can be independently rebuilt and\n audited rather than merely trusted.\n\nrequester_id is the signed Requester.id VERBATIM — the bytes under the\n agent's signature, never rewritten. It NAMES the same agent as the\n Exchange's canonical agent identity but is not byte-equal to it: a signer\n may spell its directory any way it likes (the deployed identity service\n signs \"scheme://host\"), so the forensic join goes through directory-host\n normalization, not plain equality. No wire rule: the agent plane does not\n constrain Requester.id, and this row states what was signed.").default(""), "tenant_id": z.string().min(1).max(255).describe("The tenant the transaction executed under. The admin plane is\n deployment-scoped (cross-tenant), so the row states its tenant. Same rule\n as ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated)."), "transaction_id": z.string().min(1).max(255).describe("The evidenced transaction (Exchange-minted transaction identity). The\n format is implementation-defined, exactly as in ramp.v1 (the documented\n storage model mints a 26-char ULID). The 255 bound is NEW to this plane —\n ramp.v1 leaves transaction ids unconstrained — and is safe here because\n the Exchange mints the id itself, far below that bound; it exists so the\n selector stays storable and indexable.") }).describe("The append-once evidence row. Required: it exists 1:1 for every found\n transaction — an unknown transaction_id is NOT_FOUND, never an empty\n response."), "obligation_state": z.object({ "consumed_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Reported consumed quantity, in the metering unit from the Offer's\n Pricing — the value the accepted usage report carried. Mirrors\n ramp.v1.Usage.consumed_quantity's wire type (int32, unconstrained)\n exactly: this view must be able to state whatever the report stated,\n and a decimal-string shape here could express values (e.g. \"3.5\") no\n report can produce. Absent until a usage report has been accepted.").optional(), "created_at": z.string().datetime({ offset: true }).describe("When the obligation was minted (the store's CreatedAt)."), "fulfilled_at": z.string().datetime({ offset: true }).describe("When a usage report was ACCEPTED (the store's FulfilledAt) — the same\n event that moves state to OBLIGATION_STATE_FULFILLED. Not \"when a report\n arrived\": a report that arrived and was rejected leaves this absent, and\n the obligation still expires on window_end.").optional(), "state": z.enum(["OBLIGATION_STATE_PENDING","OBLIGATION_STATE_FULFILLED","OBLIGATION_STATE_EXPIRED","OBLIGATION_STATE_WAIVED","OBLIGATION_STATE_BLOCKED"]).describe("Lifecycle state. Always a real persisted state, never UNSPECIFIED.\n Server-output enum: {defined_only, not_in: [0]} — a reader must never\n see a number its schema cannot name. Same rule as\n ramp.v1.TransactionDenial.reason (drift-gated) — the discipline the\n ErrorDetail reason discriminators establish for server-output enums."), "window_end": z.string().datetime({ offset: true }).describe("When the usage report is due (the store's WindowEnd). An absolute\n instant, not the ramp.v1.ReportingObligation.window Duration it was\n derived from: this record states what the store holds, and the store\n resolved the window against created_at when it minted the obligation.") }).describe("The transaction's reporting obligation record, as persisted. The store\n keeps ONE obligation per transaction (keyed on the transaction id,\n transitioning in place — see the storage model), so this is the record\n the Exchange's own reporting path acts on, not a \"latest of several\".\n Absent when the transaction minted none.").optional(), "transaction_state": z.object({ "idempotency_key": z.string().min(1).describe("The transaction's per-item idempotency key as logged. The Exchange\n derives it as TransactionEvidence.request_idempotency_key + \":\" +\n offer_id — unconditionally, single-item requests included — so distinct\n items of a batch dedupe independently, and this value is NEVER byte-equal\n to the request-level key. A ledger joining this row against a log export\n matches on this derived form, not on the bare request key, and it reaches\n the TRANSACTION-side events only: a usage-report event stores the report's\n own idempotency key, because a report addresses a whole transaction and\n has no offer id to derive with. Join a usage report on transaction_id\n instead. No upper\n bound: the derivation appends an id whose length nothing constrains."), "signed_url_expiry": z.string().datetime({ offset: true }).describe("When the signed retrieval URL expires. Named to pair with signed_url_hash\n below, so the two fields describing one minted URL read as a pair and\n neither can be mistaken for a property of the transaction itself. Do not\n read the name as a column name: stores spell this one differently\n (TransactionResultItem.expires_at on the wire, and the reference\n Exchange's transaction log calls the column plainly `expiry`), so a\n ledger joining to a log matches this field by MEANING, not by name.\n signed_url_hash is the one that happens to match a real column name.\n\nAbsent when the transaction minted no signed URL: DELIVERY_METHOD_DIRECT\n returns the resource inline or from the Exchange's own endpoint, so there\n is nothing to expire. DELIVERY_METHOD_INSTRUCTIONS and\n DELIVERY_METHOD_STREAMING both mint one and always carry this field.\n Absence is a stated fact about the delivery method, not missing data: a\n direct delivery has no value to state here, so there is nothing an empty\n value could honestly mean.").optional(), "signed_url_hash": z.string().regex(new RegExp("^(?:[A-Za-z0-9+/]{43}=?|[A-Za-z0-9_-]{43}=?)$")).min(43).max(44).describe("sha256 of the signed retrieval URL — the join key against the transaction\n log's signed_url_hash column, which holds the same digest as 32 raw bytes.\n The join is byte-to-byte; nothing needs normalizing. Text only appears\n when a store is rendered — protojson base64s this field, and a log export\n picks its own spelling — so it is exports, not stores, that a join has to\n reconcile. Hash-only by design: the full URL is a live\n bearer capability until expiry and is deliberately absent from this\n plane (see TransactionEvidence's delivery section). Absent exactly when\n signed_url_expiry is, and for the same reason: no signed URL, nothing to\n hash. The\n `optional` keyword is load-bearing — it gives this scalar explicit\n presence, so protovalidate skips the length rule on an unset value, while\n a PRESENT hash must still be exactly 32 bytes.").optional() }).describe("The transaction-log facts next to it. Required for the same 1:1 reason."), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in ramp.proto.").default("") })); + export const IngestionSourceSchema = wire(z.enum(["INGESTION_SOURCE_RAMP_SITEMAP","INGESTION_SOURCE_RSL","INGESTION_SOURCE_SITEMAP","INGESTION_SOURCE_HTML_CRAWL","INGESTION_SOURCE_CMS_API","INGESTION_SOURCE_MANUAL","INGESTION_SOURCE_CATALOG_API"])); -export const JsonWebKeySchema = wire(z.object({ "alg": z.string().describe("Signing algorithm. RAMP v1.0: MUST be \"EdDSA\".").default(""), "crv": z.string().describe("Curve. RAMP v1.0: MUST be \"Ed25519\".").default(""), "kty": z.string().describe("Key type. RAMP v1.0: MUST be \"OKP\".").default(""), "not_after": z.string().describe("RFC3339 timestamp. Key is invalid at and after this instant\n (strict upper bound).").default(""), "not_before": z.string().describe("RFC3339 timestamp. Key is invalid before this instant.").default(""), "use": z.string().describe("Intended key use. RAMP v1.0: MUST be \"sig\".").default(""), "x": z.string().describe("base64url-encoded 32-byte Ed25519 public key.").default("") }).describe("RAMP v1.0 supports Ed25519 only: kty=\"OKP\", crv=\"Ed25519\", alg=\"EdDSA\".\n Additional curves are a later concern.\n\n Time bounds are RFC3339 strings (sortable, ops-debuggable, avoids the\n JWT nbf/exp collision). At least one key in the served key set (WBAFile.keys)\n MUST have `not_before <= now < not_after`. Verification MUST reject\n signatures whose key falls outside its window.\n\n Keys carry no `kid`: the RFC 9421 keyid is the RFC 7638 JWK Thumbprint,\n computed locally by the verifier. Carrying a kid alongside the thumbprint\n created a drift surface and is removed.")); +export const JsonWebKeySchema = wire(z.object({ "alg": z.string().describe("Signing algorithm. RAMP v1.0: MUST be \"EdDSA\".").default(""), "crv": z.string().describe("Curve. RAMP v1.0: MUST be \"Ed25519\".").default(""), "kty": z.string().describe("Key type. RAMP v1.0: MUST be \"OKP\".").default(""), "not_after": z.string().describe("RFC3339 timestamp. Key is invalid at and after this instant\n (strict upper bound).").default(""), "not_before": z.string().describe("RFC3339 timestamp. Key is invalid before this instant.").default(""), "use": z.string().describe("Intended key use. RAMP v1.0: MUST be \"sig\".").default(""), "x": z.string().describe("base64url-encoded 32-byte Ed25519 public key.").default("") }).describe("JsonWebKey — Inline RFC 7517 JWK object.\n\nRAMP v1.0 supports Ed25519 only: kty=\"OKP\", crv=\"Ed25519\", alg=\"EdDSA\".\n Additional curves are a later concern.\n\n Time bounds are RFC3339 strings (sortable, ops-debuggable, avoids the\n JWT nbf/exp collision). At least one key in the served key set (WBAFile.keys)\n MUST have `not_before <= now < not_after`. Verification MUST reject\n signatures whose key falls outside its window.\n\n Keys carry no `kid`: the RFC 9421 keyid is the RFC 7638 JWK Thumbprint,\n computed locally by the verifier. Carrying a kid alongside the thumbprint\n created a drift surface and is removed.")); -export const KeyRevocationListSchema = wire(z.object({ "as_of": z.string().datetime({ offset: true }).describe("Server's response time (RFC3339, UTC). Consumers use this to detect\n clock skew.").optional(), "revoked": z.array(z.string()).describe("Complete list of revoked key thumbprints (RFC 7638, base64url-no-pad) at\n `as_of`.").optional() }).describe("Snapshot semantics: `revoked` is the complete list of revoked key thumbprints\n (RFC 7638, base64url-no-pad) at `as_of`. Consumers replace their local\n revocation set on each successful poll (no diff protocol). A revoked\n thumbprint stays revoked permanently; once dropped from the list, consumers\n MAY drop it from their local set but the corresponding key SHOULD NOT be\n re-introduced into WBAFile.keys.")); +export const KeyRevocationListSchema = wire(z.object({ "as_of": z.string().datetime({ offset: true }).describe("Server's response time (RFC3339, UTC). Consumers use this to detect\n clock skew.").optional(), "revoked": z.array(z.string()).describe("Complete list of revoked key thumbprints (RFC 7638, base64url-no-pad) at\n `as_of`.").optional() }).describe("KeyRevocationList — Body served at WBAFile.revocation_url.\n\nSnapshot semantics: `revoked` is the complete list of revoked key thumbprints\n (RFC 7638, base64url-no-pad) at `as_of`. Consumers replace their local\n revocation set on each successful poll (no diff protocol). A revoked\n thumbprint stays revoked permanently; once dropped from the list, consumers\n MAY drop it from their local set but the corresponding key SHOULD NOT be\n re-introduced into WBAFile.keys.")); -export const LicenseSchema = wire(z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("License — Identifies the governing license document for a LicenseTerm.")); +export const LicenseSchema = wire(z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("License — Identifies the governing license document for a LicenseTerm.")); -export const LicenseTermSchema = wire(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("One LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")); +export const LicenseTermSchema = wire(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent's delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:[\"subscription:...\"].\n\nCoverage 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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("LicenseTerm — Universal licensing unit.\n\nOne LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")); -export const ObligationSchema = wire(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")); +export const ObligationSchema = wire(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")); export const ObligationKindSchema = wire(z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"])); +export const ObligationStateSchema = wire(z.enum(["OBLIGATION_STATE_PENDING","OBLIGATION_STATE_FULFILLED","OBLIGATION_STATE_EXPIRED","OBLIGATION_STATE_WAIVED","OBLIGATION_STATE_BLOCKED"])); + export const ObligationTriggerSchema = wire(z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"])); -export const OfferSchema = wire(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().describe("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.").default(""), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("A provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("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": z.string().describe("Algorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("The Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().describe("CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.").default(""), "signature_algorithm": z.string().describe("JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("Analogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("One LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional() }).describe("Combines pricing, delivery method, resource identity, and reporting terms.\n CoMP-specific metadata (Package, Function) available via ramp-comp-v1 extension profile.")); +export const OfferSchema = wire(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing."), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("ResourceAttestation — Signed envelope of claims from a trusted party.\n\nA provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("Signed attestations about the resource at this URI.\n Attestations provide cryptographic proof of\n resource properties from trusted parties (providers or verification vendors).\n\nThree 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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("When the offered data was current. For dynamic resources\n (resource_mutability = DYNAMIC), this is the snapshot timestamp.\n Enables the Broker to evaluate freshness: \"this credit report\n reflects data as of March 18\" or \"this drug database was updated today.\"\n\nNot 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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("C2PA content credentials manifest URI.\n Points to a sidecar or embedded C2PA manifest for this resource.\n C2PA-aware agents MAY follow this URI to validate the full provenance\n chain (creator identity, transformation history, ingredient composition)\n using C2PA libraries (JUMBF/COSE Sign1). C2PA-unaware agents can rely\n on c2pa_status and c2pa-bridged attestation claims instead.\n\nFormats:\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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("Summary validation status of the C2PA manifest.\n Populated by the Exchange or a verification vendor after validating\n the C2PA manifest. Enables agents to filter for provenance-verified\n content without parsing JUMBF/COSE themselves.\n 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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("Hash of the content. Interpretation depends on hash_method:\n \"simhash-v1\" → locality-sensitive hash, for fuzzy dedup (Level 1)\n \"sha256\" → exact-match integrity hash (Level 2)\n\nLevel 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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Signals whether this resource's content is stable, changes over time,\n or does not exist at offer time (live streaming).\n 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 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": z.string().describe("Soft binding hash — content-derived identifier that survives format\n transcoding (resolution changes, compression, PDF-to-text extraction).\n Extracted from C2PA soft binding assertion when present.\n Enables post-delivery verification when the hard binding hash breaks\n due to legitimate format conversion.\n\nAlgorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("Preview — Lightweight resource preview for offer evaluation.\n\nThe Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("Lightweight previews for offer evaluation.\n The Exchange holds URLs (50–200 bytes each); the provider's CDN serves\n the actual bytes. Agents fetch previews only when evaluating offers —\n not on every discovery query. Multiple previews at different sizes\n allow agents to pick the cheapest fetch for their evaluation needs.\n\nPer 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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("REQUIRED. A DETACHED Ed25519 signature over the canonical serialization of\n the ENTIRE Offer, carried as the raw 64 signature bytes in lowercase or\n uppercase hex — 128 hex characters, NOT a JWS. There is no JOSE header and\n no compact serialization here: the signed bytes are defined by the\n canonical-signing recipe below, and this field carries only the signature\n itself. `signature_algorithm` names the algorithm separately.\n Same convention as `AgentAcceptance.signature`, and it is what the admin\n plane's offline verification recipe replays. The hex shape is STATED here\n and ENFORCED there: this field carries no schema rule, while\n ramp.admin.v1.TransactionEvidence.offer_sig — the same value, copied into\n the evidence row — is pattern-bound to 128 hex characters.\n\nThe signature covers every field, including `pricing`, `terms` (the full\n licensing payload), `expires_at`, and `exchange`. Only `signature` and\n `signature_algorithm` are excluded from the signed bytes. `expires_at` is\n signed so the offer's validity window is integrity-protected: a relaying\n Broker cannot extend (or shorten) the TTL of a signed offer to replay it\n outside the window the Exchange intended.\n\n CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.\n\n The rule is the hex shape this comment already describes: 128 characters,\n either case, which is one Ed25519 signature (64 bytes) hex-encoded. Either\n case is accepted because hex decoding accepts both and a dispute should\n read the same characters a request log holds; every SDK in this repo emits\n lowercase. The pattern also makes the field mandatory in practice — the\n empty string does not match it — which restates what this message already\n requires: an unsigned Offer is not an Offer, since the signature is what\n makes its terms, pricing and expiry non-repudiable."), "signature_algorithm": z.string().describe("Signature algorithm. Always 'EdDSA' — the JOSE algorithm identifier for\n Ed25519 (RFC 8032). Only the identifier is borrowed from JOSE: `signature`\n is a detached hex signature, not a JWS.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("SubscriptionQuotaInfo — Proactive quota signaling for subscription access.\n\nAnalogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent's delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:[\"subscription:...\"].\n\nCoverage 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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("LicenseTerm — Universal licensing unit.\n\nOne LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "title": z.string().describe("Resource title (human-readable, for display/logging).").optional() }).describe("Offer — A single resource offer from an Exchange.\n\nCombines pricing, delivery method, resource identity, and reporting terms.\n CoMP-specific metadata (Package, Function) available via ramp-comp-v1 extension profile.")); export const OfferAbsenceReasonSchema = wire(z.enum(["OFFER_ABSENCE_REASON_NOT_IN_CATALOG","OFFER_ABSENCE_REASON_CONTENT_BLOCKED","OFFER_ABSENCE_REASON_RESTRICTION_FILTERED","OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE","OFFER_ABSENCE_REASON_NOT_AUTHORIZED","OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT","OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION","OFFER_ABSENCE_REASON_BUDGET_EXCEEDED"])); -export const OfferGroupSchema = wire(z.object({ "absence_reason": z.enum(["OFFER_ABSENCE_REASON_NOT_IN_CATALOG","OFFER_ABSENCE_REASON_CONTENT_BLOCKED","OFFER_ABSENCE_REASON_RESTRICTION_FILTERED","OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE","OFFER_ABSENCE_REASON_NOT_AUTHORIZED","OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT","OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION","OFFER_ABSENCE_REASON_BUDGET_EXCEEDED"]).describe("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.").optional(), "discovery_method": z.enum(["DISCOVERY_METHOD_EXCHANGE","DISCOVERY_METHOD_SEARCH","DISCOVERY_METHOD_RECOMMENDATION","DISCOVERY_METHOD_SYNDICATION"]).describe("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.").optional(), "offers": z.array(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().describe("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.").default(""), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("A provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("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": z.string().describe("Algorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("The Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().describe("CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.").default(""), "signature_algorithm": z.string().describe("JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("Analogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("One LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional() }).describe("Combines pricing, delivery method, resource identity, and reporting terms.\n CoMP-specific metadata (Package, Function) available via ramp-comp-v1 extension profile.")).describe("Zero or more offers for this URI. Empty = resource not available.").optional(), "restriction_filters": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("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.").optional(), "uri": z.string().describe("The URI this group of offers is for (echoed from ResourceQuery.uris).").default("") }).describe("OfferGroup — Offers for a single requested URI.\n Enables multi-URI batch queries where the caller needs to know\n which offers correspond to which requested resource.")); +export const OfferGroupSchema = wire(z.object({ "absence_reason": z.enum(["OFFER_ABSENCE_REASON_NOT_IN_CATALOG","OFFER_ABSENCE_REASON_CONTENT_BLOCKED","OFFER_ABSENCE_REASON_RESTRICTION_FILTERED","OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE","OFFER_ABSENCE_REASON_NOT_AUTHORIZED","OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT","OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION","OFFER_ABSENCE_REASON_BUDGET_EXCEEDED"]).describe("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.").optional(), "discovery_method": z.enum(["DISCOVERY_METHOD_EXCHANGE","DISCOVERY_METHOD_SEARCH","DISCOVERY_METHOD_RECOMMENDATION","DISCOVERY_METHOD_SYNDICATION"]).describe("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.").optional(), "offers": z.array(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing."), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("ResourceAttestation — Signed envelope of claims from a trusted party.\n\nA provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("Signed attestations about the resource at this URI.\n Attestations provide cryptographic proof of\n resource properties from trusted parties (providers or verification vendors).\n\nThree 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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("When the offered data was current. For dynamic resources\n (resource_mutability = DYNAMIC), this is the snapshot timestamp.\n Enables the Broker to evaluate freshness: \"this credit report\n reflects data as of March 18\" or \"this drug database was updated today.\"\n\nNot 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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("C2PA content credentials manifest URI.\n Points to a sidecar or embedded C2PA manifest for this resource.\n C2PA-aware agents MAY follow this URI to validate the full provenance\n chain (creator identity, transformation history, ingredient composition)\n using C2PA libraries (JUMBF/COSE Sign1). C2PA-unaware agents can rely\n on c2pa_status and c2pa-bridged attestation claims instead.\n\nFormats:\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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("Summary validation status of the C2PA manifest.\n Populated by the Exchange or a verification vendor after validating\n the C2PA manifest. Enables agents to filter for provenance-verified\n content without parsing JUMBF/COSE themselves.\n 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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("Hash of the content. Interpretation depends on hash_method:\n \"simhash-v1\" → locality-sensitive hash, for fuzzy dedup (Level 1)\n \"sha256\" → exact-match integrity hash (Level 2)\n\nLevel 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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Signals whether this resource's content is stable, changes over time,\n or does not exist at offer time (live streaming).\n 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 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": z.string().describe("Soft binding hash — content-derived identifier that survives format\n transcoding (resolution changes, compression, PDF-to-text extraction).\n Extracted from C2PA soft binding assertion when present.\n Enables post-delivery verification when the hard binding hash breaks\n due to legitimate format conversion.\n\nAlgorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("Preview — Lightweight resource preview for offer evaluation.\n\nThe Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("Lightweight previews for offer evaluation.\n The Exchange holds URLs (50–200 bytes each); the provider's CDN serves\n the actual bytes. Agents fetch previews only when evaluating offers —\n not on every discovery query. Multiple previews at different sizes\n allow agents to pick the cheapest fetch for their evaluation needs.\n\nPer 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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("REQUIRED. A DETACHED Ed25519 signature over the canonical serialization of\n the ENTIRE Offer, carried as the raw 64 signature bytes in lowercase or\n uppercase hex — 128 hex characters, NOT a JWS. There is no JOSE header and\n no compact serialization here: the signed bytes are defined by the\n canonical-signing recipe below, and this field carries only the signature\n itself. `signature_algorithm` names the algorithm separately.\n Same convention as `AgentAcceptance.signature`, and it is what the admin\n plane's offline verification recipe replays. The hex shape is STATED here\n and ENFORCED there: this field carries no schema rule, while\n ramp.admin.v1.TransactionEvidence.offer_sig — the same value, copied into\n the evidence row — is pattern-bound to 128 hex characters.\n\nThe signature covers every field, including `pricing`, `terms` (the full\n licensing payload), `expires_at`, and `exchange`. Only `signature` and\n `signature_algorithm` are excluded from the signed bytes. `expires_at` is\n signed so the offer's validity window is integrity-protected: a relaying\n Broker cannot extend (or shorten) the TTL of a signed offer to replay it\n outside the window the Exchange intended.\n\n CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.\n\n The rule is the hex shape this comment already describes: 128 characters,\n either case, which is one Ed25519 signature (64 bytes) hex-encoded. Either\n case is accepted because hex decoding accepts both and a dispute should\n read the same characters a request log holds; every SDK in this repo emits\n lowercase. The pattern also makes the field mandatory in practice — the\n empty string does not match it — which restates what this message already\n requires: an unsigned Offer is not an Offer, since the signature is what\n makes its terms, pricing and expiry non-repudiable."), "signature_algorithm": z.string().describe("Signature algorithm. Always 'EdDSA' — the JOSE algorithm identifier for\n Ed25519 (RFC 8032). Only the identifier is borrowed from JOSE: `signature`\n is a detached hex signature, not a JWS.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("SubscriptionQuotaInfo — Proactive quota signaling for subscription access.\n\nAnalogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent's delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:[\"subscription:...\"].\n\nCoverage 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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("LicenseTerm — Universal licensing unit.\n\nOne LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "title": z.string().describe("Resource title (human-readable, for display/logging).").optional() }).describe("Offer — A single resource offer from an Exchange.\n\nCombines pricing, delivery method, resource identity, and reporting terms.\n CoMP-specific metadata (Package, Function) available via ramp-comp-v1 extension profile.")).describe("Zero or more offers for this URI. Empty = resource not available.").optional(), "restriction_filters": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("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.").optional(), "uri": z.string().describe("The URI this group of offers is for (echoed from ResourceQuery.uris).").default("") }).describe("OfferGroup — Offers for a single requested URI.\n Enables multi-URI batch queries where the caller needs to know\n which offers correspond to which requested resource.")); -export const PreviewSchema = wire(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("The Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")); +export const PreviewSchema = wire(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("Preview — Lightweight resource preview for offer evaluation.\n\nThe Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")); -export const PricingSchema = wire(z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("Providers set prices in their preferred model. The Exchange\n normalizes to unit cost (effective cost per unit) for cross-exchange\n comparison — analogous to eCPM in programmatic advertising.\n Unit cost is denominated in the Exchange's base currency.\n\n Fields: model, rate, currency, unit_cost, estimated_quantity,\n license_duration_months, unit, metering.")); +export const PricingSchema = wire(z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("Pricing — Terms for a resource offer.\n\nProviders set prices in their preferred model. The Exchange\n normalizes to unit cost (effective cost per unit) for cross-exchange\n comparison — analogous to eCPM in programmatic advertising.\n Unit cost is denominated in the Exchange's base currency.\n\n Fields: model, rate, currency, unit_cost, estimated_quantity,\n license_duration_months, unit, metering.")); export const PricingMeteringSchema = wire(z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"])); @@ -106,11 +112,11 @@ export const PricingModelSchema = wire(z.enum(["PRICING_MODEL_FREE","PRICING_MOD export const ProviderRelationshipSchema = wire(z.enum(["PROVIDER_RELATIONSHIP_DIRECT","PROVIDER_RELATIONSHIP_RESELLER"])); -export const PushResourcesRequestSchema = wire(z.object({ "caller_id": z.string().describe("Identity of the caller (who is pushing this data).\n The Exchange verifies this matches a registered CatalogService client.").default(""), "entries": z.array(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().describe("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.").default(""), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("A provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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).").optional(), "content_hash": z.string().describe("Content hash").optional(), "content_id": z.string().describe("Content identifier").optional(), "domain": z.string().describe("Provider domain").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Estimated quantity in the metering unit").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm").optional(), "path": z.string().describe("Content path").default(""), "provenance_source": z.string().describe("Who provided this resource metadata. Creates audit trail for\n \"where did this catalog entry come from?\"").optional(), "provenance_timestamp": z.string().datetime({ offset: true }).describe("When this metadata was collected/generated.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Optional mutability hint. When omitted, the Exchange applies the `STATIC`\n default at Offer build; an explicit `UNSPECIFIED` is rejected. A value in\n `ext` is not read — the typed field is authoritative, so an ext-only value\n is treated as omitted. Mirrors the required Offer-side\n `ResourceIdentity.resource_mutability`.").optional(), "source": z.enum(["INGESTION_SOURCE_RAMP_SITEMAP","INGESTION_SOURCE_RSL","INGESTION_SOURCE_SITEMAP","INGESTION_SOURCE_HTML_CRAWL","INGESTION_SOURCE_CMS_API","INGESTION_SOURCE_MANUAL","INGESTION_SOURCE_CATALOG_API"]).describe("How the entry was discovered").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("One LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "word_count": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Word count").optional() })).describe("Content entries to push").optional(), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the recipient this request is addressed to (e.g.\n \"exchange.example\" or \"exchange.example:8081\"). See \"Request recipient\" in\n the file header. Distinct from `tenant_id` above, which names a publisher\n tenant WITHIN an Exchange, not the Exchange itself."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "tenant_id": z.string().describe("Tenant identifier").default(""), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") })); +export const PushResourcesRequestSchema = wire(z.object({ "caller_id": z.string().describe("Identity of the caller (who is pushing this data).\n The Exchange verifies this matches a registered CatalogService client.").default(""), "entries": z.array(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing."), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("ResourceAttestation — Signed envelope of claims from a trusted party.\n\nA provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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).").optional(), "content_hash": z.string().describe("Content hash").optional(), "content_id": z.string().describe("Content identifier").optional(), "domain": z.string().describe("Provider domain").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Estimated quantity in the metering unit").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm").optional(), "path": z.string().describe("Content path").default(""), "provenance_source": z.string().describe("Who provided this resource metadata. Creates audit trail for\n \"where did this catalog entry come from?\"").optional(), "provenance_timestamp": z.string().datetime({ offset: true }).describe("When this metadata was collected/generated.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Optional mutability hint. When omitted, the Exchange applies the `STATIC`\n default at Offer build; an explicit `UNSPECIFIED` is rejected. A value in\n `ext` is not read — the typed field is authoritative, so an ext-only value\n is treated as omitted. Mirrors the required Offer-side\n `ResourceIdentity.resource_mutability`.").optional(), "source": z.enum(["INGESTION_SOURCE_RAMP_SITEMAP","INGESTION_SOURCE_RSL","INGESTION_SOURCE_SITEMAP","INGESTION_SOURCE_HTML_CRAWL","INGESTION_SOURCE_CMS_API","INGESTION_SOURCE_MANUAL","INGESTION_SOURCE_CATALOG_API"]).describe("How the entry was discovered").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent's delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:[\"subscription:...\"].\n\nCoverage 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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("LicenseTerm — Universal licensing unit.\n\nOne LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "title": z.string().describe("Content title").optional(), "word_count": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Word count").optional() })).describe("Content entries to push").optional(), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the recipient this request is addressed to (e.g.\n \"exchange.example\" or \"exchange.example:8081\"). See \"Request recipient\" in\n the file header. Distinct from `tenant_id` above, which names a publisher\n tenant WITHIN an Exchange, not the Exchange itself."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "tenant_id": z.string().describe("Tenant identifier").default(""), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") })); export const PushResourcesResponseSchema = wire(z.object({ "accepted": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Number of entries accepted").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "rejected": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Number of entries rejected").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default(""), "warnings": z.array(z.string()).describe("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.").optional() })); -export const QuotaSchema = wire(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")); +export const QuotaSchema = wire(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")); export const QuotaWindowSchema = wire(z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"])); @@ -136,29 +142,33 @@ export const RemoveResourcesResponseSchema = wire(z.object({ "removed": z.coerce export const ReportingObligationSchema = wire(z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("ReportingObligation — Requirements attached to a delivery.")); -export const ReportingPolicySchema = wire(z.object({ "quantity_tolerance": z.coerce.number().gte(0).lte(1).describe("Accepted relative deviation between estimated and reported quantity, as a\n fraction: 0 requires an exact match, 1 accepts any deviation. Omitted: the\n receiving Exchange's default tolerance applies.").optional(), "required_fields": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(32).refine((arr) => arr.every((item, i) => arr.indexOf(item) == i), "All items must be unique!").describe("Report field names the usage-report validator requires. The wire constrains\n only the token shape; which names are meaningful is defined by the receiving\n Exchange and may change without a contract change. Names are a set: repeats\n are rejected. Empty means no required fields.").optional(), "tenant_id": z.string().min(1).max(255).describe("The tenant whose reporting policy is being replaced."), "window_seconds": z.coerce.number().int().gt(0).lte(31536000).describe("Reporting window in seconds. Applies to obligations minted after this call;\n obligations already issued keep the window they were minted with. Capped at\n one year. Omitted: the receiving Exchange's default applies.").optional() }).describe("ReportingPolicy is the reporting-policy payload shared by\n SetReportingPolicy's request and response. The field rules live here once, so\n the write and the echoed read-back stay in lockstep.")); +export const ReportingObligationStateSchema = wire(z.object({ "consumed_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Reported consumed quantity, in the metering unit from the Offer's\n Pricing — the value the accepted usage report carried. Mirrors\n ramp.v1.Usage.consumed_quantity's wire type (int32, unconstrained)\n exactly: this view must be able to state whatever the report stated,\n and a decimal-string shape here could express values (e.g. \"3.5\") no\n report can produce. Absent until a usage report has been accepted.").optional(), "created_at": z.string().datetime({ offset: true }).describe("When the obligation was minted (the store's CreatedAt)."), "fulfilled_at": z.string().datetime({ offset: true }).describe("When a usage report was ACCEPTED (the store's FulfilledAt) — the same\n event that moves state to OBLIGATION_STATE_FULFILLED. Not \"when a report\n arrived\": a report that arrived and was rejected leaves this absent, and\n the obligation still expires on window_end.").optional(), "state": z.enum(["OBLIGATION_STATE_PENDING","OBLIGATION_STATE_FULFILLED","OBLIGATION_STATE_EXPIRED","OBLIGATION_STATE_WAIVED","OBLIGATION_STATE_BLOCKED"]).describe("Lifecycle state. Always a real persisted state, never UNSPECIFIED.\n Server-output enum: {defined_only, not_in: [0]} — a reader must never\n see a number its schema cannot name. Same rule as\n ramp.v1.TransactionDenial.reason (drift-gated) — the discipline the\n ErrorDetail reason discriminators establish for server-output enums."), "window_end": z.string().datetime({ offset: true }).describe("When the usage report is due (the store's WindowEnd). An absolute\n instant, not the ramp.v1.ReportingObligation.window Duration it was\n derived from: this record states what the store holds, and the store\n resolved the window against created_at when it minted the obligation.") }).describe("ReportingObligationState — the server-side lifecycle record of the\n transaction's reporting obligation, as persisted. Named apart from\n ramp.v1.ReportingObligation, which is the agent-facing requirements\n contract; this is the state those requirements minted.\n\nEVERY field here is backed by a column on the obligation row, with no\n exceptions and no translation step. The timestamp fields carry the store's\n own column names (WindowEnd, FulfilledAt, CreatedAt) in snake_case, so a\n reader can join this record against the storage model by name, and\n consumed_quantity is a column too — written in the same statement as the\n state transition when a report validates.\n\n That completeness is the property worth having, and it is what makes this\n message a projection rather than an assembly. A single field sourced\n elsewhere would mean a reader could not tell, from the message alone, which\n values a server had to go looking for.")); + +export const ReportingPolicySchema = wire(z.object({ "quantity_tolerance": z.coerce.number().gte(0).lte(1).describe("Accepted relative deviation between estimated and reported quantity, as a\n fraction: 0 requires an exact match, 1 accepts any deviation. Omitted: the\n receiving Exchange's default tolerance applies.").optional(), "required_fields": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(32).refine((arr) => arr.every((item, i) => arr.indexOf(item) == i), "All items must be unique!").describe("Report field names the usage-report validator requires. The wire constrains\n only the token shape; which names are meaningful is defined by the receiving\n Exchange and may change without a contract change. Names are a set: repeats\n are rejected. Empty means no required fields.").optional(), "tenant_id": z.string().min(1).max(255).describe("The tenant whose reporting policy is being replaced. Same rule as\n ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated)."), "window_seconds": z.coerce.number().int().gt(0).lte(31536000).describe("Reporting window in seconds. Applies to obligations minted after this call;\n obligations already issued keep the window they were minted with. Capped at\n one year. Omitted: the receiving Exchange's default applies.").optional() }).describe("ReportingPolicy is the reporting-policy payload shared by\n SetReportingPolicy's request and response. The field rules live here once, so\n the write and the echoed read-back stay in lockstep.")); -export const RequestConstraintsSchema = wire(z.object({ "budget_period": z.string().describe("Budget period (e.g. \"2592000s\" = 30 days; proto-JSON encodes Duration\n as seconds). Resets at period boundary.").optional(), "budget_scope": z.string().describe("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.").optional(), "delivery_preference": z.array(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])).describe("Preferred delivery methods, in order of preference.").optional(), "exchanges": z.array(z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260)).describe("Authorized Exchange domains, in the shape \"Request recipient\" defines in the\n file header. Broker queries only these. This is a FILTER over third parties,\n not an address — the recipient of the request carrying it is a separate\n question.").optional(), "max_data_age": z.string().describe("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\"").optional(), "max_hops": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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).").optional(), "max_price": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Maximum price the agent is willing to pay.").optional(), "max_unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Maximum effective cost per unit, as an exact decimal string (not a float).").optional(), "period_budget": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Per-period budget limit. The Broker tracks spend against this\n for the budget_scope. Transactions that would exceed are denied.").optional(), "preferred_exchanges": z.array(z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260)).describe("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.").optional(), "reporting_capable": z.boolean().describe("Whether the agent supports post-usage reporting.").optional() }).describe("RequestConstraints — Budget and preference constraints.")); +export const RequestConstraintsSchema = wire(z.object({ "budget_period": z.string().describe("Budget period (e.g. \"2592000s\" = 30 days; proto-JSON encodes Duration\n as seconds). Resets at period boundary.").optional(), "budget_scope": z.string().describe("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.").optional(), "delivery_preference": z.array(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])).describe("Preferred delivery methods, in order of preference.").optional(), "exchanges": z.array(z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260)).describe("Authorized Exchange domains, in the shape \"Request recipient\" defines in the\n file header. Broker queries only these. This is a FILTER over third parties,\n not an address — the recipient of the request carrying it is a separate\n question.").optional(), "max_data_age": z.string().describe("Maximum acceptable age of resource data. The Broker SHOULD\n exclude offers where (now - Offer.data_as_of) exceeds this duration.\n\nOnly 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\"").optional(), "max_hops": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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).").optional(), "max_price": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Maximum price the agent is willing to pay.").optional(), "max_unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Maximum effective cost per unit, as an exact decimal string (not a float).").optional(), "period_budget": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Per-period budget limit. The Broker tracks spend against this\n for the budget_scope. Transactions that would exceed are denied.").optional(), "preferred_exchanges": z.array(z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260)).describe("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.").optional(), "reporting_capable": z.boolean().describe("Whether the agent supports post-usage reporting.").optional() }).describe("RequestConstraints — Budget and preference constraints.")); -export const RequesterSchema = wire(z.object({ "delegation": z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this delegation expires. Exchange MUST reject expired tokens.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "issuer": z.string().describe("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.").optional(), "max_accesses": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "max_spend_cents": z.coerce.number().int().describe("Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.").optional(), "principal_domain": z.string().describe("Who granted this delegation (domain for public key lookup).").default(""), "principal_id": z.string().describe("Principal's identifier (e.g., \"user@acme.com\", \"marketdata.example.com\").").default(""), "quota_period": z.string().describe("Quota reset period. How often the access/spend counters reset.\n Example: 30 days for monthly subscriptions — \"2592000s\" on the wire\n (proto-JSON encodes Duration as seconds; \"720h\" is not accepted).\n When absent, the quota is lifetime (bounded only by expires_at).").optional(), "revocation_uri": z.string().describe("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).").optional(), "scopes": z.array(z.string()).describe("Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).").optional(), "token": z.string().regex(new RegExp("^[A-Za-z0-9+/]*={0,2}$")).describe("Token bytes. A JWT (base64url-encoded JWS).").default(""), "token_format": z.string().describe("Token format: \"jwt\" (default). Empty is treated as \"jwt\". The field stays\n open for a future format.").default("") }).describe("Optional delegation — present when the requester acts on behalf of\n another entity (user, organization, upstream agent).").optional(), "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Domain the requester belongs to — used for public key lookup, so the value\n is concatenated into a URL the verifier fetches ({domain}/.well-known/ramp.json,\n WellKnownManifest with role=ROLE_AGENT). It carries the same bare-host shape\n \"Request recipient\" defines in the file header, for the same structural\n reason: a scheme, path or query smuggled in here would choose what gets\n fetched, not merely from where."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "id": z.string().describe("Unique requester identifier (e.g., \"agent-research-bot-001\").").default(""), "name": z.string().describe("Human-readable name (e.g., \"Acme Research Assistant\").").optional(), "scopes": z.array(z.string()).max(64).describe("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).").optional(), "type": z.enum(["REQUESTER_TYPE_AGENT","REQUESTER_TYPE_HUMAN_TOOL","REQUESTER_TYPE_SERVICE","REQUESTER_TYPE_DELEGATED","REQUESTER_TYPE_RESEARCH"]).describe("What kind of entity is making this request.") }).describe("Carries identity and entitlements ONLY — who is asking and what they are\n entitled to (scopes, delegation). What they are asking for (uris) and the\n limits they will operate within (acceptable_restrictions) belong to the ask,\n not the identity, and live on ResourceQuery / DiscoveryRequest. The Exchange\n verifies identity via the RFC 9421 request signature, then filters its\n catalog by the requester's scopes. Billing needs nothing from this message:\n the Exchange resolves the caller's account (RegisterResponse.billing_ref)\n from the verified signature, never from anything the caller sends.")); +export const RequestCorrelationSchema = wire(z.object({ "minted": z.boolean().describe("Provenance: true = the id is SERVER-DERIVED, false = propagated verbatim\n from a caller-supplied header. True covers both ways a server derives\n one — the header was absent, or it was present but nonconforming and was\n replaced — because the property this flag exists for is INFLUENCE, not\n origin story: false means a caller chose these characters, true means no\n caller did. The two are byte-indistinguishable in request_id alone, so a\n forensic read needs this flag to tell a server-derived correlation key\n from an attacker-influenceable one.").default(false), "request_id": z.string().regex(new RegExp("^[!-~]+$")).min(1).max(255).describe("The correlation id as persisted. GOVERNING INVARIANT, established on the\n WRITE path: a persisted request_id always conforms to the rules below —\n printable ASCII, 1..255 — so a present value has already passed the check\n on the way in, and these rules are not a read-side filter over a laxer\n stored value. HOW a server reaches that invariant is its own choice, and\n two mechanisms both conform: reject the nonconforming header and record a\n server-derived id in its place (minted = true), or record no correlation\n at all (the wrapping message stays absent). The first keeps a correlation\n key for a request whose header was bad, the second states that nothing\n trustworthy arrived; neither can put a nonconforming value in the store,\n which is the only property this contract needs. A server that accepts a\n narrower charset than the rules below still satisfies the invariant.\n Background, for a reader tracing where the value comes from: a propagated\n id is caller-influenceable, which is what `minted` below exists to record.\n Which component performs the check is deliberately not stated here. It is\n server behaviour, this file cannot gate it, and an earlier revision of this\n comment described a particular SDK's middleware and was made wrong by a\n change to that SDK three commits later.") }).describe("RequestCorrelation — the recorded X-Request-ID correlation for one\n evidence row, with its provenance. See TransactionEvidence\n .request_correlation for why this is a message rather than two sibling\n fields.")); + +export const RequesterSchema = wire(z.object({ "delegation": z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this delegation expires. Exchange MUST reject expired tokens.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "issuer": z.string().describe("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.").optional(), "max_accesses": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "max_spend_cents": z.coerce.number().int().describe("Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.").optional(), "principal_domain": z.string().describe("Who granted this delegation (domain for public key lookup).").default(""), "principal_id": z.string().describe("Principal's identifier (e.g., \"user@acme.com\", \"marketdata.example.com\").").default(""), "quota_period": z.string().describe("Quota reset period. How often the access/spend counters reset.\n Example: 30 days for monthly subscriptions — \"2592000s\" on the wire\n (proto-JSON encodes Duration as seconds; \"720h\" is not accepted).\n When absent, the quota is lifetime (bounded only by expires_at).").optional(), "revocation_uri": z.string().describe("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).").optional(), "scopes": z.array(z.string()).describe("Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).").optional(), "token": z.string().regex(new RegExp("^[A-Za-z0-9+/]*={0,2}$")).describe("Token bytes. A JWT (base64url-encoded JWS).").default(""), "token_format": z.string().describe("Token format: \"jwt\" (default). Empty is treated as \"jwt\". The field stays\n open for a future format.").default("") }).describe("Optional delegation — present when the requester acts on behalf of\n another entity (user, organization, upstream agent).").optional(), "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Domain the requester belongs to — used for public key lookup, so the value\n is concatenated into a URL the verifier fetches ({domain}/.well-known/ramp.json,\n WellKnownManifest with role=ROLE_AGENT). It carries the same bare-host shape\n \"Request recipient\" defines in the file header, for the same structural\n reason: a scheme, path or query smuggled in here would choose what gets\n fetched, not merely from where."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "id": z.string().describe("Unique requester identifier (e.g., \"agent-research-bot-001\").").default(""), "name": z.string().describe("Human-readable name (e.g., \"Acme Research Assistant\").").optional(), "scopes": z.array(z.string()).max(64).describe("Entitlement scopes. Declare what the requester can access.\n\nThe 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).").optional(), "type": z.enum(["REQUESTER_TYPE_AGENT","REQUESTER_TYPE_HUMAN_TOOL","REQUESTER_TYPE_SERVICE","REQUESTER_TYPE_DELEGATED","REQUESTER_TYPE_RESEARCH"]).describe("What kind of entity is making this request.") }).describe("Requester — Universal identity for any RAMP client.\n\nCarries identity and entitlements ONLY — who is asking and what they are\n entitled to (scopes, delegation). What they are asking for (uris) and the\n limits they will operate within (acceptable_restrictions) belong to the ask,\n not the identity, and live on ResourceQuery / DiscoveryRequest. The Exchange\n verifies identity via the RFC 9421 request signature, then filters its\n catalog by the requester's scopes. Billing needs nothing from this message:\n the Exchange resolves the caller's account (RegisterResponse.billing_ref)\n from the verified signature, never from anything the caller sends.")); export const RequesterTypeSchema = wire(z.enum(["REQUESTER_TYPE_AGENT","REQUESTER_TYPE_HUMAN_TOOL","REQUESTER_TYPE_SERVICE","REQUESTER_TYPE_DELEGATED","REQUESTER_TYPE_RESEARCH"])); export const ResolutionTypeSchema = wire(z.enum(["RESOLUTION_TYPE_CREDIT","RESOLUTION_TYPE_REDELIVERY","RESOLUTION_TYPE_REJECTED","RESOLUTION_TYPE_INVESTIGATION"])); -export const ResourceAttestationSchema = wire(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().describe("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.").default(""), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("A provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")); +export const ResourceAttestationSchema = wire(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing."), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("ResourceAttestation — Signed envelope of claims from a trusted party.\n\nA provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")); -export const ResourceEntrySchema = wire(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().describe("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.").default(""), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("A provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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).").optional(), "content_hash": z.string().describe("Content hash").optional(), "content_id": z.string().describe("Content identifier").optional(), "domain": z.string().describe("Provider domain").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Estimated quantity in the metering unit").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm").optional(), "path": z.string().describe("Content path").default(""), "provenance_source": z.string().describe("Who provided this resource metadata. Creates audit trail for\n \"where did this catalog entry come from?\"").optional(), "provenance_timestamp": z.string().datetime({ offset: true }).describe("When this metadata was collected/generated.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Optional mutability hint. When omitted, the Exchange applies the `STATIC`\n default at Offer build; an explicit `UNSPECIFIED` is rejected. A value in\n `ext` is not read — the typed field is authoritative, so an ext-only value\n is treated as omitted. Mirrors the required Offer-side\n `ResourceIdentity.resource_mutability`.").optional(), "source": z.enum(["INGESTION_SOURCE_RAMP_SITEMAP","INGESTION_SOURCE_RSL","INGESTION_SOURCE_SITEMAP","INGESTION_SOURCE_HTML_CRAWL","INGESTION_SOURCE_CMS_API","INGESTION_SOURCE_MANUAL","INGESTION_SOURCE_CATALOG_API"]).describe("How the entry was discovered").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("One LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "word_count": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Word count").optional() })); +export const ResourceEntrySchema = wire(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing."), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("ResourceAttestation — Signed envelope of claims from a trusted party.\n\nA provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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).").optional(), "content_hash": z.string().describe("Content hash").optional(), "content_id": z.string().describe("Content identifier").optional(), "domain": z.string().describe("Provider domain").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Estimated quantity in the metering unit").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm").optional(), "path": z.string().describe("Content path").default(""), "provenance_source": z.string().describe("Who provided this resource metadata. Creates audit trail for\n \"where did this catalog entry come from?\"").optional(), "provenance_timestamp": z.string().datetime({ offset: true }).describe("When this metadata was collected/generated.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Optional mutability hint. When omitted, the Exchange applies the `STATIC`\n default at Offer build; an explicit `UNSPECIFIED` is rejected. A value in\n `ext` is not read — the typed field is authoritative, so an ext-only value\n is treated as omitted. Mirrors the required Offer-side\n `ResourceIdentity.resource_mutability`.").optional(), "source": z.enum(["INGESTION_SOURCE_RAMP_SITEMAP","INGESTION_SOURCE_RSL","INGESTION_SOURCE_SITEMAP","INGESTION_SOURCE_HTML_CRAWL","INGESTION_SOURCE_CMS_API","INGESTION_SOURCE_MANUAL","INGESTION_SOURCE_CATALOG_API"]).describe("How the entry was discovered").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent's delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:[\"subscription:...\"].\n\nCoverage 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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("LicenseTerm — Universal licensing unit.\n\nOne LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "title": z.string().describe("Content title").optional(), "word_count": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Word count").optional() })); -export const ResourceIdentitySchema = wire(z.object({ "c2pa_manifest": z.string().describe("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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("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": z.string().describe("Algorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Serves two purposes:\n 1. Cross-exchange deduplication (Broker groups offers for\n the same underlying resource to compare pricing).\n 2. Resource integrity verification (agent checks that delivered\n resource matches what was promised).\n\n Providers declare what verification level they support via\n content_hash + hash_method. Higher verification = higher trust =\n resource can command premium pricing.\n\n Level 0: No hash (canonical_url only). Agent gets what it gets.\n Level 1: SimHash (fuzzy match). Tolerates minor page changes\n (ads, nav, timestamps). Catches bait-and-switch.\n Level 2: SHA-256 (exact match). Provider serves a clean,\n deterministic payload. Agent verifies exact content.\n\n This is non-blocking on current infrastructure: providers who\n serve dynamic pages use Level 0-1. Providers who invest in\n consistent resource delivery reach Level 2 and earn more.\n\n CoMP's Package.id identifies the *package* (exchange-specific);\n ResourceIdentity identifies the *resource* (cross-exchange).")); +export const ResourceIdentitySchema = wire(z.object({ "c2pa_manifest": z.string().describe("C2PA content credentials manifest URI.\n Points to a sidecar or embedded C2PA manifest for this resource.\n C2PA-aware agents MAY follow this URI to validate the full provenance\n chain (creator identity, transformation history, ingredient composition)\n using C2PA libraries (JUMBF/COSE Sign1). C2PA-unaware agents can rely\n on c2pa_status and c2pa-bridged attestation claims instead.\n\nFormats:\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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("Summary validation status of the C2PA manifest.\n Populated by the Exchange or a verification vendor after validating\n the C2PA manifest. Enables agents to filter for provenance-verified\n content without parsing JUMBF/COSE themselves.\n 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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("Hash of the content. Interpretation depends on hash_method:\n \"simhash-v1\" → locality-sensitive hash, for fuzzy dedup (Level 1)\n \"sha256\" → exact-match integrity hash (Level 2)\n\nLevel 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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Signals whether this resource's content is stable, changes over time,\n or does not exist at offer time (live streaming).\n 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 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": z.string().describe("Soft binding hash — content-derived identifier that survives format\n transcoding (resolution changes, compression, PDF-to-text extraction).\n Extracted from C2PA soft binding assertion when present.\n Enables post-delivery verification when the hard binding hash breaks\n due to legitimate format conversion.\n\nAlgorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("ResourceIdentity — Layered resource identification and verification.\n\nServes two purposes:\n 1. Cross-exchange deduplication (Broker groups offers for\n the same underlying resource to compare pricing).\n 2. Resource integrity verification (agent checks that delivered\n resource matches what was promised).\n\n Providers declare what verification level they support via\n content_hash + hash_method. Higher verification = higher trust =\n resource can command premium pricing.\n\n Level 0: No hash (canonical_url only). Agent gets what it gets.\n Level 1: SimHash (fuzzy match). Tolerates minor page changes\n (ads, nav, timestamps). Catches bait-and-switch.\n Level 2: SHA-256 (exact match). Provider serves a clean,\n deterministic payload. Agent verifies exact content.\n\n This is non-blocking on current infrastructure: providers who\n serve dynamic pages use Level 0-1. Providers who invest in\n consistent resource delivery reach Level 2 and earn more.\n\n CoMP's Package.id identifies the *package* (exchange-specific);\n ResourceIdentity identifies the *resource* (cross-exchange).")); export const ResourceMutabilitySchema = wire(z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"])); -export const ResourceQuerySchema = wire(z.object({ "acceptable_restrictions": z.array(z.object({ "axis": z.union([z.string().regex(new RegExp("^RESTRICTION_KIND_UNSPECIFIED$")), z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("Which axis (same enum as Restriction.kind): FUNCTION / GEOGRAPHY /\n USER_TYPE / OTHER.").default(0), "values": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("The values the query operates within on this axis — same token vocabulary\n as the terms (e.g. FUNCTION [\"ai-train\"], GEOGRAPHY [\"US\", \"EU\"]).").optional() }).describe("AcceptableRestriction — the limits a query operates within on one restriction\n axis, expressed in the same RestrictionKind vocabulary that terms use. The\n Exchange/Broker MAY pre-select offers whose term restrictions fall within\n these as a convenience (see Restriction); it is NOT enforcement — the agent\n self-selects and bears compliance.")).describe("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.").optional(), "deadline": z.string().describe("Maximum time the caller will wait for a response.\n Exchange SHOULD prioritize speed over completeness when tight.\n Absent = \"0.5s\" default (proto-JSON encodes Duration as seconds).").optional(), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the recipient this request is addressed to (e.g.\n \"exchange.example\" or \"exchange.example:8081\"). See \"Request recipient\" in\n the file header for the full contract, including the recipient's duty to\n reject a request that names someone else."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "requester": z.object({ "delegation": z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this delegation expires. Exchange MUST reject expired tokens.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "issuer": z.string().describe("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.").optional(), "max_accesses": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "max_spend_cents": z.coerce.number().int().describe("Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.").optional(), "principal_domain": z.string().describe("Who granted this delegation (domain for public key lookup).").default(""), "principal_id": z.string().describe("Principal's identifier (e.g., \"user@acme.com\", \"marketdata.example.com\").").default(""), "quota_period": z.string().describe("Quota reset period. How often the access/spend counters reset.\n Example: 30 days for monthly subscriptions — \"2592000s\" on the wire\n (proto-JSON encodes Duration as seconds; \"720h\" is not accepted).\n When absent, the quota is lifetime (bounded only by expires_at).").optional(), "revocation_uri": z.string().describe("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).").optional(), "scopes": z.array(z.string()).describe("Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).").optional(), "token": z.string().regex(new RegExp("^[A-Za-z0-9+/]*={0,2}$")).describe("Token bytes. A JWT (base64url-encoded JWS).").default(""), "token_format": z.string().describe("Token format: \"jwt\" (default). Empty is treated as \"jwt\". The field stays\n open for a future format.").default("") }).describe("Optional delegation — present when the requester acts on behalf of\n another entity (user, organization, upstream agent).").optional(), "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Domain the requester belongs to — used for public key lookup, so the value\n is concatenated into a URL the verifier fetches ({domain}/.well-known/ramp.json,\n WellKnownManifest with role=ROLE_AGENT). It carries the same bare-host shape\n \"Request recipient\" defines in the file header, for the same structural\n reason: a scheme, path or query smuggled in here would choose what gets\n fetched, not merely from where."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "id": z.string().describe("Unique requester identifier (e.g., \"agent-research-bot-001\").").default(""), "name": z.string().describe("Human-readable name (e.g., \"Acme Research Assistant\").").optional(), "scopes": z.array(z.string()).max(64).describe("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).").optional(), "type": z.enum(["REQUESTER_TYPE_AGENT","REQUESTER_TYPE_HUMAN_TOOL","REQUESTER_TYPE_SERVICE","REQUESTER_TYPE_DELEGATED","REQUESTER_TYPE_RESEARCH"]).describe("What kind of entity is making this request.") }).describe("Requester identity — who is making this request, what scopes they have,\n and optional delegation chain.").optional(), "supported_profiles": z.array(z.string()).describe("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\"]").optional(), "uris": z.array(z.string()).max(256).describe("Resource URIs being queried.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("Sent by a Broker or directly by an AI agent.\n The Exchange evaluates its access policies, available inventory,\n and reporting requirements before responding.")); +export const ResourceQuerySchema = wire(z.object({ "acceptable_restrictions": z.array(z.object({ "axis": z.union([z.string().regex(new RegExp("^RESTRICTION_KIND_UNSPECIFIED$")), z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("Which axis (same enum as Restriction.kind): FUNCTION / GEOGRAPHY /\n USER_TYPE / OTHER.").default(0), "values": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("The values the query operates within on this axis — same token vocabulary\n as the terms (e.g. FUNCTION [\"ai-train\"], GEOGRAPHY [\"US\", \"EU\"]).").optional() }).describe("AcceptableRestriction — the limits a query operates within on one restriction\n axis, expressed in the same RestrictionKind vocabulary that terms use. The\n Exchange/Broker MAY pre-select offers whose term restrictions fall within\n these as a convenience (see Restriction); it is NOT enforcement — the agent\n self-selects and bears compliance.")).describe("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.").optional(), "deadline": z.string().describe("Maximum time the caller will wait for a response.\n Exchange SHOULD prioritize speed over completeness when tight.\n Absent = \"0.5s\" default (proto-JSON encodes Duration as seconds).").optional(), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the recipient this request is addressed to (e.g.\n \"exchange.example\" or \"exchange.example:8081\"). See \"Request recipient\" in\n the file header for the full contract, including the recipient's duty to\n reject a request that names someone else."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "requester": z.object({ "delegation": z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this delegation expires. Exchange MUST reject expired tokens.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "issuer": z.string().describe("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.").optional(), "max_accesses": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "max_spend_cents": z.coerce.number().int().describe("Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.").optional(), "principal_domain": z.string().describe("Who granted this delegation (domain for public key lookup).").default(""), "principal_id": z.string().describe("Principal's identifier (e.g., \"user@acme.com\", \"marketdata.example.com\").").default(""), "quota_period": z.string().describe("Quota reset period. How often the access/spend counters reset.\n Example: 30 days for monthly subscriptions — \"2592000s\" on the wire\n (proto-JSON encodes Duration as seconds; \"720h\" is not accepted).\n When absent, the quota is lifetime (bounded only by expires_at).").optional(), "revocation_uri": z.string().describe("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).").optional(), "scopes": z.array(z.string()).describe("Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).").optional(), "token": z.string().regex(new RegExp("^[A-Za-z0-9+/]*={0,2}$")).describe("Token bytes. A JWT (base64url-encoded JWS).").default(""), "token_format": z.string().describe("Token format: \"jwt\" (default). Empty is treated as \"jwt\". The field stays\n open for a future format.").default("") }).describe("Optional delegation — present when the requester acts on behalf of\n another entity (user, organization, upstream agent).").optional(), "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Domain the requester belongs to — used for public key lookup, so the value\n is concatenated into a URL the verifier fetches ({domain}/.well-known/ramp.json,\n WellKnownManifest with role=ROLE_AGENT). It carries the same bare-host shape\n \"Request recipient\" defines in the file header, for the same structural\n reason: a scheme, path or query smuggled in here would choose what gets\n fetched, not merely from where."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "id": z.string().describe("Unique requester identifier (e.g., \"agent-research-bot-001\").").default(""), "name": z.string().describe("Human-readable name (e.g., \"Acme Research Assistant\").").optional(), "scopes": z.array(z.string()).max(64).describe("Entitlement scopes. Declare what the requester can access.\n\nThe 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).").optional(), "type": z.enum(["REQUESTER_TYPE_AGENT","REQUESTER_TYPE_HUMAN_TOOL","REQUESTER_TYPE_SERVICE","REQUESTER_TYPE_DELEGATED","REQUESTER_TYPE_RESEARCH"]).describe("What kind of entity is making this request.") }).describe("Requester identity — who is making this request, what scopes they have,\n and optional delegation chain.").optional(), "supported_profiles": z.array(z.string()).describe("Domain extension profiles the caller understands.\n\nDeclares 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\"]").optional(), "uris": z.array(z.string()).max(256).describe("Resource URIs being queried.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("ResourceQuery — Query an Exchange for available resource offers.\n\nSent by a Broker or directly by an AI agent.\n The Exchange evaluates its access policies, available inventory,\n and reporting requirements before responding.")); -export const ResourceResponseSchema = wire(z.object({ "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Canonical domain of the responding Exchange, in the shape \"Request\n recipient\" defines in the file header. The response counterpart of the\n recipient field on the request: it names who answered."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "offer_groups": z.array(z.object({ "absence_reason": z.enum(["OFFER_ABSENCE_REASON_NOT_IN_CATALOG","OFFER_ABSENCE_REASON_CONTENT_BLOCKED","OFFER_ABSENCE_REASON_RESTRICTION_FILTERED","OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE","OFFER_ABSENCE_REASON_NOT_AUTHORIZED","OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT","OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION","OFFER_ABSENCE_REASON_BUDGET_EXCEEDED"]).describe("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.").optional(), "discovery_method": z.enum(["DISCOVERY_METHOD_EXCHANGE","DISCOVERY_METHOD_SEARCH","DISCOVERY_METHOD_RECOMMENDATION","DISCOVERY_METHOD_SYNDICATION"]).describe("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.").optional(), "offers": z.array(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().describe("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.").default(""), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("A provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("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": z.string().describe("Algorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("The Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().describe("CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.").default(""), "signature_algorithm": z.string().describe("JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("Analogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("One LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional() }).describe("Combines pricing, delivery method, resource identity, and reporting terms.\n CoMP-specific metadata (Package, Function) available via ramp-comp-v1 extension profile.")).describe("Zero or more offers for this URI. Empty = resource not available.").optional(), "restriction_filters": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("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.").optional(), "uri": z.string().describe("The URI this group of offers is for (echoed from ResourceQuery.uris).").default("") }).describe("OfferGroup — Offers for a single requested URI.\n Enables multi-URI batch queries where the caller needs to know\n which offers correspond to which requested resource.")).describe("Offers grouped by requested URI (for multi-URI batch queries).\n When populated, `offers` SHOULD be empty to avoid ambiguity.").optional(), "offers": z.array(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().describe("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.").default(""), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("A provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("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": z.string().describe("Algorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("The Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().describe("CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.").default(""), "signature_algorithm": z.string().describe("JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("Analogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("One LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional() }).describe("Combines pricing, delivery method, resource identity, and reporting terms.\n CoMP-specific metadata (Package, Function) available via ramp-comp-v1 extension profile.")).describe("Flat list of offers (for single-URI queries).").optional(), "rate_limit": z.object({ "limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Maximum requests allowed in the current window.").optional(), "remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Requests remaining in the current window.").optional(), "reset_at": z.string().datetime({ offset: true }).describe("When the current window resets (UTC). After this time, `remaining` resets to `limit`.").optional(), "window": z.string().describe("Duration of the rate limit window (e.g. 60s = per-minute limit).").optional() }).describe("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.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("When the ResourceQuery contains multiple URIs, offers are grouped by URI\n via OfferGroup. When a single URI is queried, the Exchange MAY use\n either the flat `offers` field or a single OfferGroup.")); +export const ResourceResponseSchema = wire(z.object({ "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Canonical domain of the responding Exchange, in the shape \"Request\n recipient\" defines in the file header. The response counterpart of the\n recipient field on the request: it names who answered."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "offer_groups": z.array(z.object({ "absence_reason": z.enum(["OFFER_ABSENCE_REASON_NOT_IN_CATALOG","OFFER_ABSENCE_REASON_CONTENT_BLOCKED","OFFER_ABSENCE_REASON_RESTRICTION_FILTERED","OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE","OFFER_ABSENCE_REASON_NOT_AUTHORIZED","OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT","OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION","OFFER_ABSENCE_REASON_BUDGET_EXCEEDED"]).describe("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.").optional(), "discovery_method": z.enum(["DISCOVERY_METHOD_EXCHANGE","DISCOVERY_METHOD_SEARCH","DISCOVERY_METHOD_RECOMMENDATION","DISCOVERY_METHOD_SYNDICATION"]).describe("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.").optional(), "offers": z.array(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing."), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("ResourceAttestation — Signed envelope of claims from a trusted party.\n\nA provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("Signed attestations about the resource at this URI.\n Attestations provide cryptographic proof of\n resource properties from trusted parties (providers or verification vendors).\n\nThree 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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("When the offered data was current. For dynamic resources\n (resource_mutability = DYNAMIC), this is the snapshot timestamp.\n Enables the Broker to evaluate freshness: \"this credit report\n reflects data as of March 18\" or \"this drug database was updated today.\"\n\nNot 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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("C2PA content credentials manifest URI.\n Points to a sidecar or embedded C2PA manifest for this resource.\n C2PA-aware agents MAY follow this URI to validate the full provenance\n chain (creator identity, transformation history, ingredient composition)\n using C2PA libraries (JUMBF/COSE Sign1). C2PA-unaware agents can rely\n on c2pa_status and c2pa-bridged attestation claims instead.\n\nFormats:\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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("Summary validation status of the C2PA manifest.\n Populated by the Exchange or a verification vendor after validating\n the C2PA manifest. Enables agents to filter for provenance-verified\n content without parsing JUMBF/COSE themselves.\n 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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("Hash of the content. Interpretation depends on hash_method:\n \"simhash-v1\" → locality-sensitive hash, for fuzzy dedup (Level 1)\n \"sha256\" → exact-match integrity hash (Level 2)\n\nLevel 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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Signals whether this resource's content is stable, changes over time,\n or does not exist at offer time (live streaming).\n 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 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": z.string().describe("Soft binding hash — content-derived identifier that survives format\n transcoding (resolution changes, compression, PDF-to-text extraction).\n Extracted from C2PA soft binding assertion when present.\n Enables post-delivery verification when the hard binding hash breaks\n due to legitimate format conversion.\n\nAlgorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("Preview — Lightweight resource preview for offer evaluation.\n\nThe Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("Lightweight previews for offer evaluation.\n The Exchange holds URLs (50–200 bytes each); the provider's CDN serves\n the actual bytes. Agents fetch previews only when evaluating offers —\n not on every discovery query. Multiple previews at different sizes\n allow agents to pick the cheapest fetch for their evaluation needs.\n\nPer 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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("REQUIRED. A DETACHED Ed25519 signature over the canonical serialization of\n the ENTIRE Offer, carried as the raw 64 signature bytes in lowercase or\n uppercase hex — 128 hex characters, NOT a JWS. There is no JOSE header and\n no compact serialization here: the signed bytes are defined by the\n canonical-signing recipe below, and this field carries only the signature\n itself. `signature_algorithm` names the algorithm separately.\n Same convention as `AgentAcceptance.signature`, and it is what the admin\n plane's offline verification recipe replays. The hex shape is STATED here\n and ENFORCED there: this field carries no schema rule, while\n ramp.admin.v1.TransactionEvidence.offer_sig — the same value, copied into\n the evidence row — is pattern-bound to 128 hex characters.\n\nThe signature covers every field, including `pricing`, `terms` (the full\n licensing payload), `expires_at`, and `exchange`. Only `signature` and\n `signature_algorithm` are excluded from the signed bytes. `expires_at` is\n signed so the offer's validity window is integrity-protected: a relaying\n Broker cannot extend (or shorten) the TTL of a signed offer to replay it\n outside the window the Exchange intended.\n\n CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.\n\n The rule is the hex shape this comment already describes: 128 characters,\n either case, which is one Ed25519 signature (64 bytes) hex-encoded. Either\n case is accepted because hex decoding accepts both and a dispute should\n read the same characters a request log holds; every SDK in this repo emits\n lowercase. The pattern also makes the field mandatory in practice — the\n empty string does not match it — which restates what this message already\n requires: an unsigned Offer is not an Offer, since the signature is what\n makes its terms, pricing and expiry non-repudiable."), "signature_algorithm": z.string().describe("Signature algorithm. Always 'EdDSA' — the JOSE algorithm identifier for\n Ed25519 (RFC 8032). Only the identifier is borrowed from JOSE: `signature`\n is a detached hex signature, not a JWS.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("SubscriptionQuotaInfo — Proactive quota signaling for subscription access.\n\nAnalogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent's delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:[\"subscription:...\"].\n\nCoverage 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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("LicenseTerm — Universal licensing unit.\n\nOne LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "title": z.string().describe("Resource title (human-readable, for display/logging).").optional() }).describe("Offer — A single resource offer from an Exchange.\n\nCombines pricing, delivery method, resource identity, and reporting terms.\n CoMP-specific metadata (Package, Function) available via ramp-comp-v1 extension profile.")).describe("Zero or more offers for this URI. Empty = resource not available.").optional(), "restriction_filters": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("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.").optional(), "uri": z.string().describe("The URI this group of offers is for (echoed from ResourceQuery.uris).").default("") }).describe("OfferGroup — Offers for a single requested URI.\n Enables multi-URI batch queries where the caller needs to know\n which offers correspond to which requested resource.")).describe("Offers grouped by requested URI (for multi-URI batch queries).\n When populated, `offers` SHOULD be empty to avoid ambiguity.").optional(), "offers": z.array(z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing."), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("ResourceAttestation — Signed envelope of claims from a trusted party.\n\nA provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("Signed attestations about the resource at this URI.\n Attestations provide cryptographic proof of\n resource properties from trusted parties (providers or verification vendors).\n\nThree 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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("When the offered data was current. For dynamic resources\n (resource_mutability = DYNAMIC), this is the snapshot timestamp.\n Enables the Broker to evaluate freshness: \"this credit report\n reflects data as of March 18\" or \"this drug database was updated today.\"\n\nNot 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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("C2PA content credentials manifest URI.\n Points to a sidecar or embedded C2PA manifest for this resource.\n C2PA-aware agents MAY follow this URI to validate the full provenance\n chain (creator identity, transformation history, ingredient composition)\n using C2PA libraries (JUMBF/COSE Sign1). C2PA-unaware agents can rely\n on c2pa_status and c2pa-bridged attestation claims instead.\n\nFormats:\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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("Summary validation status of the C2PA manifest.\n Populated by the Exchange or a verification vendor after validating\n the C2PA manifest. Enables agents to filter for provenance-verified\n content without parsing JUMBF/COSE themselves.\n 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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("Hash of the content. Interpretation depends on hash_method:\n \"simhash-v1\" → locality-sensitive hash, for fuzzy dedup (Level 1)\n \"sha256\" → exact-match integrity hash (Level 2)\n\nLevel 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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Signals whether this resource's content is stable, changes over time,\n or does not exist at offer time (live streaming).\n 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 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": z.string().describe("Soft binding hash — content-derived identifier that survives format\n transcoding (resolution changes, compression, PDF-to-text extraction).\n Extracted from C2PA soft binding assertion when present.\n Enables post-delivery verification when the hard binding hash breaks\n due to legitimate format conversion.\n\nAlgorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("Preview — Lightweight resource preview for offer evaluation.\n\nThe Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("Lightweight previews for offer evaluation.\n The Exchange holds URLs (50–200 bytes each); the provider's CDN serves\n the actual bytes. Agents fetch previews only when evaluating offers —\n not on every discovery query. Multiple previews at different sizes\n allow agents to pick the cheapest fetch for their evaluation needs.\n\nPer 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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("REQUIRED. A DETACHED Ed25519 signature over the canonical serialization of\n the ENTIRE Offer, carried as the raw 64 signature bytes in lowercase or\n uppercase hex — 128 hex characters, NOT a JWS. There is no JOSE header and\n no compact serialization here: the signed bytes are defined by the\n canonical-signing recipe below, and this field carries only the signature\n itself. `signature_algorithm` names the algorithm separately.\n Same convention as `AgentAcceptance.signature`, and it is what the admin\n plane's offline verification recipe replays. The hex shape is STATED here\n and ENFORCED there: this field carries no schema rule, while\n ramp.admin.v1.TransactionEvidence.offer_sig — the same value, copied into\n the evidence row — is pattern-bound to 128 hex characters.\n\nThe signature covers every field, including `pricing`, `terms` (the full\n licensing payload), `expires_at`, and `exchange`. Only `signature` and\n `signature_algorithm` are excluded from the signed bytes. `expires_at` is\n signed so the offer's validity window is integrity-protected: a relaying\n Broker cannot extend (or shorten) the TTL of a signed offer to replay it\n outside the window the Exchange intended.\n\n CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.\n\n The rule is the hex shape this comment already describes: 128 characters,\n either case, which is one Ed25519 signature (64 bytes) hex-encoded. Either\n case is accepted because hex decoding accepts both and a dispute should\n read the same characters a request log holds; every SDK in this repo emits\n lowercase. The pattern also makes the field mandatory in practice — the\n empty string does not match it — which restates what this message already\n requires: an unsigned Offer is not an Offer, since the signature is what\n makes its terms, pricing and expiry non-repudiable."), "signature_algorithm": z.string().describe("Signature algorithm. Always 'EdDSA' — the JOSE algorithm identifier for\n Ed25519 (RFC 8032). Only the identifier is borrowed from JOSE: `signature`\n is a detached hex signature, not a JWS.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("SubscriptionQuotaInfo — Proactive quota signaling for subscription access.\n\nAnalogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent's delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:[\"subscription:...\"].\n\nCoverage 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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("LicenseTerm — Universal licensing unit.\n\nOne LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "title": z.string().describe("Resource title (human-readable, for display/logging).").optional() }).describe("Offer — A single resource offer from an Exchange.\n\nCombines pricing, delivery method, resource identity, and reporting terms.\n CoMP-specific metadata (Package, Function) available via ramp-comp-v1 extension profile.")).describe("Flat list of offers (for single-URI queries).").optional(), "rate_limit": z.object({ "limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Maximum requests allowed in the current window.").optional(), "remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Requests remaining in the current window.").optional(), "reset_at": z.string().datetime({ offset: true }).describe("When the current window resets (UTC). After this time, `remaining` resets to `limit`.").optional(), "window": z.string().describe("Duration of the rate limit window (e.g. 60s = per-minute limit).").optional() }).describe("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.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("ResourceResponse — Exchange returns candidate resource offers.\n\nWhen the ResourceQuery contains multiple URIs, offers are grouped by URI\n via OfferGroup. When a single URI is queried, the Exchange MAY use\n either the flat `offers` field or a single OfferGroup.")); -export const RestrictionSchema = wire(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")); +export const RestrictionSchema = wire(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")); export const RestrictionKindSchema = wire(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])); @@ -168,15 +178,15 @@ export const RetrievalAuthFailureReasonSchema = wire(z.enum(["RETRIEVAL_AUTH_FAI export const RoleSchema = wire(z.enum(["ROLE_AGENT","ROLE_EXCHANGE","ROLE_BROKER","ROLE_PUBLISHER"])); -export const SetReportingPolicyRequestSchema = wire(z.object({ "policy": z.object({ "quantity_tolerance": z.coerce.number().gte(0).lte(1).describe("Accepted relative deviation between estimated and reported quantity, as a\n fraction: 0 requires an exact match, 1 accepts any deviation. Omitted: the\n receiving Exchange's default tolerance applies.").optional(), "required_fields": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(32).refine((arr) => arr.every((item, i) => arr.indexOf(item) == i), "All items must be unique!").describe("Report field names the usage-report validator requires. The wire constrains\n only the token shape; which names are meaningful is defined by the receiving\n Exchange and may change without a contract change. Names are a set: repeats\n are rejected. Empty means no required fields.").optional(), "tenant_id": z.string().min(1).max(255).describe("The tenant whose reporting policy is being replaced."), "window_seconds": z.coerce.number().int().gt(0).lte(31536000).describe("Reporting window in seconds. Applies to obligations minted after this call;\n obligations already issued keep the window they were minted with. Capped at\n one year. Omitted: the receiving Exchange's default applies.").optional() }).describe("The reporting policy to apply. Required — an absent payload would otherwise\n skip validation of its fields."), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in ramp.proto.").default("") })); +export const SetReportingPolicyRequestSchema = wire(z.object({ "policy": z.object({ "quantity_tolerance": z.coerce.number().gte(0).lte(1).describe("Accepted relative deviation between estimated and reported quantity, as a\n fraction: 0 requires an exact match, 1 accepts any deviation. Omitted: the\n receiving Exchange's default tolerance applies.").optional(), "required_fields": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(32).refine((arr) => arr.every((item, i) => arr.indexOf(item) == i), "All items must be unique!").describe("Report field names the usage-report validator requires. The wire constrains\n only the token shape; which names are meaningful is defined by the receiving\n Exchange and may change without a contract change. Names are a set: repeats\n are rejected. Empty means no required fields.").optional(), "tenant_id": z.string().min(1).max(255).describe("The tenant whose reporting policy is being replaced. Same rule as\n ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated)."), "window_seconds": z.coerce.number().int().gt(0).lte(31536000).describe("Reporting window in seconds. Applies to obligations minted after this call;\n obligations already issued keep the window they were minted with. Capped at\n one year. Omitted: the receiving Exchange's default applies.").optional() }).describe("The reporting policy to apply. Required — an absent payload would otherwise\n skip validation of its fields."), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in ramp.proto.").default("") })); -export const SetReportingPolicyResponseSchema = wire(z.object({ "policy": z.object({ "quantity_tolerance": z.coerce.number().gte(0).lte(1).describe("Accepted relative deviation between estimated and reported quantity, as a\n fraction: 0 requires an exact match, 1 accepts any deviation. Omitted: the\n receiving Exchange's default tolerance applies.").optional(), "required_fields": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(32).refine((arr) => arr.every((item, i) => arr.indexOf(item) == i), "All items must be unique!").describe("Report field names the usage-report validator requires. The wire constrains\n only the token shape; which names are meaningful is defined by the receiving\n Exchange and may change without a contract change. Names are a set: repeats\n are rejected. Empty means no required fields.").optional(), "tenant_id": z.string().min(1).max(255).describe("The tenant whose reporting policy is being replaced."), "window_seconds": z.coerce.number().int().gt(0).lte(31536000).describe("Reporting window in seconds. Applies to obligations minted after this call;\n obligations already issued keep the window they were minted with. Capped at\n one year. Omitted: the receiving Exchange's default applies.").optional() }).describe("The reporting policy as persisted."), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in ramp.proto.").default("") })); +export const SetReportingPolicyResponseSchema = wire(z.object({ "policy": z.object({ "quantity_tolerance": z.coerce.number().gte(0).lte(1).describe("Accepted relative deviation between estimated and reported quantity, as a\n fraction: 0 requires an exact match, 1 accepts any deviation. Omitted: the\n receiving Exchange's default tolerance applies.").optional(), "required_fields": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(32).refine((arr) => arr.every((item, i) => arr.indexOf(item) == i), "All items must be unique!").describe("Report field names the usage-report validator requires. The wire constrains\n only the token shape; which names are meaningful is defined by the receiving\n Exchange and may change without a contract change. Names are a set: repeats\n are rejected. Empty means no required fields.").optional(), "tenant_id": z.string().min(1).max(255).describe("The tenant whose reporting policy is being replaced. Same rule as\n ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated)."), "window_seconds": z.coerce.number().int().gt(0).lte(31536000).describe("Reporting window in seconds. Applies to obligations minted after this call;\n obligations already issued keep the window they were minted with. Capped at\n one year. Omitted: the receiving Exchange's default applies.").optional() }).describe("The reporting policy as persisted."), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in ramp.proto.").default("") })); export const SetTenantFeeRateRequestSchema = wire(z.object({ "rate": z.object({ "fee_rate_bps": z.coerce.number().int().gte(0).lt(10000).describe("Fee rate in basis points of gross: fee = floor(gross * bps / 10000).\n Below 10000 keeps the fee strictly under 100%; integer basis points avoid\n float drift when aggregating many charges. 0 is a legitimate explicit\n value (\"no fee\"), not an unset sentinel.").optional(), "notes": z.string().max(1024).describe("Operator commentary on the rate (why it was set, by whom, ticket link).\n Omitted clears any existing note.").optional(), "tenant_id": z.string().min(1).max(255).describe("The tenant whose fee rate is being set.") }).describe("The fee rate to apply. Required — an absent payload would otherwise skip\n validation of its fields."), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in ramp.proto.").default("") })); export const SetTenantFeeRateResponseSchema = wire(z.object({ "rate": z.object({ "fee_rate_bps": z.coerce.number().int().gte(0).lt(10000).describe("Fee rate in basis points of gross: fee = floor(gross * bps / 10000).\n Below 10000 keeps the fee strictly under 100%; integer basis points avoid\n float drift when aggregating many charges. 0 is a legitimate explicit\n value (\"no fee\"), not an unset sentinel.").optional(), "notes": z.string().max(1024).describe("Operator commentary on the rate (why it was set, by whom, ticket link).\n Omitted clears any existing note.").optional(), "tenant_id": z.string().min(1).max(255).describe("The tenant whose fee rate is being set.") }).describe("The fee rate as persisted."), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in ramp.proto.").default("") })); -export const SubscriptionQuotaInfoSchema = wire(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("Analogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")); +export const SubscriptionQuotaInfoSchema = wire(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("SubscriptionQuotaInfo — Proactive quota signaling for subscription access.\n\nAnalogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")); export const TenantFeeRateSchema = wire(z.object({ "fee_rate_bps": z.coerce.number().int().gte(0).lt(10000).describe("Fee rate in basis points of gross: fee = floor(gross * bps / 10000).\n Below 10000 keeps the fee strictly under 100%; integer basis points avoid\n float drift when aggregating many charges. 0 is a legitimate explicit\n value (\"no fee\"), not an unset sentinel.").optional(), "notes": z.string().max(1024).describe("Operator commentary on the rate (why it was set, by whom, ticket link).\n Omitted clears any existing note.").optional(), "tenant_id": z.string().min(1).max(255).describe("The tenant whose fee rate is being set.") }).describe("TenantFeeRate is the fee-rate payload shared by SetTenantFeeRate's request\n and response. The field rules live here once, so the write and the echoed\n read-back stay in lockstep.")); @@ -184,19 +194,23 @@ export const TermSemanticsSchema = wire(z.enum(["TERM_SEMANTICS_ENUMERATED","TER export const TransactionDenialSchema = wire(z.object({ "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Bare host of the Exchange that PRODUCED this denial, in the form \"Request\n recipient\" defines in the file header. Not an echo of what the caller sent:\n on a relayed or fanned-out execute the request went to a Broker, so the\n Exchange that refused may not be one the agent named. Carrying it here is\n what lets ACCOUNT_NOT_REGISTERED be actionable — the agent learns where to\n call Register without fetching a manifest to work it out. NOTHING SIGNS THIS\n VALUE: it rides in a response, and on a relayed path the response passed\n through an intermediary, so this field is exactly the unsigned addressing\n the request-side `exchange` field exists to refuse. Treat it as a HINT, not\n an instruction. Before acting on it — and registering is a consequential act,\n handing an operator's business data and a signed acceptance of that\n Exchange's terms to whoever answers — a caller MUST check the value against\n a domain it already trusts for this transaction: the signed `offer.exchange`\n of the denied item, or its own RequestConstraints.exchanges set. A value\n matching neither is reported to the caller and never dialled, because a\n hostile intermediary that could choose it would be choosing where an\n unattended agent registers.").optional(), "offer_id": z.string().describe("Batch mode: the offer this denial pertains to.").optional(), "reason": z.enum(["DENIAL_REASON_ACCOUNT_INACTIVE","DENIAL_REASON_INSUFFICIENT_BALANCE","DENIAL_REASON_RATE_LIMITED","DENIAL_REASON_CONTENT_UNAVAILABLE","DENIAL_REASON_RESTRICTION_NOT_SATISFIED","DENIAL_REASON_REPORTING_OVERDUE","DENIAL_REASON_OFFER_EXPIRED","DENIAL_REASON_SIGNATURE_INVALID","DENIAL_REASON_QUOTA_EXCEEDED","DENIAL_REASON_DELEGATION_INVALID","DENIAL_REASON_SCOPE_INSUFFICIENT","DENIAL_REASON_ENTITLEMENT_MISSING","DENIAL_REASON_ENTITLEMENT_MALFORMED","DENIAL_REASON_ENTITLEMENT_EXPIRED","DENIAL_REASON_ENTITLEMENT_WRONG_BUYER","DENIAL_REASON_SUBSCRIPTION_LAPSED","DENIAL_REASON_ENTITLEMENT_NOT_GRANTED","DENIAL_REASON_ACCOUNT_NOT_REGISTERED"]).describe("The denial reason (defined-only, non-zero)"), "restriction_mismatches": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("When reason = RESTRICTION_NOT_SATISFIED, the failed axes (same\n RestrictionKind vocabulary the terms use).").optional() }).describe("TransactionDenial — ExecuteTransaction could not complete. Carries the denial\n reason the response body no longer holds (denial_reason / restriction_mismatches\n move here in the response-shape normalization). Reuses the DenialReason vocab.")); -export const TransactionItemSchema = wire(z.object({ "agent_acceptance": z.object({ "signature": z.string().min(1).describe("Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload\n bytes (see the canonical-signing definition on Offer.signature)."), "signature_algorithm": z.string().describe("Signature algorithm; \"EdDSA\" for Ed25519.").default("") }).describe("The agent's detached acceptance signature over this item's `offer`.\n Optional on the wire; the Exchange enforces presence per\n item at the service layer for relayed batches. Signed bytes = the canonical\n AgentAcceptancePayload form, with requester_* and idempotency_key\n taken from the ENCLOSING TransactionRequest and offer_sig = offer.signature.").optional(), "offer": z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().describe("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.").default(""), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("A provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("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": z.string().describe("Algorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("The Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().describe("CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.").default(""), "signature_algorithm": z.string().describe("JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("Analogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("One LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional() }).describe("The FULL signed Offer for this batch entry, reflected back exactly as\n received at discovery. The Exchange verifies `offer.signature` over these\n presented bytes — stateless, no reconstruct-from-catalog. REQUIRED: every\n batch item carries its offer.") }).describe("TransactionItem — A single offer commitment within a batch transaction.")); +export const TransactionEvidenceSchema = wire(z.object({ "agent_acceptance_canonical_bytes": z.string().regex(new RegExp("^(?:[A-Za-z0-9+/]{4}(?:[A-Za-z0-9+/]{4})*|[A-Za-z0-9+/]{2}(?:[A-Za-z0-9+/]{4})*(?:==)?|[A-Za-z0-9+/]{3}(?:[A-Za-z0-9+/]{4})*=?|[A-Za-z0-9_-]{4}(?:[A-Za-z0-9_-]{4})*|[A-Za-z0-9_-]{2}(?:[A-Za-z0-9_-]{4})*(?:==)?|[A-Za-z0-9_-]{3}(?:[A-Za-z0-9_-]{4})*=?)$")).min(2).describe("Verbatim JCS bytes of the AgentAcceptancePayload the agent signed.\n Unbounded for the same reason as offer_canonical_bytes. Same rule as\n ramp.admin.v1.TransactionEvidence.offer_canonical_bytes (drift-gated)."), "agent_acceptance_signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("The agent's Ed25519 signature over agent_acceptance_canonical_bytes,\n hex-encoded verbatim as it arrived on the wire (either case). Same rule\n as ramp.v1.AgentAcceptance.signature (drift-gated) — the live field this\n row stores a copy of.\n\nBoth directives here point UPSTREAM into ramp.v1, which they did not\n always do. This pattern was pinned on the read plane first, while the\n agent plane still described the hex shape in prose and enforced nothing;\n the anchors sat inside this package because there was no upstream rule to\n point at. ramp.v1 now carries the rule on both signature fields, so the\n gate compares the two planes against each other and a future tightening\n on one side can no longer leave the other silently behind."), "agent_acceptance_signature_algorithm": z.literal("EdDSA").describe("Signing-algorithm label, server-derived (see offer_sig_algorithm).\n Pinned to \"EdDSA\". Same rule as\n ramp.admin.v1.TransactionEvidence.offer_sig_algorithm (drift-gated)."), "agent_directory_url": z.string().regex(new RegExp("^$|^https://[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?/[!-~]*$")).max(512).describe("The anchored well-known directory agent_public_key was pinned from. The\n registry overwrites keys in place on rotation and keeps no history, so\n this — plus created_at — attests where and when this Exchange obtained\n the key. Empty when the agent carries no directory anchor: an append-once\n row states a value for every column, so '' is a stated fact, not a gap.\n\nPROVENANCE, NOT AUTHORITY. This field is covered by neither signature and\n is written by the same party as the rest of the row, so it can never\n establish that agent_public_key is authentic — see TRUST BOUNDARY above,\n which says where the agent anchor must come from instead. Verification\n tooling MUST NOT treat this value as a fetch target it can trust: the row\n author chose it, so following it hands them the choice of what the\n \"independent\" copy says.\n\n The rules below bound the damage from tooling that follows the field\n anyway; they do not make following it safe. The value must be '' or an\n https URL whose host uses the same recipient-host grammar as\n ramp.v1.Offer.exchange, with an optional port and an ASCII-printable path,\n within 512 bytes. Stated precisely, because a rule that sounds stronger\n than it is would be worse than none: this refuses a plaintext or non-http\n scheme, embedded userinfo or whitespace, and anything that is not a\n host-plus-path shape. It does NOT refuse an IPv4-literal host — the\n recipient-host grammar admits all-numeric labels, so https://169.254.169.254/\n matches. Blocking link-local and private address space is the fetching\n tool's job, and it is one more reason this field is not a fetch target.\n\n Named directory, not discovery: ramp.v1 uses \"discovery\" for RESOURCE\n discovery (DiscoveryRequest, OfferGroup.discovery_method), a different\n thing entirely. This is the agent's well-known directory document, which\n is what every sentence describing the field already calls it.").default(""), "agent_public_key": z.string().regex(new RegExp("^(?:[A-Za-z0-9+/]{43}=?|[A-Za-z0-9_-]{43}=?)$")).min(43).max(44).describe("The registry-pinned agent verifying key (raw 32-byte Ed25519) the\n acceptance verified against. This is the ACCEPTANCE key, which is the\n agent identity for the transaction — ramp.v1.AgentAcceptance defines that\n normatively under \"Agent identity\", and this row stores the key that\n definition names. It is deliberately NOT the transport signer: a Broker\n may author a re-packaged execute as sender, so the RFC 9421 signer on that\n leg is the broker, and a row anchored on it would name the wrong party.\n Same rule as\n ramp.admin.v1.TransactionEvidence.exchange_signing_public_key\n (drift-gated)."), "broker": z.string().regex(new RegExp("^$|^[!-~]+$")).max(255).describe("The relay hop that presented this request to the Exchange, if the\n Exchange records one. A transport fact the Exchange observed, covered by\n neither signature — which is why it sits in this section and not on\n TransactionState: TransactionState projects transaction-log columns, and\n broker routing is an execute-time observation about the connection, not\n a property of the transaction's operational state.\n\nThree states, and the `optional` keyword is what makes them distinct:\n ABSENT means this Exchange does not record routing at all; '' means it\n does record it AND the acceptance arrived direct; a value means it\n arrived through that hop. Without explicit presence the field would\n default to '', so an Exchange with nothing to say would state \"arrived\n direct\" for every row — a forensic plane asserting a transport fact it\n never observed.\n\n WHAT THE VALUE IS: implementation-defined provenance for the outermost\n hop, not a resolvable identity. The reference Exchange serves the\n verified RFC 7638 key thumbprint of the hop that presented the request.\n It deliberately does not resolve that key to a directory host: the relay\n hop is not re-identified against any registry, and the recipient tenant's\n own relay-permission setting is the gate instead. So a reader may compare\n this value for equality and may check it against a thumbprint it already\n holds, but must not expect a hostname, and must not treat it as an\n identity the Exchange vouched for. Only the outermost hop is classified;\n per-hop identity for a longer chain is out of scope here.\n\n The rule bounds the SHAPE without pinning the format. A ledger renders\n this value, so an unbounded string here would re-open on a new field\n exactly the surface request_id's printable-ASCII bound closes — control\n characters, terminal escapes and newlines reaching a rendered forensic\n row. Printable ASCII and 255 characters admit every provenance form a\n server might reasonably record (a thumbprint, a host, an opaque id) while\n refusing the shapes that only matter to a renderer. It is deliberately\n NOT a thumbprint pattern: the value is implementation-defined, and a\n format rule here could invalidate a row for a transaction that\n legitimately executed under a server that spells it some other way — the\n requester_id reasoning. The pattern admits the EMPTY string explicitly,\n because '' is one of the three states — recorded, and the acceptance\n arrived direct. A bare ^[!-~]+$ would need at least one character and\n would delete that state, leaving absence to mean both \"not recorded\" and\n \"arrived direct\". Same alternation shape agent_directory_url uses above,\n for the same reason.").optional(), "created_at": z.string().datetime({ offset: true }).describe("When the Exchange wrote this row (server clock)."), "exchange_signing_public_key": z.string().regex(new RegExp("^(?:[A-Za-z0-9+/]{43}=?|[A-Za-z0-9_-]{43}=?)$")).min(43).max(44).describe("The Exchange verifying key itself (raw 32-byte Ed25519), not a key id."), "offer_canonical_bytes": z.string().regex(new RegExp("^(?:[A-Za-z0-9+/]{4}(?:[A-Za-z0-9+/]{4})*|[A-Za-z0-9+/]{2}(?:[A-Za-z0-9+/]{4})*(?:==)?|[A-Za-z0-9+/]{3}(?:[A-Za-z0-9+/]{4})*=?|[A-Za-z0-9_-]{4}(?:[A-Za-z0-9_-]{4})*|[A-Za-z0-9_-]{2}(?:[A-Za-z0-9_-]{4})*(?:==)?|[A-Za-z0-9_-]{3}(?:[A-Za-z0-9_-]{4})*=?)$")).min(2).describe("Verbatim JCS bytes the Exchange's signature was computed over (the offer\n with its signature fields cleared). min_len only, no ceiling: same\n rationale as offer_json — the bytes under the signature are whatever size\n the signed offer was, and a bound could invalidate a legitimate row."), "offer_id": z.string().min(1).describe("The signed Offer.offer_id (which IS the catalog resource_id). Duplicated\n from the offer JSON so the row reads standalone, without parsing it."), "offer_json": z.string().min(1).describe("The signed offer as a raw JSON string, for query and human audit.\n Deliberately NOT a Struct: a Struct re-normalizes, and the canonical\n bytes below remain the arbiter of what was signed. No upper bound, unlike\n this file's 255-capped ids: upstream ramp.v1 places no size bound on an\n offer, and the row must state whatever the parties actually signed — a\n cap here could make the row fail its own validation for a transaction\n that legitimately executed (the requester_id rationale)."), "offer_sig": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("The Exchange's Ed25519 signature over offer_canonical_bytes, hex-encoded\n in the verbatim wire form (either case — hex decoding accepts both, and a\n dispute should read the same characters a request log holds). Named after\n ramp.v1.AgentAcceptancePayload.offer_sig: it is the same value, the one\n the agent's acceptance binds to. Same rule as ramp.v1.Offer.signature\n (drift-gated) — the field this row stores a copy of."), "offer_sig_algorithm": z.literal("EdDSA").describe("Signing-algorithm label, server-derived from the Exchange's own verify\n path — never echoed from the wire. The canonical payload clears the wire\n labels before signing, so an echoed label would sit outside signature\n coverage and could claim anything under an otherwise valid signature.\n Pinned to \"EdDSA\" — the content-signature label\n ramp.v1.Offer.signature_algorithm pins; \"ed25519\" is the separate label\n reserved for RFC 9421 HTTP request signatures and never appears here.\n const (not min_len) so a generated client also rejects a claimed \"none\"\n or \"HS256\".\n\nSpelled sig_algorithm, not signature_algorithm, which is how ramp.v1 and\n the sibling agent_acceptance_signature_algorithm spell it. The short form\n is INHERITED, not chosen: this label names the neighbouring offer_sig, and\n that field copies an upstream field name verbatim\n (ramp.v1.AgentAcceptancePayload.offer_sig). A label that renamed the field\n it describes would be the worse inconsistency.\n\n The long spelling is also not available: ramp.v1 retired a scalar\n offer-signature field (the execute request now reflects the\n full signed Offer instead), and scripts/check-doc-conformance.sh bans that\n identifier across the protos and the docs so the removed name cannot be\n read as live anywhere. A field named after it here would either fail that\n gate or force it open."), "request_correlation": z.object({ "minted": z.boolean().describe("Provenance: true = the id is SERVER-DERIVED, false = propagated verbatim\n from a caller-supplied header. True covers both ways a server derives\n one — the header was absent, or it was present but nonconforming and was\n replaced — because the property this flag exists for is INFLUENCE, not\n origin story: false means a caller chose these characters, true means no\n caller did. The two are byte-indistinguishable in request_id alone, so a\n forensic read needs this flag to tell a server-derived correlation key\n from an attacker-influenceable one.").default(false), "request_id": z.string().regex(new RegExp("^[!-~]+$")).min(1).max(255).describe("The correlation id as persisted. GOVERNING INVARIANT, established on the\n WRITE path: a persisted request_id always conforms to the rules below —\n printable ASCII, 1..255 — so a present value has already passed the check\n on the way in, and these rules are not a read-side filter over a laxer\n stored value. HOW a server reaches that invariant is its own choice, and\n two mechanisms both conform: reject the nonconforming header and record a\n server-derived id in its place (minted = true), or record no correlation\n at all (the wrapping message stays absent). The first keeps a correlation\n key for a request whose header was bad, the second states that nothing\n trustworthy arrived; neither can put a nonconforming value in the store,\n which is the only property this contract needs. A server that accepts a\n narrower charset than the rules below still satisfies the invariant.\n Background, for a reader tracing where the value comes from: a propagated\n id is caller-influenceable, which is what `minted` below exists to record.\n Which component performs the check is deliberately not stated here. It is\n server behaviour, this file cannot gate it, and an earlier revision of this\n comment described a particular SDK's middleware and was made wrong by a\n change to that SDK three commits later.") }).describe("Correlation id joining this row outward to whatever else recorded the\n same X-Request-ID for this execute call, with its provenance. One\n message, not two\n sibling fields: presence of the message is the pairing — id and\n provenance flag arrive together or not at all, a constraint two\n optional siblings could not express without message-level CEL (which\n this file forbids). Absent when the Exchange recorded no correlation id.").optional(), "request_idempotency_key": z.string().min(1).max(255).describe("The REQUEST-level idempotency key the acceptance signs — NOT the derived\n per-item key that TransactionState.idempotency_key carries. Same rule as\n ramp.v1.TransactionRequest.idempotency_key (drift-gated)."), "requester_domain": z.string().describe("The signed Requester.domain, verbatim. Unbounded HERE even though the\n agent plane bounds it — ramp.v1.Requester.domain carries max_len 260 and\n the bare-host pattern. Those rules govern what an Exchange may ACCEPT on\n the way in; they do not govern what this row may STATE after the fact. The\n row's job is to reproduce the bytes the acceptance actually signed, so a\n rule here could make the row fail its own validation for a transaction\n that legitimately executed — one accepted under an earlier rule set, or\n signed by a party that spelled the value some other way. Same conclusion\n as requester_id, reached differently: Requester.id genuinely carries no\n wire rule at all.").default(""), "requester_id": z.string().describe("The acceptance payload's remaining inputs (offer_sig above is the\n fourth), stored so the signed bytes can be independently rebuilt and\n audited rather than merely trusted.\n\nrequester_id is the signed Requester.id VERBATIM — the bytes under the\n agent's signature, never rewritten. It NAMES the same agent as the\n Exchange's canonical agent identity but is not byte-equal to it: a signer\n may spell its directory any way it likes (the deployed identity service\n signs \"scheme://host\"), so the forensic join goes through directory-host\n normalization, not plain equality. No wire rule: the agent plane does not\n constrain Requester.id, and this row states what was signed.").default(""), "tenant_id": z.string().min(1).max(255).describe("The tenant the transaction executed under. The admin plane is\n deployment-scoped (cross-tenant), so the row states its tenant. Same rule\n as ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated)."), "transaction_id": z.string().min(1).max(255).describe("The evidenced transaction (Exchange-minted transaction identity). The\n format is implementation-defined, exactly as in ramp.v1 (the documented\n storage model mints a 26-char ULID). The 255 bound is NEW to this plane —\n ramp.v1 leaves transaction ids unconstrained — and is safe here because\n the Exchange mints the id itself, far below that bound; it exists so the\n selector stays storable and indexable.") }).describe("TransactionEvidence — one append-once evidence row, exactly as the Exchange\n persisted it for a successfully executed transaction. The row is written\n only after both signatures verified, and a denied execute writes nothing,\n so the row's existence is itself the success statement.\n\nThe row re-verifies OFFLINE, from this message alone — no agent registry,\n no Exchange key file, no live service:\n * the Exchange signed this exact offer:\n ed25519.Verify(exchange_signing_public_key, offer_canonical_bytes, hex-decoded offer_sig)\n * the agent signed an acceptance:\n ed25519.Verify(agent_public_key, agent_acceptance_canonical_bytes, hex-decoded agent_acceptance_signature)\n * and that acceptance is THIS agreement — not merely a valid acceptance:\n JCS-parse(agent_acceptance_canonical_bytes) matches the row, member for\n member. Three names coincide; the fourth does not:\n payload offer_sig == offer_sig (hex, case-insensitive)\n payload requester_id == requester_id\n payload requester_domain == requester_domain\n payload idempotency_key == request_idempotency_key\n Those four are every field of ramp.v1.AgentAcceptancePayload, and the row\n stores all four so this comparison is possible from the row alone.\n The third step is not bookkeeping, and offer_sig alone is not enough for it.\n Two failures it prevents, which are different:\n SPLICING, by an outsider. A genuine offer from one transaction and a\n genuine acceptance from another, by the same agent, both verify against\n real keys and pass the authenticity step below. offer_sig catches this one.\n FABRICATION, by whoever writes the row. Two acceptances by one agent\n against ONE offer share offer_sig and differ only in the idempotency key,\n which ramp.v1.AgentAcceptancePayload.idempotency_key exists to bind. So an\n Exchange holding a single genuine acceptance can write two rows for two\n different executes against one offer, and both pass an offer_sig-only\n check. Only the idempotency-key comparison separates them.\n Comparing all four leaves no member of the signed payload unchecked, which is\n the only version of this step that means what it says. A verifier that skips\n it has checked two signatures and no agreement. The Exchange MUST perform the\n same comparison before persisting a row, so a bad row is never written.\n Both verifying public keys ride along (not key ids) so re-verification\n survives key rotation, which removes retired ids from the published JWKS.\n The *_canonical_bytes are the verbatim RFC 8785 JCS bytes each signature\n was computed over, stored as-signed and never re-derived: protobuf-binary\n is non-canonical by protocol rule, and a stored-inputs-only row would stop\n re-verifying the day the canonicalization recipe moved.\n\n TRUST BOUNDARY. Offline re-verification proves the row is INTERNALLY\n CONSISTENT: each signature verifies against the key and bytes stored in\n the same row, so anyone able to write a row could mint one that passes.\n To prove AUTHENTICITY — that these parties actually operated these keys —\n a verifier must compare the embedded keys against copies obtained\n independently. WHERE to obtain them is the whole question, and only one of\n the two sides has an anchor inside a signature.\n\n EXCHANGE SIDE — anchored in the signed bytes. offer_canonical_bytes\n carries the offer's `exchange` field (ramp.v1.Offer.exchange, the bare\n host of the issuing Exchange), and offer_sig covers it. A verifier reads\n that host OUT of the canonical bytes, fetches THAT Exchange's published\n JWKS (the authority per protocol/authentication), and checks\n exchange_signing_public_key against it. A fabricated row cannot redirect\n this step: changing `exchange` invalidates the very signature the check\n exists to confirm.\n\n AGENT SIDE — no signed anchor exists, and the row does not supply one.\n agent_directory_url is covered by NEITHER signature and is written by the\n same party as the rest of the row, so a fabricated row satisfies any\n procedure built on it using a host its author controls. It is a record of\n where this Exchange states it pinned the key — provenance, never the\n authority. The agent anchor must be obtained INDEPENDENTLY: from the\n counterparty the audit is being run for, or from the agent's own directory\n located through an identity the verifier already trusts. This is unchanged\n when agent_directory_url is '' (the agent carried no directory anchor):\n there is no fallback to reconstruct, because the field was never the\n authority to fall back from.\n\n The in-row keys are convenience copies that keep old rows verifiable after\n rotation; they are not the root of trust. After matching a key against its\n authority, a verifier should also check that key's RFC 7638 thumbprint\n against a revocation list, because a key can be rotated out BECAUSE it was\n revoked, and a revoked key must not count as authentic.\n\n There is no single list covering both keys. WBAFile.revocation_url is one\n URL per DIRECTORY, so the ramp.v1.KeyRevocationList served there can only\n enumerate that directory's own revoked keys. Each key is therefore checked\n against ITS OWN side's list, reached the same way its anchor was:\n exchange_signing_public_key against the issuing Exchange's list, reached\n from the `exchange` host inside offer_canonical_bytes; agent_public_key\n against the agent's list, reached from the independent directory that\n supplied the agent anchor above — never from agent_directory_url, which is\n provenance and not authority.\n\n SCOPE OF THE GUARANTEE. The signatures cover what was AGREED, not what was\n DELIVERED. transaction_id, request_correlation, broker, created_at and\n agent_directory_url are this Exchange's own assertions, outside both\n signatures; the delivery witness is the edge delivery log, reconciled\n separately (the join key, sha256 of the signed retrieval URL, lives on\n TransactionState.signed_url_hash). agent_directory_url is listed here as well as under the\n trust boundary above because it is the one unsigned field a reader is most\n likely to mistake for an anchor.\n\n WHAT A ROW HOLDER CAN REPLAY. The delivery section below withholds the\n signed retrieval URL because it is a live bearer capability. Applying the\n same test to what the row DOES carry gives two different answers.\n\n THE OFFER IS REPLAYABLE, AND THAT IS A STATED RESIDUAL RISK. offer_json\n plus offer_sig are a complete, valid, Exchange-signed offer.\n ramp.v1.Offer binds NO requester and NO tenant — it has no audience field\n naming who the offer was issued to — and its expires_at is optional. So a\n row holder can present this same offer to its issuing Exchange and accept\n it under their OWN identity, and nothing inside the signed bytes\n contradicts them. Three things bound that, and none of them closes it:\n - expires_at ends the window, when the Exchange set one;\n - Offer.exchange names exactly one Exchange that will accept the offer,\n so a replay is confined to that Exchange's own terms and billing;\n - the network-layer reachability restriction on this plane (see the file\n header) decides who can read a row at all.\n Closing it needs a requester audience INSIDE the signed offer, which\n belongs upstream in ramp.v1 and is not something this plane can add.\n\n THE ACCEPTANCE IS NOT USEFULLY REPLAYABLE. The four fields of\n ramp.v1.AgentAcceptancePayload are offer_sig, requester_id,\n requester_domain and idempotency_key — the last of which this row stores\n under the name request_idempotency_key, to keep it distinct from the\n derived per-item key on TransactionState. agent_acceptance_signature is the\n signature over those four, so the row does hold a complete, resubmittable\n acceptance. Resubmitting it achieves nothing: it carries the same\n request-level idempotency key under the same acceptance identity, so it\n lands in the same dedupe namespace and the Exchange returns the original\n result instead of executing again. What the row does NOT hold is the\n agent's private key, so a holder cannot mint an acceptance for a different\n offer, identity, or key. The replay exposure here is the offer's, not the\n acceptance's.")); + +export const TransactionItemSchema = wire(z.object({ "agent_acceptance": z.object({ "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload\n bytes (see the canonical-signing definition on Offer.signature). Same rule\n as ramp.v1.Offer.signature — the same 128-character hex shape, either\n case, because it is the same kind of value produced by the same\n convention.\n\nThe pattern replaced a bare min_len: 1, which it subsumes: a 128-character\n string cannot be empty. Nothing conformant is refused that was accepted\n before — a signature outside this shape could never hex-decode into 64\n bytes and so could never verify, so it failed at the verify step instead,\n later and with a worse error."), "signature_algorithm": z.string().describe("Signature algorithm; \"EdDSA\" for Ed25519.").default("") }).describe("The agent's detached acceptance signature over this item's `offer`.\n Optional on the wire; the Exchange enforces presence per\n item at the service layer for relayed batches. Signed bytes = the canonical\n AgentAcceptancePayload form, with requester_* and idempotency_key\n taken from the ENCLOSING TransactionRequest and offer_sig = offer.signature.").optional(), "offer": z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing."), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("ResourceAttestation — Signed envelope of claims from a trusted party.\n\nA provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("Signed attestations about the resource at this URI.\n Attestations provide cryptographic proof of\n resource properties from trusted parties (providers or verification vendors).\n\nThree 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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("When the offered data was current. For dynamic resources\n (resource_mutability = DYNAMIC), this is the snapshot timestamp.\n Enables the Broker to evaluate freshness: \"this credit report\n reflects data as of March 18\" or \"this drug database was updated today.\"\n\nNot 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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("C2PA content credentials manifest URI.\n Points to a sidecar or embedded C2PA manifest for this resource.\n C2PA-aware agents MAY follow this URI to validate the full provenance\n chain (creator identity, transformation history, ingredient composition)\n using C2PA libraries (JUMBF/COSE Sign1). C2PA-unaware agents can rely\n on c2pa_status and c2pa-bridged attestation claims instead.\n\nFormats:\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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("Summary validation status of the C2PA manifest.\n Populated by the Exchange or a verification vendor after validating\n the C2PA manifest. Enables agents to filter for provenance-verified\n content without parsing JUMBF/COSE themselves.\n 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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("Hash of the content. Interpretation depends on hash_method:\n \"simhash-v1\" → locality-sensitive hash, for fuzzy dedup (Level 1)\n \"sha256\" → exact-match integrity hash (Level 2)\n\nLevel 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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Signals whether this resource's content is stable, changes over time,\n or does not exist at offer time (live streaming).\n 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 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": z.string().describe("Soft binding hash — content-derived identifier that survives format\n transcoding (resolution changes, compression, PDF-to-text extraction).\n Extracted from C2PA soft binding assertion when present.\n Enables post-delivery verification when the hard binding hash breaks\n due to legitimate format conversion.\n\nAlgorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("Preview — Lightweight resource preview for offer evaluation.\n\nThe Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("Lightweight previews for offer evaluation.\n The Exchange holds URLs (50–200 bytes each); the provider's CDN serves\n the actual bytes. Agents fetch previews only when evaluating offers —\n not on every discovery query. Multiple previews at different sizes\n allow agents to pick the cheapest fetch for their evaluation needs.\n\nPer 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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("REQUIRED. A DETACHED Ed25519 signature over the canonical serialization of\n the ENTIRE Offer, carried as the raw 64 signature bytes in lowercase or\n uppercase hex — 128 hex characters, NOT a JWS. There is no JOSE header and\n no compact serialization here: the signed bytes are defined by the\n canonical-signing recipe below, and this field carries only the signature\n itself. `signature_algorithm` names the algorithm separately.\n Same convention as `AgentAcceptance.signature`, and it is what the admin\n plane's offline verification recipe replays. The hex shape is STATED here\n and ENFORCED there: this field carries no schema rule, while\n ramp.admin.v1.TransactionEvidence.offer_sig — the same value, copied into\n the evidence row — is pattern-bound to 128 hex characters.\n\nThe signature covers every field, including `pricing`, `terms` (the full\n licensing payload), `expires_at`, and `exchange`. Only `signature` and\n `signature_algorithm` are excluded from the signed bytes. `expires_at` is\n signed so the offer's validity window is integrity-protected: a relaying\n Broker cannot extend (or shorten) the TTL of a signed offer to replay it\n outside the window the Exchange intended.\n\n CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.\n\n The rule is the hex shape this comment already describes: 128 characters,\n either case, which is one Ed25519 signature (64 bytes) hex-encoded. Either\n case is accepted because hex decoding accepts both and a dispute should\n read the same characters a request log holds; every SDK in this repo emits\n lowercase. The pattern also makes the field mandatory in practice — the\n empty string does not match it — which restates what this message already\n requires: an unsigned Offer is not an Offer, since the signature is what\n makes its terms, pricing and expiry non-repudiable."), "signature_algorithm": z.string().describe("Signature algorithm. Always 'EdDSA' — the JOSE algorithm identifier for\n Ed25519 (RFC 8032). Only the identifier is borrowed from JOSE: `signature`\n is a detached hex signature, not a JWS.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("SubscriptionQuotaInfo — Proactive quota signaling for subscription access.\n\nAnalogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent's delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:[\"subscription:...\"].\n\nCoverage 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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("LicenseTerm — Universal licensing unit.\n\nOne LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "title": z.string().describe("Resource title (human-readable, for display/logging).").optional() }).describe("The FULL signed Offer for this batch entry, reflected back exactly as\n received at discovery. The Exchange verifies `offer.signature` over these\n presented bytes — stateless, no reconstruct-from-catalog. REQUIRED: every\n batch item carries its offer.") }).describe("TransactionItem — A single offer commitment within a batch transaction.")); + +export const TransactionRequestSchema = wire(z.object({ "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "idempotency_key": z.string().min(1).max(255).describe("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\nDEDUPE SCOPE — the invariant, stated here once and cited by every other RPC\n that carries an idempotency_key: a key chosen by one caller MUST NEVER\n collide with another caller's cached result. The server dedupes within a\n namespace, never globally. What that namespace IS differs per RPC, because\n the RPCs do not authenticate the same way; each states its own, and each\n namespace has to make the invariant true on its own terms.\n\n For this RPC the namespace is the ACCEPTANCE IDENTITY — the agent key\n defined under \"Agent identity\" on AgentAcceptance — never the transport\n sender, which may be a Broker relaying many agents behind one key. The\n server dedupes per (acceptance identity, key)."), "items": z.array(z.object({ "agent_acceptance": z.object({ "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload\n bytes (see the canonical-signing definition on Offer.signature). Same rule\n as ramp.v1.Offer.signature — the same 128-character hex shape, either\n case, because it is the same kind of value produced by the same\n convention.\n\nThe pattern replaced a bare min_len: 1, which it subsumes: a 128-character\n string cannot be empty. Nothing conformant is refused that was accepted\n before — a signature outside this shape could never hex-decode into 64\n bytes and so could never verify, so it failed at the verify step instead,\n later and with a worse error."), "signature_algorithm": z.string().describe("Signature algorithm; \"EdDSA\" for Ed25519.").default("") }).describe("The agent's detached acceptance signature over this item's `offer`.\n Optional on the wire; the Exchange enforces presence per\n item at the service layer for relayed batches. Signed bytes = the canonical\n AgentAcceptancePayload form, with requester_* and idempotency_key\n taken from the ENCLOSING TransactionRequest and offer_sig = offer.signature.").optional(), "offer": z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("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.\n\nHEX, like every other detached signature in this contract: 128 characters,\n either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as\n ramp.v1.Offer.signature — the same shape, for the same reason.\n\n The encoding was previously unstated, which was the real defect — the\n comment described the signed BYTES precisely and never said how the\n signature itself is written, so a vendor had to guess. Two vendors guessing\n differently is exactly the failure the hex settlement exists to prevent, and\n an attestation is the worst place for it: the verifying party is a third\n party who never negotiated with the reader.\n\n The rule also makes the field mandatory in practice, since the empty string\n does not match. That restates what this message already means. An\n attestation is a signed third-party claim; without the signature it is an\n unverifiable assertion by an unproven author, which is Level 0 — no\n attestation present — rather than an attestation with a field missing."), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("ResourceAttestation — Signed envelope of claims from a trusted party.\n\nA provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("Signed attestations about the resource at this URI.\n Attestations provide cryptographic proof of\n resource properties from trusted parties (providers or verification vendors).\n\nThree 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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("When the offered data was current. For dynamic resources\n (resource_mutability = DYNAMIC), this is the snapshot timestamp.\n Enables the Broker to evaluate freshness: \"this credit report\n reflects data as of March 18\" or \"this drug database was updated today.\"\n\nNot 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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("C2PA content credentials manifest URI.\n Points to a sidecar or embedded C2PA manifest for this resource.\n C2PA-aware agents MAY follow this URI to validate the full provenance\n chain (creator identity, transformation history, ingredient composition)\n using C2PA libraries (JUMBF/COSE Sign1). C2PA-unaware agents can rely\n on c2pa_status and c2pa-bridged attestation claims instead.\n\nFormats:\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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("Summary validation status of the C2PA manifest.\n Populated by the Exchange or a verification vendor after validating\n the C2PA manifest. Enables agents to filter for provenance-verified\n content without parsing JUMBF/COSE themselves.\n 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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("Hash of the content. Interpretation depends on hash_method:\n \"simhash-v1\" → locality-sensitive hash, for fuzzy dedup (Level 1)\n \"sha256\" → exact-match integrity hash (Level 2)\n\nLevel 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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("Signals whether this resource's content is stable, changes over time,\n or does not exist at offer time (live streaming).\n 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 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": z.string().describe("Soft binding hash — content-derived identifier that survives format\n transcoding (resolution changes, compression, PDF-to-text extraction).\n Extracted from C2PA soft binding assertion when present.\n Enables post-delivery verification when the hard binding hash breaks\n due to legitimate format conversion.\n\nAlgorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("Preview — Lightweight resource preview for offer evaluation.\n\nThe Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("Lightweight previews for offer evaluation.\n The Exchange holds URLs (50–200 bytes each); the provider's CDN serves\n the actual bytes. Agents fetch previews only when evaluating offers —\n not on every discovery query. Multiple previews at different sizes\n allow agents to pick the cheapest fetch for their evaluation needs.\n\nPer 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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().regex(new RegExp("^[0-9A-Fa-f]{128}$")).describe("REQUIRED. A DETACHED Ed25519 signature over the canonical serialization of\n the ENTIRE Offer, carried as the raw 64 signature bytes in lowercase or\n uppercase hex — 128 hex characters, NOT a JWS. There is no JOSE header and\n no compact serialization here: the signed bytes are defined by the\n canonical-signing recipe below, and this field carries only the signature\n itself. `signature_algorithm` names the algorithm separately.\n Same convention as `AgentAcceptance.signature`, and it is what the admin\n plane's offline verification recipe replays. The hex shape is STATED here\n and ENFORCED there: this field carries no schema rule, while\n ramp.admin.v1.TransactionEvidence.offer_sig — the same value, copied into\n the evidence row — is pattern-bound to 128 hex characters.\n\nThe signature covers every field, including `pricing`, `terms` (the full\n licensing payload), `expires_at`, and `exchange`. Only `signature` and\n `signature_algorithm` are excluded from the signed bytes. `expires_at` is\n signed so the offer's validity window is integrity-protected: a relaying\n Broker cannot extend (or shorten) the TTL of a signed offer to replay it\n outside the window the Exchange intended.\n\n CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.\n\n The rule is the hex shape this comment already describes: 128 characters,\n either case, which is one Ed25519 signature (64 bytes) hex-encoded. Either\n case is accepted because hex decoding accepts both and a dispute should\n read the same characters a request log holds; every SDK in this repo emits\n lowercase. The pattern also makes the field mandatory in practice — the\n empty string does not match it — which restates what this message already\n requires: an unsigned Offer is not an Offer, since the signature is what\n makes its terms, pricing and expiry non-repudiable."), "signature_algorithm": z.string().describe("Signature algorithm. Always 'EdDSA' — the JOSE algorithm identifier for\n Ed25519 (RFC 8032). Only the identifier is borrowed from JOSE: `signature`\n is a detached hex signature, not a JWS.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("SubscriptionQuotaInfo — Proactive quota signaling for subscription access.\n\nAnalogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("Canonical identity of the license document (RFC 3986). MUST NOT be\n URL-validated — data-labels TDL identifiers use non-URL schemes.\n For REFERENCE_ONLY terms this is the authoritative specification.\n Examples:\n \"https://creativecommons.org/licenses/by/4.0/\"\n \"https://techcrunch.com/licensing/ai-terms-2026\"\n\n\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Cryptographic digest of the document at `uri`, in \"method:hexdigest\" form\n (e.g. \"sha256:9f86d081...\"). Pins the referenced document so a consumer can\n verify the bytes it fetches match what was offered; covered by the offer\n signature, so it is tamper-evident end to end. REQUIRED whenever `uri` is\n non-empty — any semantics, mutable or not: without a pinned digest a MitM\n (or the publisher) can swap the document the agent reads. The Exchange pins\n it at ingestion (computing it over the safely-fetched document, or\n accepting a publisher-supplied value when uri is not HTTP-fetchable, e.g. a\n non-URL TDL scheme).\n\nThe 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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Obligation — A post-use behavioral requirement attached to a LicenseTerm.\n\nExamples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("Metering basis — the \"per what\" of PER_UNIT pricing. REQUIRED when\n model = PER_UNIT. Custom units namespace as \"vendor:unit\". Ignored for\n FREE / FLAT.\n\nThe (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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("The unit being capped — an open vocabulary axis.\n\nThe (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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quota — A usage cap that gates whether this LicenseTerm remains valid.\n\nQuotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restriction — A single constraint on one licensing dimension.\n\nRestrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("Delegation scope-gating: the Exchange returns this term to an agent iff the\n agent's delegation grant covers ALL of these scopes (AND-semantics).\n Empty = public. A subscription term is Pricing{model:FREE} +\n scopes:[\"subscription:...\"].\n\nCoverage 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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("LicenseTerm — Universal licensing unit.\n\nOne LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional(), "title": z.string().describe("Resource title (human-readable, for display/logging).").optional() }).describe("The FULL signed Offer for this batch entry, reflected back exactly as\n received at discovery. The Exchange verifies `offer.signature` over these\n presented bytes — stateless, no reconstruct-from-catalog. REQUIRED: every\n batch item carries its offer.") }).describe("TransactionItem — A single offer commitment within a batch transaction.")).min(1).describe("The offers committed in this request (REQUIRED, min 1), each carrying its\n own reflected signed Offer + detached acceptance. A single offer is the\n degenerate 1-element list. The Exchange verifies each item's\n `offer.signature` (which covers pricing, terms, and expires_at) over the\n presented bytes against its own key — stateless, self-contained bearer\n tokens, with no reconstruct-from-catalog."), "requester": z.object({ "delegation": z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this delegation expires. Exchange MUST reject expired tokens.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "issuer": z.string().describe("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.").optional(), "max_accesses": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "max_spend_cents": z.coerce.number().int().describe("Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.").optional(), "principal_domain": z.string().describe("Who granted this delegation (domain for public key lookup).").default(""), "principal_id": z.string().describe("Principal's identifier (e.g., \"user@acme.com\", \"marketdata.example.com\").").default(""), "quota_period": z.string().describe("Quota reset period. How often the access/spend counters reset.\n Example: 30 days for monthly subscriptions — \"2592000s\" on the wire\n (proto-JSON encodes Duration as seconds; \"720h\" is not accepted).\n When absent, the quota is lifetime (bounded only by expires_at).").optional(), "revocation_uri": z.string().describe("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).").optional(), "scopes": z.array(z.string()).describe("Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).").optional(), "token": z.string().regex(new RegExp("^[A-Za-z0-9+/]*={0,2}$")).describe("Token bytes. A JWT (base64url-encoded JWS).").default(""), "token_format": z.string().describe("Token format: \"jwt\" (default). Empty is treated as \"jwt\". The field stays\n open for a future format.").default("") }).describe("Optional delegation — present when the requester acts on behalf of\n another entity (user, organization, upstream agent).").optional(), "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Domain the requester belongs to — used for public key lookup, so the value\n is concatenated into a URL the verifier fetches ({domain}/.well-known/ramp.json,\n WellKnownManifest with role=ROLE_AGENT). It carries the same bare-host shape\n \"Request recipient\" defines in the file header, for the same structural\n reason: a scheme, path or query smuggled in here would choose what gets\n fetched, not merely from where."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "id": z.string().describe("Unique requester identifier (e.g., \"agent-research-bot-001\").").default(""), "name": z.string().describe("Human-readable name (e.g., \"Acme Research Assistant\").").optional(), "scopes": z.array(z.string()).max(64).describe("Entitlement scopes. Declare what the requester can access.\n\nThe 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).").optional(), "type": z.enum(["REQUESTER_TYPE_AGENT","REQUESTER_TYPE_HUMAN_TOOL","REQUESTER_TYPE_SERVICE","REQUESTER_TYPE_DELEGATED","REQUESTER_TYPE_RESEARCH"]).describe("What kind of entity is making this request.") }).describe("Requester identity — forwarded for authorization and audit.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("TransactionRequest — Commit to one or more offers.\n\nAfter selecting offers, the caller commits by sending this to the\n Exchange. Supports both single-offer and batch (multi-offer) modes.\n The Exchange validates eligibility, authorizes billing, creates\n delivery, and logs each transaction.")); -export const TransactionRequestSchema = wire(z.object({ "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "idempotency_key": z.string().min(1).max(255).describe("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": z.array(z.object({ "agent_acceptance": z.object({ "signature": z.string().min(1).describe("Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload\n bytes (see the canonical-signing definition on Offer.signature)."), "signature_algorithm": z.string().describe("Signature algorithm; \"EdDSA\" for Ed25519.").default("") }).describe("The agent's detached acceptance signature over this item's `offer`.\n Optional on the wire; the Exchange enforces presence per\n item at the service layer for relayed batches. Signed bytes = the canonical\n AgentAcceptancePayload form, with requester_* and idempotency_key\n taken from the ENCLOSING TransactionRequest and offer_sig = offer.signature.").optional(), "offer": z.object({ "attestations": z.array(z.object({ "attested_at": z.string().datetime({ offset: true }).describe("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\").").optional(), "claims": z.record(z.string(), z.any()).describe("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\"].").optional(), "keyid": z.string().describe("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.").default(""), "signature": z.string().describe("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.").default(""), "uri": z.string().describe("The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.").default(""), "verifier": z.string().describe("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").default("") }).describe("A provider or third-party verification vendor (GumGum, DoubleVerify, IAS)\n attests to properties of the resource at a specific URI at a specific time.\n The signature covers all fields, proving origin and integrity of the claims.\n\n Verification levels (determined by who the verifier is):\n Level 0: No attestation present. Resource may carry identifiers\n (DOI, IPTC GUID via ResourceIdentity) but nothing is cryptographically\n verifiable. Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): verifier == provider domain. Provider signs\n own claims with their Ed25519 key. Agent can independently verify\n content_hash by re-computing it from delivered bytes. Requires the\n provider to serve deterministic content at the delivery endpoint.\n Level 2 (third-party attested): verifier == verification vendor domain.\n Vendor independently crawled the resource and attested to its properties.\n Agent trusts the attestation — does NOT re-verify the content hash\n (agent lacks the vendor's extraction algorithm). The Ed25519 signature\n proves the vendor made the attestation; trust is binary (\"do I trust\n this vendor?\").\n\n Claims are limited to 4KB. Attestations are carried in-memory in the\n Exchange catalog and in Offer responses — strict size limits protect\n against payload poisoning and ensure catalog performance at scale.\n\n Verifiers MUST publish their attestation-signing keys in their WBA directory\n (WBAFile.keys) at:\n https://{verifier-domain}/.well-known/http-message-signatures-directory\n identified by RFC 7638 thumbprint. Verifiers publish the claims-schema\n structure at WellKnownManifest.ext[\"ramp.attestation.claims_schema\"].")).describe("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.").optional(), "data_as_of": z.string().datetime({ offset: true }).describe("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.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource will be delivered.").default(0), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the Exchange that issued this offer (e.g.\n \"exchange.example\" or \"exchange.example:8081\"), in the form \"Request\n recipient\" defines in the file header. This is the execute-routing target:\n the agent, or a relaying Broker, sends the ExecuteTransaction call for this\n offer to this Exchange, and a Broker relaying a mixed batch groups the items\n by this value. Because it is an ordinary Offer field it falls inside the\n signed bytes (see `signature` below — the signature covers every field\n except `signature` / `signature_algorithm`), so an intermediary cannot\n redirect the execute call to a different Exchange without invalidating the\n offer, and it is what retires the X-RAMP-Exchange-Endpoint transport header.\n It is also the audience statement of an ExecuteTransaction, which is why\n TransactionRequest carries no top-level `exchange`: on receipt, an Exchange\n MUST reject the request unless EVERY item's offer.exchange names its own\n domain. Presence is enforced because an empty value is unroutable — a\n relaying Broker has nothing to group or dial on, and the swap-protection\n above is vacuous when the signed bytes carry no recipient at all."), "expires_at": z.string().datetime({ offset: true }).describe("When this offer expires (ISO 8601).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "iab_categories": z.array(z.string()).describe("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.").optional(), "identity": z.object({ "c2pa_manifest": z.string().describe("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=...").optional(), "c2pa_status": z.enum(["C2PA_STATUS_TRUSTED","C2PA_STATUS_VALID","C2PA_STATUS_INVALID","C2PA_STATUS_ABSENT"]).describe("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.").optional(), "canonical_url": z.string().describe("Provider's authoritative URL for this resource (rel=\"canonical\").\n Always available. Different per provider for syndicated content.").optional(), "content_hash": z.string().describe("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.").optional(), "doi": z.string().describe("Digital Object Identifier — persistent, never changes.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "hash_method": z.string().describe("Hash algorithm and verification level.\n Examples: \"simhash-v1\", \"minhash-v1\", \"sha256\", \"sha384\"").optional(), "iptc_guid": z.string().describe("IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).").optional(), "isni": z.string().describe("International Standard Name Identifier for the creator.").optional(), "resource_mutability": z.enum(["RESOURCE_MUTABILITY_STATIC","RESOURCE_MUTABILITY_DYNAMIC","RESOURCE_MUTABILITY_LIVE"]).describe("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": z.string().describe("Algorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).").optional(), "soft_binding_method": z.string().describe("Algorithm used for soft_binding.\n Examples: \"phash-v1\" (perceptual hash), \"c2pa-watermark\" (C2PA invisible\n watermark), \"chromaprint\" (audio fingerprint).").optional() }).describe("Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.").optional(), "offer_id": z.string().describe("Unique identifier for this offer, assigned by the Exchange.").default(""), "previews": z.array(z.object({ "duration": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Duration in seconds (for audio and video clips).").optional(), "height": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Height in pixels (images and video)").optional(), "media_type": z.string().describe("MIME type of the preview.\n Examples: \"image/jpeg\", \"image/webp\", \"audio/mpeg\", \"video/mp4\",\n \"text/plain\", \"application/json\"").default(""), "size": z.string().describe("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)").optional(), "url": z.string().describe("URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.").default(""), "width": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Dimensions in pixels (for images and video).").optional() }).describe("The Exchange holds URLs (50–200 bytes per preview); the provider's\n CDN serves the actual bytes. This follows the universal pattern:\n Shutterstock (multi-size thumbnail URLs), Spotify (preview_url to\n 30s clip), IIIF (parameterized image URLs), OpenRTB (img.url + dims).\n\n Previews are free to fetch — no RAMP transaction required. They are\n the equivalent of looking at a book cover before buying. Providers\n MAY watermark visual previews or truncate text/audio previews.\n\n The Exchange populates preview URLs during catalog ingestion. Preview\n URLs MAY be signed with a short TTL to prevent hotlinking, or public\n (provider's choice). Agents fetch previews only when evaluating\n offers, not on every discovery query.")).describe("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).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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.").optional(), "reporting": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Post-usage reporting requirements for this offer.").optional(), "signature": z.string().describe("CANONICAL SIGNING (RFC 8785 JCS over canonical proto-JSON). The signed bytes\n are:\n\n signed_payload = JCS( protojson(msg with signature +\n signature_algorithm cleared) )\n\n i.e. render the message to canonical proto-JSON with the PINNED option set\n below, then apply RFC 8785 (JSON Canonicalization Scheme). Deterministic\n protobuf BINARY marshaling is explicitly NOT canonical across languages and\n versions (protobuf's own caveat), so it cannot be a cross-language signing\n primitive; JCS over proto-JSON can be reproduced by ANY language (Go, TS,\n Python) without a protobuf binary codec, so a broker/exchange/client in any\n language signs and verifies byte-identically. This same definition applies to\n the agent offer-acceptance signature (AgentAcceptance.signature).\n\n PINNED proto-JSON option set (the arbiter is the Go-emitted golden vector —\n whatever these options render MUST be byte-identical across all languages):\n - enum values as NAME strings (not numbers);\n - int64 / uint64 / fixed64 as decimal STRINGS;\n - bytes as standard (padded) base64;\n - google.protobuf.Timestamp / Duration per the proto-JSON WKT rules\n (RFC 3339 string for Timestamp);\n - unpopulated fields are OMITTED (never emitted as defaults);\n - field naming is snake_case (the proto field name, UseProtoNames=true),\n the naming every SDK target shares — wire, corpus, and signed form are all\n snake_case;\n - google.protobuf.Struct (`ext`) → a plain JSON object; JCS then sorts its\n keys recursively, so the Struct case needs no special handling.\n\n UNKNOWN FIELDS. A canonicalizer either OMITS content it has no schema for or\n PRESERVES it, and the rule follows from which:\n\n - OMITTING (e.g. proto-JSON, which emits only schema-defined fields): such a\n canonicalizer CANNOT reproduce the signed bytes of a message carrying\n unknown fields — what it renders silently drops part of what the signer\n covered. It MUST refuse the message rather than emit the reduced bytes,\n and a verifier built on it MUST reject rather than verify over them. The\n refusal binds at EVERY depth: a nested message and each element of a\n repeated or map field carries its own unknown-field set.\n - PRESERVING (a canonicalizer that carries unrecognized members through):\n it reproduces the signed bytes faithfully, so there is nothing to refuse.\n\n Either way an APPENDED field cannot pass: an omitting canonicalizer refuses\n the message, and a preserving one renders the appended member into bytes the\n signer never covered, so the signature fails. Without the refusal the omitting\n case would fail OPEN — an intermediary could add unknown fields to an\n already-signed message and leave its signature verifying, smuggling\n unauthenticated content through a message the recipient treats as verified.\n\n Extensions therefore ride in `ext` / `ext_critical`, which are defined fields\n and inside the signed bytes — never as undeclared field numbers.\n\n 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.").default(""), "signature_algorithm": z.string().describe("JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization.").default(""), "subscription_id": z.string().describe("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.").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("Analogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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).").optional(), "terms": z.array(z.object({ "license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "obligations": z.array(z.object({ "detail": z.string().describe("Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.").optional(), "kind": z.enum(["OBLIGATION_KIND_ATTRIBUTION","OBLIGATION_KIND_CONTRIBUTION","OBLIGATION_KIND_SHARE_ALIKE","OBLIGATION_KIND_NETWORK_COPYLEFT","OBLIGATION_KIND_NOTICE","OBLIGATION_KIND_OTHER"]).describe("What the agent must do."), "scope_license": z.object({ "id": z.string().describe("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.").optional(), "immutable": z.boolean().describe("Data-labels TDL: the document at uri is versioned and will not change.").optional(), "name": z.string().describe("Human-readable name (licenseType, schema.org node name).").optional(), "uri": z.string().describe("\"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.").optional(), "uri_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("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.").optional() }).describe("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.").optional(), "trigger": z.enum(["OBLIGATION_TRIGGER_ON_USE","OBLIGATION_TRIGGER_ON_DISTRIBUTION","OBLIGATION_TRIGGER_ON_NETWORK_SERVICE","OBLIGATION_TRIGGER_ON_DERIVATIVE"]).describe("When the obligation activates.") }).describe("Examples:\n Attribution on display: cite the author whenever content is shown to a user.\n Share-alike on derivative: AI-generated content that incorporates this work\n must be released under the same license.\n Notice on distribution: include the copyright notice when distributing copies.")).describe("Post-use behavioral requirements.").optional(), "part_label": z.string().describe("Informational human-readable name for this sub-part (sub-part terms).").optional(), "pricing": z.object({ "currency": z.string().describe("ISO 4217 currency code (e.g. \"USD\", \"EUR\").").default(""), "estimated_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "license_duration_months": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("License duration in months. How long the granted access remains valid.").optional(), "metering": z.enum(["PRICING_METERING_ONLINE","PRICING_METERING_NONE","PRICING_METERING_OFFLINE_SELF_REPORTED"]).describe("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.").optional(), "model": z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"]).describe("Provider's pricing model."), "rate": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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`.").default(""), "unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("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).").optional() }).describe("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": z.array(z.object({ "limit": z.coerce.number().int().gte(1).describe("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": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$")).max(64).describe("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": z.enum(["QUOTA_WINDOW_HOURLY","QUOTA_WINDOW_DAILY","QUOTA_WINDOW_MONTHLY","QUOTA_WINDOW_TOTAL"]).describe("Time window over which the limit accumulates.") }).describe("Quotas limit how much a licensee may consume before the term expires or\n must be renegotiated. They are NOT billing quantities — billing is in Pricing.\n\n The metric vocabulary is authored ONLY in the (ramp.v1.vocab) entries on\n Quota.metric below; the quotametrics constants + IsRegistered derive from it.")).describe("Usage caps. The agent must not exceed any individual Quota.").optional(), "restrictions": z.array(z.object({ "advisory": z.boolean().describe("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.").default(false), "kind": z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"]).describe("Which dimension this restriction applies to."), "permitted": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("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\", …").optional(), "prohibited": z.array(z.string().regex(new RegExp("^[A-Za-z0-9._:*-]+$")).min(1).max(64)).max(64).describe("Tokens blocked on this axis. Takes precedence over permitted[].").optional() }).describe("Restrictions model allowed and prohibited values on one axis (function,\n geography, or user-type). They are validated and normalized at ingest and\n RIDE ON THE OFFER: the AGENT is the responsible party — it self-selects the\n term whose restrictions it can honour and bears compliance, and enforcement\n happens downstream at accept → report → reconcile. Restrictions are NOT an\n Exchange-side gate the requester must pass to see a term.\n\n An Exchange or Broker MAY, purely as a CONVENIENCE, pre-filter the offers it\n returns against the limits the query states in ResourceQuery.acceptable_restrictions\n (the same RestrictionKind axes/vocabulary the terms use) — e.g. an agent that\n only wants US-eligible content can ask the Exchange to skip the rest so it\n doesn't pay to discover offers it would never accept. That filter is advisory and\n optional: a different Broker may not apply it, and it is a recommendation\n matched to the request, never an enforcement verdict. When an Exchange does\n drop offers this way it MAY signal it via OfferAbsenceReason.RESTRICTION_FILTERED\n (with the axes in OfferGroup.restriction_filters). Term visibility is otherwise\n gated only by resource_id/URI and delegation scope coverage — see\n LicenseTerm.scopes.\n\n Reading a restriction:\n A value is in-scope when it matches at least one permitted[] token\n AND matches none of the prohibited[] tokens.\n Empty permitted[] = any value is permitted on this axis.\n Empty prohibited[] = nothing is explicitly prohibited.\n\n Vocabulary sources (authored on the RestrictionKind enum values via\n (ramp.v1.vocab_enum); the functiontokens / geographytokens / usertypes\n constants + IsRegistered derive from them):\n FUNCTION — RSL 1.0 AI-use vocabulary + established IP/copyright terms\n GEOGRAPHY — ISO 3166-1 alpha-2 (structural) + the specials *, EU, EEA\n USER_TYPE — RAMP user/organization categories")).describe("Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.").optional(), "scopes": z.array(z.string()).max(64).describe("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.").optional(), "semantics": z.enum(["TERM_SEMANTICS_ENUMERATED","TERM_SEMANTICS_REFERENCE_ONLY"]).describe("How to interpret the machine fields.") }).describe("One LicenseTerm describes one complete access arrangement for a resource.\n A resource carries zero or more terms; having multiple terms is the normal\n case (one per use category, user type, or commercial arrangement).\n\n The same LicenseTerm shape appears at ingestion (ResourceEntry.terms) and\n at emission (Offer.terms). The Exchange stores what the publisher pushed\n and surfaces it on discovery, so agents see the same terms the publisher\n declared — no translation or reformulation.\n\n Validation rules:\n - Pricing MUST be present on EVERY term, regardless of semantics.\n Absent Pricing → reject at ingest: an agent cannot act on a term with\n no price. This holds for REFERENCE_ONLY too — its License governs the\n human-readable terms, but the machine-readable price is still stated\n here, not deferred to the document.\n - model=FREE must be explicit. Absent Pricing ≠ free. A term may be FREE\n under an arbitrary license; the agent still needs the price stated so it\n knows the access is free rather than unpriced.\n - REFERENCE_ONLY terms MUST carry a License with a non-empty uri. A\n REFERENCE_ONLY term that references no document is meaningless → reject\n at ingest.\n - Restriction tokens are validated against the vocab registry.\n Unknown tokens produce a PushResourcesResponse.warnings[] entry\n but do NOT cause rejection (forward-compatible).")).describe("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.").optional() }).describe("The FULL signed Offer for this batch entry, reflected back exactly as\n received at discovery. The Exchange verifies `offer.signature` over these\n presented bytes — stateless, no reconstruct-from-catalog. REQUIRED: every\n batch item carries its offer.") }).describe("TransactionItem — A single offer commitment within a batch transaction.")).min(1).describe("The offers committed in this request (REQUIRED, min 1), each carrying its\n own reflected signed Offer + detached acceptance. A single offer is the\n degenerate 1-element list. The Exchange verifies each item's\n `offer.signature` (which covers pricing, terms, and expires_at) over the\n presented bytes against its own key — stateless, self-contained bearer\n tokens, with no reconstruct-from-catalog.").optional(), "requester": z.object({ "delegation": z.object({ "expires_at": z.string().datetime({ offset: true }).describe("When this delegation expires. Exchange MUST reject expired tokens.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "issuer": z.string().describe("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.").optional(), "max_accesses": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "max_spend_cents": z.coerce.number().int().describe("Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.").optional(), "principal_domain": z.string().describe("Who granted this delegation (domain for public key lookup).").default(""), "principal_id": z.string().describe("Principal's identifier (e.g., \"user@acme.com\", \"marketdata.example.com\").").default(""), "quota_period": z.string().describe("Quota reset period. How often the access/spend counters reset.\n Example: 30 days for monthly subscriptions — \"2592000s\" on the wire\n (proto-JSON encodes Duration as seconds; \"720h\" is not accepted).\n When absent, the quota is lifetime (bounded only by expires_at).").optional(), "revocation_uri": z.string().describe("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).").optional(), "scopes": z.array(z.string()).describe("Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).").optional(), "token": z.string().regex(new RegExp("^[A-Za-z0-9+/]*={0,2}$")).describe("Token bytes. A JWT (base64url-encoded JWS).").default(""), "token_format": z.string().describe("Token format: \"jwt\" (default). Empty is treated as \"jwt\". The field stays\n open for a future format.").default("") }).describe("Optional delegation — present when the requester acts on behalf of\n another entity (user, organization, upstream agent).").optional(), "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Domain the requester belongs to — used for public key lookup, so the value\n is concatenated into a URL the verifier fetches ({domain}/.well-known/ramp.json,\n WellKnownManifest with role=ROLE_AGENT). It carries the same bare-host shape\n \"Request recipient\" defines in the file header, for the same structural\n reason: a scheme, path or query smuggled in here would choose what gets\n fetched, not merely from where."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "id": z.string().describe("Unique requester identifier (e.g., \"agent-research-bot-001\").").default(""), "name": z.string().describe("Human-readable name (e.g., \"Acme Research Assistant\").").optional(), "scopes": z.array(z.string()).max(64).describe("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).").optional(), "type": z.enum(["REQUESTER_TYPE_AGENT","REQUESTER_TYPE_HUMAN_TOOL","REQUESTER_TYPE_SERVICE","REQUESTER_TYPE_DELEGATED","REQUESTER_TYPE_RESEARCH"]).describe("What kind of entity is making this request.") }).describe("Requester identity — forwarded for authorization and audit.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("After selecting offers, the caller commits by sending this to the\n Exchange. Supports both single-offer and batch (multi-offer) modes.\n The Exchange validates eligibility, authorizes billing, creates\n delivery, and logs each transaction.")); +export const TransactionResponseSchema = wire(z.object({ "agent_identity_hash": z.string().describe("Identity that a delivered retrieval_endpoint is bound to: the RFC 7638 JWK\n Thumbprint of the agent's Ed25519 key, as \"Agent identity\" on\n AgentAcceptance defines it — the acceptance key, not the transport signer,\n which may be a Broker. See \"Retrieval-URL identity binding\" in the file\n header for how a delivery endpoint checks the binding. Shared across the\n request; set once, which is why every acceptance in one request must be\n signed by the same key.").default(""), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "items": z.array(z.object({ "billing_id": z.string().describe("Billing record identifier minted by the Exchange's billing adapter for\n this transaction (not the account handle — see RegisterResponse.billing_ref).").default(""), "cost": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Cost for this item.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource is delivered for this item.").default(0), "denial_reason": z.enum(["DENIAL_REASON_ACCOUNT_INACTIVE","DENIAL_REASON_INSUFFICIENT_BALANCE","DENIAL_REASON_RATE_LIMITED","DENIAL_REASON_CONTENT_UNAVAILABLE","DENIAL_REASON_RESTRICTION_NOT_SATISFIED","DENIAL_REASON_REPORTING_OVERDUE","DENIAL_REASON_OFFER_EXPIRED","DENIAL_REASON_SIGNATURE_INVALID","DENIAL_REASON_QUOTA_EXCEEDED","DENIAL_REASON_DELEGATION_INVALID","DENIAL_REASON_SCOPE_INSUFFICIENT","DENIAL_REASON_ENTITLEMENT_MISSING","DENIAL_REASON_ENTITLEMENT_MALFORMED","DENIAL_REASON_ENTITLEMENT_EXPIRED","DENIAL_REASON_ENTITLEMENT_WRONG_BUYER","DENIAL_REASON_SUBSCRIPTION_LAPSED","DENIAL_REASON_ENTITLEMENT_NOT_GRANTED","DENIAL_REASON_ACCOUNT_NOT_REGISTERED"]).describe("Set if this specific item was denied (others may succeed).").optional(), "expires_at": z.string().datetime({ offset: true }).describe("When retrieval_endpoint expires.").optional(), "offer_id": z.string().describe("The offer_id this result is for.").default(""), "reporting_obligation": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Reporting requirements for this item.").optional(), "resource_title": z.string().describe("Resource title echoed from the Offer.").optional(), "restriction_mismatches": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("When denial_reason = RESTRICTION_NOT_SATISFIED, the restriction axes the\n request failed, in the same RestrictionKind vocabulary the terms use.").optional(), "retrieval_endpoint": z.string().describe("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.").optional(), "subscription_id": z.string().describe("If under subscription, no per-request charge.").optional(), "subscription_unit_value": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("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).").optional(), "transaction_id": z.string().describe("Exchange-assigned transaction identifier. Opaque to agents; the format\n is implementation-defined (the documented storage model mints a\n time-ordered ULID as the record's primary key).\n\nENTROPY. RAMP places no entropy requirement on this value. An implementer\n choosing a sequential id should know precisely what that does and does not\n cost, because the protection here is narrower than \"unguessable ids are\n unnecessary\".\n\n WHAT IS GUARANTEED. The admin plane's evidence read\n (ramp.admin.v1.GetTransactionEvidence) selects by the\n (tenant_id, transaction_id) PAIR, so a transaction id ALONE is never a\n bearer capability for the forensic row. Counterparty agents legitimately\n hold the ids of their own transactions, and that pairing is what stops one\n of those ids from reading the row on its own. That is the whole guarantee,\n and a conformance guard fails if the selector stops being a pair.\n\n WHAT IS NOT GUARANTEED: resistance to ENUMERATION. The tenant half of the\n pair is not a secret. Deployments use human brand slugs, and the slug is\n handed to every agent that holds an offer from that tenant — it prefixes\n the offer id inside the signed offer. So a caller who can reach the admin\n plane at all, and who has done business with a tenant, already knows one\n valid tenant value and can walk sequential transaction ids against it.\n What bounds that is the network-layer reachability restriction on the\n admin plane (see the ramp.admin.v1 file header): that plane must not be\n exposed on the public agent-facing listener, and it is the outer control\n an operator must not relax. An Exchange that wants enumeration resistance\n in depth should mint unguessable ids; RAMP does not require it, and no\n agent-plane behavior depends on this format either way.").default("") }).describe("TransactionResultItem — Result for a single offer in a batch transaction.")).describe("Per-offer results (one entry per committed item, in original order).").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("SubscriptionQuotaInfo — Proactive quota signaling for subscription access.\n\nAnalogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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.").optional(), "total_cost": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Aggregate cost across all items.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("TransactionResponse — Exchange confirms the transaction(s).\n\nItems-only: every per-result datum lives in `items`\n (one TransactionResultItem per committed offer, in original order); the\n top-level fields carry only the shared aggregate state. A single offer is the\n degenerate 1-element `items`. The per-item denials remain in-body on\n TransactionResultItem as partial results of a successful request.")); -export const TransactionResponseSchema = wire(z.object({ "agent_identity_hash": z.string().describe("Identity that a delivered retrieval_endpoint is bound to: the RFC 7638 JWK\n Thumbprint of the agent's Ed25519 request-signing key (see \"Retrieval-URL\n identity binding\" above). Shared across the request; set once.").default(""), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "items": z.array(z.object({ "billing_id": z.string().describe("Billing record identifier minted by the Exchange's billing adapter for\n this transaction (not the account handle — see RegisterResponse.billing_ref).").default(""), "cost": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Cost for this item.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource is delivered for this item.").default(0), "denial_reason": z.enum(["DENIAL_REASON_ACCOUNT_INACTIVE","DENIAL_REASON_INSUFFICIENT_BALANCE","DENIAL_REASON_RATE_LIMITED","DENIAL_REASON_CONTENT_UNAVAILABLE","DENIAL_REASON_RESTRICTION_NOT_SATISFIED","DENIAL_REASON_REPORTING_OVERDUE","DENIAL_REASON_OFFER_EXPIRED","DENIAL_REASON_SIGNATURE_INVALID","DENIAL_REASON_QUOTA_EXCEEDED","DENIAL_REASON_DELEGATION_INVALID","DENIAL_REASON_SCOPE_INSUFFICIENT","DENIAL_REASON_ENTITLEMENT_MISSING","DENIAL_REASON_ENTITLEMENT_MALFORMED","DENIAL_REASON_ENTITLEMENT_EXPIRED","DENIAL_REASON_ENTITLEMENT_WRONG_BUYER","DENIAL_REASON_SUBSCRIPTION_LAPSED","DENIAL_REASON_ENTITLEMENT_NOT_GRANTED","DENIAL_REASON_ACCOUNT_NOT_REGISTERED"]).describe("Set if this specific item was denied (others may succeed).").optional(), "expires_at": z.string().datetime({ offset: true }).describe("When retrieval_endpoint expires.").optional(), "offer_id": z.string().describe("The offer_id this result is for.").default(""), "reporting_obligation": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Reporting requirements for this item.").optional(), "resource_title": z.string().describe("Resource title echoed from the Offer.").optional(), "restriction_mismatches": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("When denial_reason = RESTRICTION_NOT_SATISFIED, the restriction axes the\n request failed, in the same RestrictionKind vocabulary the terms use.").optional(), "retrieval_endpoint": z.string().describe("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.").optional(), "subscription_id": z.string().describe("If under subscription, no per-request charge.").optional(), "subscription_unit_value": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("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).").optional(), "transaction_id": z.string().describe("Exchange-assigned transaction identifier.").default("") }).describe("TransactionResultItem — Result for a single offer in a batch transaction.")).describe("Per-offer results (one entry per committed item, in original order).").optional(), "subscription_quota": z.array(z.object({ "quota_limit": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Total allowed in the current period.").optional(), "quota_remaining": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Remaining in the current period.").optional(), "quota_used": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Used so far in the current period.").optional(), "resets_at": z.string().datetime({ offset: true }).describe("When the quota counter resets (UTC).").optional(), "subscription_id": z.string().describe("Subscription this quota applies to.").default(""), "unit": z.string().describe("What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: \"accesses\", \"tokens\", \"spend_cents\", \"burst\"").optional() }).describe("Analogous to RateLimitInfo (which signals API request rate limits), this\n signals subscription consumption quotas. Enables agents to throttle\n proactively instead of discovering exhaustion via denial.\n\n Returned on Offer (per-offer quota visibility) and TransactionResponse\n (post-transaction remaining quota). A subscription may have multiple\n independent quotas (access count + spend cap + burst limit), so this\n message is used as a repeated field.\n\n Quota decrement timing: the counter increments at ExecuteTransaction\n (optimistic decrement, before delivery). If delivery fails, the agent\n files a DisputeTransaction which may reverse the decrement. This is\n consistent with the billing model (billing_id created at transaction time).")).describe("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.").optional(), "total_cost": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Aggregate cost across all items.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("Items-only: every per-result datum lives in `items`\n (one TransactionResultItem per committed offer, in original order); the\n top-level fields carry only the shared aggregate state. A single offer is the\n degenerate 1-element `items`. The per-item denials remain in-body on\n TransactionResultItem as partial results of a successful request.")); +export const TransactionResultItemSchema = wire(z.object({ "billing_id": z.string().describe("Billing record identifier minted by the Exchange's billing adapter for\n this transaction (not the account handle — see RegisterResponse.billing_ref).").default(""), "cost": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Cost for this item.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource is delivered for this item.").default(0), "denial_reason": z.enum(["DENIAL_REASON_ACCOUNT_INACTIVE","DENIAL_REASON_INSUFFICIENT_BALANCE","DENIAL_REASON_RATE_LIMITED","DENIAL_REASON_CONTENT_UNAVAILABLE","DENIAL_REASON_RESTRICTION_NOT_SATISFIED","DENIAL_REASON_REPORTING_OVERDUE","DENIAL_REASON_OFFER_EXPIRED","DENIAL_REASON_SIGNATURE_INVALID","DENIAL_REASON_QUOTA_EXCEEDED","DENIAL_REASON_DELEGATION_INVALID","DENIAL_REASON_SCOPE_INSUFFICIENT","DENIAL_REASON_ENTITLEMENT_MISSING","DENIAL_REASON_ENTITLEMENT_MALFORMED","DENIAL_REASON_ENTITLEMENT_EXPIRED","DENIAL_REASON_ENTITLEMENT_WRONG_BUYER","DENIAL_REASON_SUBSCRIPTION_LAPSED","DENIAL_REASON_ENTITLEMENT_NOT_GRANTED","DENIAL_REASON_ACCOUNT_NOT_REGISTERED"]).describe("Set if this specific item was denied (others may succeed).").optional(), "expires_at": z.string().datetime({ offset: true }).describe("When retrieval_endpoint expires.").optional(), "offer_id": z.string().describe("The offer_id this result is for.").default(""), "reporting_obligation": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Reporting requirements for this item.").optional(), "resource_title": z.string().describe("Resource title echoed from the Offer.").optional(), "restriction_mismatches": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("When denial_reason = RESTRICTION_NOT_SATISFIED, the restriction axes the\n request failed, in the same RestrictionKind vocabulary the terms use.").optional(), "retrieval_endpoint": z.string().describe("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.").optional(), "subscription_id": z.string().describe("If under subscription, no per-request charge.").optional(), "subscription_unit_value": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("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).").optional(), "transaction_id": z.string().describe("Exchange-assigned transaction identifier. Opaque to agents; the format\n is implementation-defined (the documented storage model mints a\n time-ordered ULID as the record's primary key).\n\nENTROPY. RAMP places no entropy requirement on this value. An implementer\n choosing a sequential id should know precisely what that does and does not\n cost, because the protection here is narrower than \"unguessable ids are\n unnecessary\".\n\n WHAT IS GUARANTEED. The admin plane's evidence read\n (ramp.admin.v1.GetTransactionEvidence) selects by the\n (tenant_id, transaction_id) PAIR, so a transaction id ALONE is never a\n bearer capability for the forensic row. Counterparty agents legitimately\n hold the ids of their own transactions, and that pairing is what stops one\n of those ids from reading the row on its own. That is the whole guarantee,\n and a conformance guard fails if the selector stops being a pair.\n\n WHAT IS NOT GUARANTEED: resistance to ENUMERATION. The tenant half of the\n pair is not a secret. Deployments use human brand slugs, and the slug is\n handed to every agent that holds an offer from that tenant — it prefixes\n the offer id inside the signed offer. So a caller who can reach the admin\n plane at all, and who has done business with a tenant, already knows one\n valid tenant value and can walk sequential transaction ids against it.\n What bounds that is the network-layer reachability restriction on the\n admin plane (see the ramp.admin.v1 file header): that plane must not be\n exposed on the public agent-facing listener, and it is the outer control\n an operator must not relax. An Exchange that wants enumeration resistance\n in depth should mint unguessable ids; RAMP does not require it, and no\n agent-plane behavior depends on this format either way.").default("") }).describe("TransactionResultItem — Result for a single offer in a batch transaction.")); -export const TransactionResultItemSchema = wire(z.object({ "billing_id": z.string().describe("Billing record identifier minted by the Exchange's billing adapter for\n this transaction (not the account handle — see RegisterResponse.billing_ref).").default(""), "cost": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("Cost for this item.").optional(), "delivery_method": z.union([z.string().regex(new RegExp("^DELIVERY_METHOD_UNSPECIFIED$")), z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"]), z.coerce.number().int().gte(-2147483648).lte(2147483647)]).describe("How resource is delivered for this item.").default(0), "denial_reason": z.enum(["DENIAL_REASON_ACCOUNT_INACTIVE","DENIAL_REASON_INSUFFICIENT_BALANCE","DENIAL_REASON_RATE_LIMITED","DENIAL_REASON_CONTENT_UNAVAILABLE","DENIAL_REASON_RESTRICTION_NOT_SATISFIED","DENIAL_REASON_REPORTING_OVERDUE","DENIAL_REASON_OFFER_EXPIRED","DENIAL_REASON_SIGNATURE_INVALID","DENIAL_REASON_QUOTA_EXCEEDED","DENIAL_REASON_DELEGATION_INVALID","DENIAL_REASON_SCOPE_INSUFFICIENT","DENIAL_REASON_ENTITLEMENT_MISSING","DENIAL_REASON_ENTITLEMENT_MALFORMED","DENIAL_REASON_ENTITLEMENT_EXPIRED","DENIAL_REASON_ENTITLEMENT_WRONG_BUYER","DENIAL_REASON_SUBSCRIPTION_LAPSED","DENIAL_REASON_ENTITLEMENT_NOT_GRANTED","DENIAL_REASON_ACCOUNT_NOT_REGISTERED"]).describe("Set if this specific item was denied (others may succeed).").optional(), "expires_at": z.string().datetime({ offset: true }).describe("When retrieval_endpoint expires.").optional(), "offer_id": z.string().describe("The offer_id this result is for.").default(""), "reporting_obligation": z.object({ "endpoint": z.string().describe("URL to submit the usage report to (if different from Exchange).").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "required": z.boolean().describe("Whether post-usage reporting is required.").default(false), "required_fields": z.array(z.string()).describe("Field names that must be present in the report.").optional(), "window": z.string().describe("Duration within which the report must be submitted (e.g. \"86400s\" = 24\n hours; proto-JSON encodes Duration as seconds).").optional() }).describe("Reporting requirements for this item.").optional(), "resource_title": z.string().describe("Resource title echoed from the Offer.").optional(), "restriction_mismatches": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("When denial_reason = RESTRICTION_NOT_SATISFIED, the restriction axes the\n request failed, in the same RestrictionKind vocabulary the terms use.").optional(), "retrieval_endpoint": z.string().describe("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.").optional(), "subscription_id": z.string().describe("If under subscription, no per-request charge.").optional(), "subscription_unit_value": z.object({ "amount": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).describe("Exact decimal string (not a float), e.g. \"19.99\". Denominated in `currency`.").default(""), "currency": z.string().default(""), "unit_cost": z.string().regex(new RegExp("^([0-9]+([.][0-9]+)?)?$")).max(32).optional() }).describe("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).").optional(), "transaction_id": z.string().describe("Exchange-assigned transaction identifier.").default("") }).describe("TransactionResultItem — Result for a single offer in a batch transaction.")); +export const TransactionStateSchema = wire(z.object({ "idempotency_key": z.string().min(1).describe("The transaction's per-item idempotency key as logged. The Exchange\n derives it as TransactionEvidence.request_idempotency_key + \":\" +\n offer_id — unconditionally, single-item requests included — so distinct\n items of a batch dedupe independently, and this value is NEVER byte-equal\n to the request-level key. A ledger joining this row against a log export\n matches on this derived form, not on the bare request key, and it reaches\n the TRANSACTION-side events only: a usage-report event stores the report's\n own idempotency key, because a report addresses a whole transaction and\n has no offer id to derive with. Join a usage report on transaction_id\n instead. No upper\n bound: the derivation appends an id whose length nothing constrains."), "signed_url_expiry": z.string().datetime({ offset: true }).describe("When the signed retrieval URL expires. Named to pair with signed_url_hash\n below, so the two fields describing one minted URL read as a pair and\n neither can be mistaken for a property of the transaction itself. Do not\n read the name as a column name: stores spell this one differently\n (TransactionResultItem.expires_at on the wire, and the reference\n Exchange's transaction log calls the column plainly `expiry`), so a\n ledger joining to a log matches this field by MEANING, not by name.\n signed_url_hash is the one that happens to match a real column name.\n\nAbsent when the transaction minted no signed URL: DELIVERY_METHOD_DIRECT\n returns the resource inline or from the Exchange's own endpoint, so there\n is nothing to expire. DELIVERY_METHOD_INSTRUCTIONS and\n DELIVERY_METHOD_STREAMING both mint one and always carry this field.\n Absence is a stated fact about the delivery method, not missing data: a\n direct delivery has no value to state here, so there is nothing an empty\n value could honestly mean.").optional(), "signed_url_hash": z.string().regex(new RegExp("^(?:[A-Za-z0-9+/]{43}=?|[A-Za-z0-9_-]{43}=?)$")).min(43).max(44).describe("sha256 of the signed retrieval URL — the join key against the transaction\n log's signed_url_hash column, which holds the same digest as 32 raw bytes.\n The join is byte-to-byte; nothing needs normalizing. Text only appears\n when a store is rendered — protojson base64s this field, and a log export\n picks its own spelling — so it is exports, not stores, that a join has to\n reconcile. Hash-only by design: the full URL is a live\n bearer capability until expiry and is deliberately absent from this\n plane (see TransactionEvidence's delivery section). Absent exactly when\n signed_url_expiry is, and for the same reason: no signed URL, nothing to\n hash. The\n `optional` keyword is load-bearing — it gives this scalar explicit\n presence, so protovalidate skips the length rule on an unset value, while\n a PRESENT hash must still be exactly 32 bytes.").optional() }).describe("TransactionState — the thin transaction-log facts a ledger renderer needs\n next to the evidence row. The log row is the operational record (updated\n when a usage report lands); the evidence row is the append-once proof.\n There is deliberately no status field: a denied execute aborts before any\n row is written, so evidence only ever describes a successful execute —\n existence is the status, and a renderer derives its status cell from it.")); export const UsageSchema = wire(z.object({ "attribution": z.array(z.object({ "displayed_url": z.string().describe("URL displayed to the user as the attribution link.").optional(), "format": z.enum(["CITATION_FORMAT_LINK","CITATION_FORMAT_FOOTNOTE","CITATION_FORMAT_INLINE"]).describe("How the citation was presented.").optional(), "visible_to_user": z.boolean().describe("Whether the attribution was visible to the end user.").optional() }).describe("AttributionDetail — Structured attribution metadata for usage reporting.")).describe("Structured attribution details for each citation provided.").optional(), "citation_included": z.boolean().describe("Whether citation was included as required by the offer terms.").optional(), "consumed_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "consumed_unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "displayed_to_user": z.boolean().describe("Whether resource/output was displayed to a human.").optional(), "function": z.array(z.string()).describe("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.").optional(), "subfn": z.array(z.string()).describe("Sub-function detail. Standard values: \"training\", \"rag\", \"grounding\",\n \"agent_view\", \"agent_actions\".").optional() }).describe("Usage — How resource was actually used by the AI system.")); -export const UsageAssetSchema = wire(z.object({ "package_id": z.string().describe("Package identifier").optional(), "uri": z.string().describe("Asset URI").default("") }).describe("UsageAsset — A single asset included in the usage report.")); +export const UsageAssetSchema = wire(z.object({ "package_id": z.string().describe("Package identifier").optional(), "title": z.string().describe("Asset title").optional(), "uri": z.string().describe("Asset URI").default("") }).describe("UsageAsset — A single asset included in the usage report.")); -export const UsageReportSchema = wire(z.object({ "assets": z.array(z.object({ "package_id": z.string().describe("Package identifier").optional(), "uri": z.string().describe("Asset URI").default("") }).describe("UsageAsset — A single asset included in the usage report.")).describe("Assets that were delivered and used.").optional(), "billing_id": z.string().describe("Billing record identifier from the delivery (TransactionResultItem.billing_id).").default(""), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the recipient this report is addressed to (e.g.\n \"exchange.example\" or \"exchange.example:8081\") — the Exchange that issued\n the offer and therefore holds the reporting obligation. See \"Request\n recipient\" in the file header for the full contract. Promoted from optional:\n an absent or empty value used to skip the recipient check entirely, which\n made the check opt-in for the caller."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "idempotency_key": z.string().min(1).max(255).describe("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": z.string().datetime({ offset: true }).describe("When the resource was used (ISO 8601).").optional(), "transaction_id": z.string().describe("Transaction ID from the delivery.").default(""), "usage": z.object({ "attribution": z.array(z.object({ "displayed_url": z.string().describe("URL displayed to the user as the attribution link.").optional(), "format": z.enum(["CITATION_FORMAT_LINK","CITATION_FORMAT_FOOTNOTE","CITATION_FORMAT_INLINE"]).describe("How the citation was presented.").optional(), "visible_to_user": z.boolean().describe("Whether the attribution was visible to the end user.").optional() }).describe("AttributionDetail — Structured attribution metadata for usage reporting.")).describe("Structured attribution details for each citation provided.").optional(), "citation_included": z.boolean().describe("Whether citation was included as required by the offer terms.").optional(), "consumed_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "consumed_unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "displayed_to_user": z.boolean().describe("Whether resource/output was displayed to a human.").optional(), "function": z.array(z.string()).describe("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.").optional(), "subfn": z.array(z.string()).describe("Sub-function detail. Standard values: \"training\", \"rag\", \"grounding\",\n \"agent_view\", \"agent_actions\".").optional() }).describe("How the resource was actually used.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("Filed by the agent or Broker after resource is used.\n Failure to report may result in the Exchange blocking subsequent access.")); +export const UsageReportSchema = wire(z.object({ "assets": z.array(z.object({ "package_id": z.string().describe("Package identifier").optional(), "title": z.string().describe("Asset title").optional(), "uri": z.string().describe("Asset URI").default("") }).describe("UsageAsset — A single asset included in the usage report.")).describe("Assets that were delivered and used.").optional(), "billing_id": z.string().describe("Billing record identifier from the delivery (TransactionResultItem.billing_id).").default(""), "exchange": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("REQUIRED. Bare host of the recipient this report is addressed to (e.g.\n \"exchange.example\" or \"exchange.example:8081\") — the Exchange that issued\n the offer and therefore holds the reporting obligation. See \"Request\n recipient\" in the file header for the full contract. Promoted from optional:\n an absent or empty value used to skip the recipient check entirely, which\n made the check opt-in for the caller."), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "idempotency_key": z.string().min(1).max(255).describe("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\nDEDUPE SCOPE. The invariant is the one stated on\n TransactionRequest.idempotency_key. This message carries no acceptance\n payload, so there is no in-body agent signature to anchor on; the namespace\n is instead the TRANSACTION the report addresses, and the server dedupes per\n (transaction_id, key). That satisfies the invariant without depending on\n the transport signer: the transaction was bound to exactly one agent by its\n acceptance at execute time, so two agents relayed by the same Broker report\n against different transactions and never share a namespace.\n Leaving the signer out is also what makes the relay work. \"Filed by the\n agent or Broker\" above means the Broker FORWARDS the agent's report, not\n that it authors one of its own: the body is unchanged and carries this same\n key, so a direct submission and a relayed copy are one report on two paths\n and MUST collapse. Adding the verified signer to the namespace would split\n them and count the usage twice.\n\n WHO MAY FILE. Dropping the signer from the namespace removes a protection\n that has to be restored explicitly, so the rule is stated rather than\n implied: only the agent the transaction was bound to at execute time may\n file against it, or a Broker relaying that agent's report unchanged. The\n argument above is about honest filers — it shows two legitimate parties\n never collide by accident, which is a different claim from who is allowed\n to write. A filing from any other party MUST be rejected, never deduped:\n the slot is now shared, so an accepted filing from an unbound party would\n occupy the one the bound agent's report needs, and the real usage would\n collapse into it and go uncounted.\n\n An unauthorized filing is reported as USAGE_REPORT_REJECTION_REASON_\n TRANSACTION_NOT_FOUND, deliberately. There is no distinct \"not authorized\"\n reason and there should not be one: it would confirm to a party not bound\n to the transaction that the transaction exists, which turns the rejection\n into an oracle for probing transaction ids."), "timestamp": z.string().datetime({ offset: true }).describe("When the resource was used (ISO 8601).").optional(), "transaction_id": z.string().min(1).describe("Transaction ID from the delivery. MUST be non-empty. It is also the dedupe\n namespace for `idempotency_key` above, so a report that names no\n transaction has no namespace to dedupe within — the rule below is what\n makes that namespace exist, not a shape preference. No upper bound: the\n Exchange assigns this id and nothing upstream constrains its length."), "usage": z.object({ "attribution": z.array(z.object({ "displayed_url": z.string().describe("URL displayed to the user as the attribution link.").optional(), "format": z.enum(["CITATION_FORMAT_LINK","CITATION_FORMAT_FOOTNOTE","CITATION_FORMAT_INLINE"]).describe("How the citation was presented.").optional(), "visible_to_user": z.boolean().describe("Whether the attribution was visible to the end user.").optional() }).describe("AttributionDetail — Structured attribution metadata for usage reporting.")).describe("Structured attribution details for each citation provided.").optional(), "citation_included": z.boolean().describe("Whether citation was included as required by the offer terms.").optional(), "consumed_quantity": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("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.").optional(), "consumed_unit": z.string().regex(new RegExp("^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$")).max(64).describe("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.").optional(), "displayed_to_user": z.boolean().describe("Whether resource/output was displayed to a human.").optional(), "function": z.array(z.string()).describe("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.").optional(), "subfn": z.array(z.string()).describe("Sub-function detail. Standard values: \"training\", \"rag\", \"grounding\",\n \"agent_view\", \"agent_actions\".").optional() }).describe("How the resource was actually used.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("UsageReport — Post-usage report for a completed transaction.\n\nFiled by the agent or Broker after resource is used.\n Failure to report may result in the Exchange blocking subsequent access.")); export const UsageReportRejectionSchema = wire(z.object({ "reason": z.enum(["USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND","USAGE_REPORT_REJECTION_REASON_DUPLICATE","USAGE_REPORT_REJECTION_REASON_WINDOW_EXPIRED","USAGE_REPORT_REJECTION_REASON_MISSING_REQUIRED_FIELDS","USAGE_REPORT_REJECTION_REASON_MALFORMED"]).describe("The rejection reason (defined-only, non-zero)") }).describe("UsageReportRejection — a usage report could not be accepted.")); @@ -204,7 +218,7 @@ export const UsageReportRejectionReasonSchema = wire(z.enum(["USAGE_REPORT_REJEC export const UsageReportResponseSchema = wire(z.object({ "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "report_id": z.string().describe("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)").default(""), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("UsageReportResponse — Acknowledgment of a usage report.")); -export const WBAFileSchema = wire(z.object({ "keys": z.array(z.object({ "alg": z.string().describe("Signing algorithm. RAMP v1.0: MUST be \"EdDSA\".").default(""), "crv": z.string().describe("Curve. RAMP v1.0: MUST be \"Ed25519\".").default(""), "kty": z.string().describe("Key type. RAMP v1.0: MUST be \"OKP\".").default(""), "not_after": z.string().describe("RFC3339 timestamp. Key is invalid at and after this instant\n (strict upper bound).").default(""), "not_before": z.string().describe("RFC3339 timestamp. Key is invalid before this instant.").default(""), "use": z.string().describe("Intended key use. RAMP v1.0: MUST be \"sig\".").default(""), "x": z.string().describe("base64url-encoded 32-byte Ed25519 public key.").default("") }).describe("RAMP v1.0 supports Ed25519 only: kty=\"OKP\", crv=\"Ed25519\", alg=\"EdDSA\".\n Additional curves are a later concern.\n\n Time bounds are RFC3339 strings (sortable, ops-debuggable, avoids the\n JWT nbf/exp collision). At least one key in the served key set (WBAFile.keys)\n MUST have `not_before <= now < not_after`. Verification MUST reject\n signatures whose key falls outside its window.\n\n Keys carry no `kid`: the RFC 9421 keyid is the RFC 7638 JWK Thumbprint,\n computed locally by the verifier. Carrying a kid alongside the thumbprint\n created a drift surface and is removed.")).describe("RFC 7517 JWK Set \"keys\" member. RAMP v1: Ed25519 (OKP) keys, each with\n not_before/not_after RAMP extension members.").optional(), "revocation_url": z.string().describe("Directory-level emergency revocation channel. One per directory; the list\n it points to enumerates revoked key thumbprints. Consumers poll on a 300s\n cadence (±10% jitter) and replace their local revoked set with the response.").optional() }).describe("WBAFile — Pure Web Bot Auth directory served at the WBA-canonical well-known\n path (/.well-known/http-message-signatures-directory). A JOSE JWK Set per\n RFC 7517 §5 plus a directory-level revocation pointer. JWKs carry no kid; the\n RFC 9421 keyid is the RFC 7638 JWK Thumbprint. Off-the-shelf WBA verifiers\n read the `keys` array and ignore RAMP's extra members (per-key\n not_before/not_after, and revocation_url) per RFC 7517 §5.")); +export const WBAFileSchema = wire(z.object({ "keys": z.array(z.object({ "alg": z.string().describe("Signing algorithm. RAMP v1.0: MUST be \"EdDSA\".").default(""), "crv": z.string().describe("Curve. RAMP v1.0: MUST be \"Ed25519\".").default(""), "kty": z.string().describe("Key type. RAMP v1.0: MUST be \"OKP\".").default(""), "not_after": z.string().describe("RFC3339 timestamp. Key is invalid at and after this instant\n (strict upper bound).").default(""), "not_before": z.string().describe("RFC3339 timestamp. Key is invalid before this instant.").default(""), "use": z.string().describe("Intended key use. RAMP v1.0: MUST be \"sig\".").default(""), "x": z.string().describe("base64url-encoded 32-byte Ed25519 public key.").default("") }).describe("JsonWebKey — Inline RFC 7517 JWK object.\n\nRAMP v1.0 supports Ed25519 only: kty=\"OKP\", crv=\"Ed25519\", alg=\"EdDSA\".\n Additional curves are a later concern.\n\n Time bounds are RFC3339 strings (sortable, ops-debuggable, avoids the\n JWT nbf/exp collision). At least one key in the served key set (WBAFile.keys)\n MUST have `not_before <= now < not_after`. Verification MUST reject\n signatures whose key falls outside its window.\n\n Keys carry no `kid`: the RFC 9421 keyid is the RFC 7638 JWK Thumbprint,\n computed locally by the verifier. Carrying a kid alongside the thumbprint\n created a drift surface and is removed.")).describe("RFC 7517 JWK Set \"keys\" member. RAMP v1: Ed25519 (OKP) keys, each with\n not_before/not_after RAMP extension members.").optional(), "revocation_url": z.string().describe("Directory-level emergency revocation channel. One per directory; the list\n it points to enumerates revoked key thumbprints. Consumers poll on a 300s\n cadence (±10% jitter) and replace their local revoked set with the response.").optional() }).describe("WBAFile — Pure Web Bot Auth directory served at the WBA-canonical well-known\n path (/.well-known/http-message-signatures-directory). A JOSE JWK Set per\n RFC 7517 §5 plus a directory-level revocation pointer. JWKs carry no kid; the\n RFC 9421 keyid is the RFC 7638 JWK Thumbprint. Off-the-shelf WBA verifiers\n read the `keys` array and ignore RAMP's extra members (per-key\n not_before/not_after, and revocation_url) per RFC 7517 §5.")); -export const WellKnownManifestSchema = wire(z.object({ "accepted_verifiers": z.array(z.string()).describe("Exchange-only. Trusted attestation verification vendors (domains).").optional(), "account_registration": z.object({ "data_schema": z.record(z.string(), z.any()).describe("JSON Schema (draft 2020-12) describing the RegisterRequest.registration_data\n object this Exchange expects. This field is the single home of the\n enforce/pass-through contract, and publishing it IS the enforcement switch.\n Present: this Exchange validates registration_data against the schema and\n refuses a non-conforming payload with\n REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, naming the offending\n members in RegistrationFailure.field_errors. Absent: registration_data is\n passed through to the system of record uninspected, so an Exchange that\n publishes no schema needs no change to stay conformant. Safety rules,\n because a consumer reads this schema out of a third party's manifest: it\n MUST be self-contained, and a consumer MUST NOT resolve a remote $ref out of\n it — doing so turns every reader into an SSRF vector aimed at a URL the\n schema's author chose. A consumer SHOULD bound validation time and recursion\n depth; draft 2020-12 `pattern` admits regexes with catastrophic\n backtracking. Size is capped at 16KB, measured as the UTF-8 bytes of this\n member as served in ramp.json; a consumer SHOULD reject an oversized schema\n and skip its local pre-check rather than truncate it, which leaves the\n Exchange's own enforcement the deciding check exactly as when no schema is\n published.").optional() }).describe("Exchange-only. How to open an account here — see AccountRegistration, which\n owns the contract. Absent: registration_data is accepted uninspected,\n exactly as before this field existed.").optional(), "base_currency": z.string().describe("Exchange-only. Base currency for pricing (ISO 4217). All unit_cost\n values from this Exchange are denominated in this currency.").optional(), "catalog_contributors": z.array(z.object({ "domain": z.string().describe("Canonical domain of the authorized contributor (e.g., \"doubleverify.com\").").default(""), "relationship": z.string().describe("Relationship of this contributor to the provider.\n Examples: \"verifier\" (resource intelligence vendor that attests to resource\n properties), \"exchange\" (an Exchange that enriches catalog entries).").default("") }).describe("CatalogContributor — A third party authorized to push catalog metadata\n (including attestations) on behalf of a provider.")).describe("Publisher-only. Authorized third-party catalog contributors.\n MUST be empty for non-publisher roles.").optional(), "catalog_endpoint": z.string().describe("Exchange-only. CatalogService endpoint URL (if exposed).").optional(), "contact": z.string().describe("Contact email (licensing, integration, security).").optional(), "delivery_methods_supported": z.array(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])).describe("Exchange-only. Supported delivery methods.").optional(), "domain": z.string().describe("Canonical domain serving this manifest.").default(""), "endpoint": z.string().describe("Exchange-only. ExchangeService endpoint URL. MUST be on the same host AND\n PORT that serve this manifest, or on a subdomain of that host on that port,\n and MUST NOT carry userinfo. A consumer refuses an endpoint anywhere else:\n this document is only as trustworthy as the host that served it, so an\n endpoint naming an unrelated host would let whoever answers for the manifest\n redirect a signed call to a party the signature never covered, and another\n port is another service the publisher of the manifest need not control. The\n host match is on a full dot-delimited label boundary, so evil-a.com is not a\n subdomain of a.com. A port equal to the scheme's default and an omitted port\n are the SAME port, so https://x, https://x:443 and x all match. An Exchange\n reachable on a non-default port names that port on both sides. (One\n paragraph deliberately: a blank line here routes the first paragraph into\n the generated types' JSON-Schema title, which the Pydantic/Zod export drops.)").optional(), "exchanges": z.array(z.object({ "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Canonical domain of the Exchange, in the shape \"Request recipient\" defines\n in the file header."), "endpoint": z.string().describe("RAMP ExchangeService endpoint URL.").default(""), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "relationship": z.enum(["PROVIDER_RELATIONSHIP_DIRECT","PROVIDER_RELATIONSHIP_RESELLER"]).describe("Relationship type (mirrors ads.txt DIRECT/RESELLER).") }).describe("AuthorizedExchange — A Exchange authorized to sell this provider's resources.")).describe("Publisher-only. Authorized exchanges for this publisher's resources.\n Like ads.txt — declares who may sell. MUST be empty for non-publisher\n roles.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052). Lists keys\n within ext that the consumer MUST understand. Unknown values reject\n with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → ignore-unknown.").optional(), "gnap_grant_endpoint": z.string().describe("Exchange-only. GNAP grant endpoint when GNAP is supported.").optional(), "hash_methods_supported": z.array(z.string()).describe("Exchange-only. Accepted resource hash methods for attestation\n verification.").optional(), "health_endpoint": z.string().describe("Exchange-only. Health check endpoint URL.").optional(), "max_intermediary_hops": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Exchange-only. Maximum forwarding hops this Exchange tolerates on an inbound\n request (Agent → Broker → … → Exchange), counted as RFC 9421 HTTP Message\n Signatures. A request carrying more SHOULD be rejected. Lets Exchanges\n publish their chain-depth tolerance so Brokers prune before forwarding.\n Absent = no published limit (Exchange applies its own default policy).").optional(), "name": z.string().describe("Exchange-only. Human-readable Exchange name.").optional(), "oidc_issuer": z.string().describe("Exchange-only. OIDC Discovery URL when OAuth methods are supported.").optional(), "operator": z.string().describe("Exchange-only. Organization operating this Exchange.").optional(), "operator_domain": z.string().describe("Exchange-only. Operator's corporate domain (may differ from domain).").optional(), "pricing_models_supported": z.array(z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"])).describe("Exchange-only. Supported pricing models.").optional(), "privacy_uri": z.string().describe("Exchange-only. Privacy policy URL.").optional(), "protocol_versions_supported": z.array(z.string()).describe("Exchange-only. Supported RAMP protocol versions (e.g. [\"1.0\"]).").optional(), "role": z.enum(["ROLE_AGENT","ROLE_EXCHANGE","ROLE_BROKER","ROLE_PUBLISHER"]).describe("Role this manifest describes."), "supported_auth_methods": z.array(z.enum(["AUTH_METHOD_GNAP","AUTH_METHOD_OAUTH_DPOP","AUTH_METHOD_OAUTH_BEARER","AUTH_METHOD_OAUTH_MTLS"])).describe("Exchange-only. Authorization methods this Exchange supports\n (ordered by preference).").optional(), "supported_profiles": z.array(z.string()).describe("Exchange-only. Domain extension profiles this Exchange conforms to.\n See standards-layering docs.").optional(), "terms_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Exchange-only. Digest of the document served at `terms_uri`, in\n \"method:hexdigest\" form (e.g. \"sha256:9f86d081...\"), pinning WHICH terms\n document this manifest is currently offering. `terms_uri` alone cannot\n answer that: it is a URL, and its content changes, so after the first\n revision every earlier registration points at a document that no longer says\n what was agreed. RegisterRequest.terms_digest echoes this value, the request\n signature covers that echo, and the Exchange records the accepted digest\n with the account — which is what makes \"which terms did this operator\n accept\" answerable later. Because a digest identifies a document only while\n a copy of it still exists, keeping the historical terms documents\n retrievable is the Exchange's obligation. It sits at the top level rather\n than inside account_registration on purpose: an Exchange with pass-through\n registration publishes no block yet still needs to pin its terms version,\n and coupling \"I enforce a schema\" to \"I version my terms\" would tie together\n two independent decisions. Operator note: publishing this field for the\n first time refuses every client that does not yet echo it, so it is a\n coordinated change rather than a safe addition.").optional(), "terms_uri": z.string().describe("Exchange-only. Terms of service URL.").optional(), "ver": z.string().describe("RAMP protocol version of THIS MANIFEST DOCUMENT's schema — a namespace\n separate from the RPC envelope `ver`, deliberately not coupled to it.\n MUST equal \"1.0\"; consumers REJECT unrecognised major versions.").default("") }).describe("Commercial graph only: role, authorized exchanges/contributors, and exchange\n capability fields. Identity keys are NOT here — they live in the WBA directory\n (WBAFile) served at /.well-known/http-message-signatures-directory and are\n referenced by RFC 7638 thumbprint, never republished here.\n Per-role fields are populated only when that role applies; consumers\n MUST ignore non-applicable fields based on `role`.")); +export const WellKnownManifestSchema = wire(z.object({ "accepted_verifiers": z.array(z.string()).describe("Exchange-only. Trusted attestation verification vendors (domains).").optional(), "account_registration": z.object({ "data_schema": z.record(z.string(), z.any()).describe("JSON Schema (draft 2020-12) describing the RegisterRequest.registration_data\n object this Exchange expects. This field is the single home of the\n enforce/pass-through contract, and publishing it IS the enforcement switch.\n Present: this Exchange validates registration_data against the schema and\n refuses a non-conforming payload with\n REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, naming the offending\n members in RegistrationFailure.field_errors. Absent: registration_data is\n passed through to the system of record uninspected, so an Exchange that\n publishes no schema needs no change to stay conformant. Safety rules,\n because a consumer reads this schema out of a third party's manifest: it\n MUST be self-contained, and a consumer MUST NOT resolve a remote $ref out of\n it — doing so turns every reader into an SSRF vector aimed at a URL the\n schema's author chose. A consumer SHOULD bound validation time and recursion\n depth; draft 2020-12 `pattern` admits regexes with catastrophic\n backtracking. Size is capped at 16KB, measured as the UTF-8 bytes of this\n member as served in ramp.json; a consumer SHOULD reject an oversized schema\n and skip its local pre-check rather than truncate it, which leaves the\n Exchange's own enforcement the deciding check exactly as when no schema is\n published.").optional() }).describe("Exchange-only. How to open an account here — see AccountRegistration, which\n owns the contract. Absent: registration_data is accepted uninspected,\n exactly as before this field existed.").optional(), "base_currency": z.string().describe("Exchange-only. Base currency for pricing (ISO 4217). All unit_cost\n values from this Exchange are denominated in this currency.").optional(), "catalog_contributors": z.array(z.object({ "domain": z.string().describe("Canonical domain of the authorized contributor (e.g., \"doubleverify.com\").").default(""), "relationship": z.string().describe("Relationship of this contributor to the provider.\n Examples: \"verifier\" (resource intelligence vendor that attests to resource\n properties), \"exchange\" (an Exchange that enriches catalog entries).").default("") }).describe("CatalogContributor — A third party authorized to push catalog metadata\n (including attestations) on behalf of a provider.")).describe("Publisher-only. Authorized third-party catalog contributors.\n MUST be empty for non-publisher roles.").optional(), "catalog_endpoint": z.string().describe("Exchange-only. CatalogService endpoint URL (if exposed).").optional(), "contact": z.string().describe("Contact email (licensing, integration, security).").optional(), "delivery_methods_supported": z.array(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])).describe("Exchange-only. Supported delivery methods.").optional(), "domain": z.string().describe("Canonical domain serving this manifest.").default(""), "endpoint": z.string().describe("Exchange-only. ExchangeService endpoint URL. MUST be on the same host AND\n PORT that serve this manifest, or on a subdomain of that host on that port,\n and MUST NOT carry userinfo. A consumer refuses an endpoint anywhere else:\n this document is only as trustworthy as the host that served it, so an\n endpoint naming an unrelated host would let whoever answers for the manifest\n redirect a signed call to a party the signature never covered, and another\n port is another service the publisher of the manifest need not control. The\n host match is on a full dot-delimited label boundary, so evil-a.com is not a\n subdomain of a.com. A port equal to the scheme's default and an omitted port\n are the SAME port, so https://x, https://x:443 and x all match. An Exchange\n reachable on a non-default port names that port on both sides. (One\n paragraph deliberately: a blank line here routes the first paragraph into\n the generated types' JSON-Schema title, which the Pydantic/Zod export drops.)").optional(), "exchanges": z.array(z.object({ "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Canonical domain of the Exchange, in the shape \"Request recipient\" defines\n in the file header."), "endpoint": z.string().describe("RAMP ExchangeService endpoint URL.").default(""), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "relationship": z.enum(["PROVIDER_RELATIONSHIP_DIRECT","PROVIDER_RELATIONSHIP_RESELLER"]).describe("Relationship type (mirrors ads.txt DIRECT/RESELLER).") }).describe("AuthorizedExchange — A Exchange authorized to sell this provider's resources.")).describe("Publisher-only. Authorized exchanges for this publisher's resources.\n Like ads.txt — declares who may sell. MUST be empty for non-publisher\n roles.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052). Lists keys\n within ext that the consumer MUST understand. Unknown values reject\n with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → ignore-unknown.").optional(), "gnap_grant_endpoint": z.string().describe("Exchange-only. GNAP grant endpoint when GNAP is supported.").optional(), "hash_methods_supported": z.array(z.string()).describe("Exchange-only. Accepted resource hash methods for attestation\n verification.").optional(), "health_endpoint": z.string().describe("Exchange-only. Health check endpoint URL.").optional(), "max_intermediary_hops": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Exchange-only. Maximum forwarding hops this Exchange tolerates on an inbound\n request (Agent → Broker → … → Exchange), counted as RFC 9421 HTTP Message\n Signatures. A request carrying more SHOULD be rejected. Lets Exchanges\n publish their chain-depth tolerance so Brokers prune before forwarding.\n Absent = no published limit (Exchange applies its own default policy).").optional(), "name": z.string().describe("Exchange-only. Human-readable Exchange name.").optional(), "oidc_issuer": z.string().describe("Exchange-only. OIDC Discovery URL when OAuth methods are supported.").optional(), "operator": z.string().describe("Exchange-only. Organization operating this Exchange.").optional(), "operator_domain": z.string().describe("Exchange-only. Operator's corporate domain (may differ from domain).").optional(), "pricing_models_supported": z.array(z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"])).describe("Exchange-only. Supported pricing models.").optional(), "privacy_uri": z.string().describe("Exchange-only. Privacy policy URL.").optional(), "protocol_versions_supported": z.array(z.string()).describe("Exchange-only. Supported RAMP protocol versions (e.g. [\"1.0\"]).").optional(), "role": z.enum(["ROLE_AGENT","ROLE_EXCHANGE","ROLE_BROKER","ROLE_PUBLISHER"]).describe("Role this manifest describes."), "supported_auth_methods": z.array(z.enum(["AUTH_METHOD_GNAP","AUTH_METHOD_OAUTH_DPOP","AUTH_METHOD_OAUTH_BEARER","AUTH_METHOD_OAUTH_MTLS"])).describe("Exchange-only. Authorization methods this Exchange supports\n (ordered by preference).").optional(), "supported_profiles": z.array(z.string()).describe("Exchange-only. Domain extension profiles this Exchange conforms to.\n See standards-layering docs.").optional(), "terms_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Exchange-only. Digest of the document served at `terms_uri`, in\n \"method:hexdigest\" form (e.g. \"sha256:9f86d081...\"), pinning WHICH terms\n document this manifest is currently offering. `terms_uri` alone cannot\n answer that: it is a URL, and its content changes, so after the first\n revision every earlier registration points at a document that no longer says\n what was agreed. RegisterRequest.terms_digest echoes this value, the request\n signature covers that echo, and the Exchange records the accepted digest\n with the account — which is what makes \"which terms did this operator\n accept\" answerable later. Because a digest identifies a document only while\n a copy of it still exists, keeping the historical terms documents\n retrievable is the Exchange's obligation. It sits at the top level rather\n than inside account_registration on purpose: an Exchange with pass-through\n registration publishes no block yet still needs to pin its terms version,\n and coupling \"I enforce a schema\" to \"I version my terms\" would tie together\n two independent decisions. Operator note: publishing this field for the\n first time refuses every client that does not yet echo it, so it is a\n coordinated change rather than a safe addition.").optional(), "terms_uri": z.string().describe("Exchange-only. Terms of service URL.").optional(), "ver": z.string().describe("RAMP protocol version of THIS MANIFEST DOCUMENT's schema — a namespace\n separate from the RPC envelope `ver`, deliberately not coupled to it.\n MUST equal \"1.0\"; consumers REJECT unrecognised major versions.").default("") }).describe("WellKnownManifest — RAMP commercial overlay, served at /.well-known/ramp.json\n by every RAMP participant (agent, exchange, broker, publisher).\n\nCommercial graph only: role, authorized exchanges/contributors, and exchange\n capability fields. Identity keys are NOT here — they live in the WBA directory\n (WBAFile) served at /.well-known/http-message-signatures-directory and are\n referenced by RFC 7638 thumbprint, never republished here.\n Per-role fields are populated only when that role applies; consumers\n MUST ignore non-applicable fields based on `role`.")); diff --git a/proto/CHANGELOG.md b/proto/CHANGELOG.md index 636ec947..1b0fd8b6 100644 --- a/proto/CHANGELOG.md +++ b/proto/CHANGELOG.md @@ -2,6 +2,374 @@ ## Unreleased +**The offline recipe compares every signed member, not just `offer_sig` (contract fix, no wire +change).** Binding the acceptance to the offer stopped splicing: a genuine acceptance from a +different offer no longer passes. It did not stop reuse. Two acceptances by one agent against ONE +offer share `offer_sig` and differ only in the idempotency key, so an Exchange holding a single +genuine acceptance could write two evidence rows for two different executes against one offer, and +both passed. That is fabrication by the row's writer rather than splicing by an outsider, and it is +the failure `AgentAcceptancePayload.idempotency_key` exists to prevent. + +The recipe now compares all four members of the signed payload against their stored copies. Three +names coincide; the fourth does not -- the payload member is `idempotency_key` and the row stores it +as `request_idempotency_key`. The row comment that listed the payload's four fields used the row's +name for the fourth, which would send an implementer looking for a JSON member that does not exist; +it now states the mapping. The conformance test carries both cases: a spliced acceptance caught on +`offer_sig`, and a reused acceptance caught on the idempotency key. + +**The admin plane no longer claims a per-tenant ACL is possible (documentation fix, no wire +change).** Four sites -- the service comment, the RPC comment, the `tenant_id` field comment and the +hand-maintained admin proto reference page -- said the `(tenant_id, transaction_id)` pair selector +is what lets a deployment put a per-tenant ACL in front of `GetTransactionEvidence`. The threat +model says the opposite twice, and it is right: `ramp.admin.v1` carries no request signing and no +per-operator identity, so there is no caller to attach an ACL to. All four now say what the pair +selector actually buys -- it narrows what a leaked transaction id is worth -- and that the network +allowlist is the only gate. This matters because the same threat model records that +`GetTransactionEvidence` widened that allowlist's blast radius from per-tenant config writes to a +cross-tenant read of every tenant's signed offers; an operator sizing it while believing a second +control sits behind it would size it too loosely. + +**The no-shared-secret claim is scoped to delivery URLs (documentation fix, no wire change).** The +file header said "No shared secret in either scheme", which is true of the two signed-URL schemes +and contradicted by `cdn_type` on `DomainVerificationConfirmation`, where `"hmac"` is still an +accepted value with no validation rule. The header now says neither *delivery-URL* scheme uses a +shared secret and points at the registration plane that still admits an HMAC key format. Whether +that plane should keep admitting it is a live question, tracked with the wider HMAC sweep. + +**Four parity exclusions stated reasons that were false (documentation fix, no wire change).** Three +justified themselves with "TS/Py have no server face"; both `ramp_sdk.server_verify` and +`core/verify-request.ts` exist and open by calling themselves exactly that. Those three also carried +a retirement trigger -- "if a Python or TS server face ever lands" -- that had already fired and so +could never fire again. The real reason is narrower and is now stated: the py/ts server faces carry +no request-id seam. The fourth said py/ts "mint request-ids inline"; neither SDK mints anything -- +both export the `RequestIDHeader` constant and no non-test code sets that header, so every RPC from +a py/ts client arrives with no correlation id. Two older entries repeating that claim are corrected +with it. The underlying behavioural gap is now tracked as its own work rather than documented as an +intentional difference in API shape. + +**The offline re-verification recipe binds the acceptance to the offer (contract fix, no wire +change).** The recipe on `TransactionEvidence` stated two independent signature checks and +introduced the second with "the agent accepted this exact offer". Nothing in the procedure +established "this exact". A genuine offer from one transaction and a genuine acceptance from +another, by the same agent, both verify against real keys and both pass the authenticity step the +same comment describes -- and the spliced row asserts an agreement that never happened. + +The recipe now has a third step: read `offer_sig` back out of +`agent_acceptance_canonical_bytes` and require it to equal the row's `offer_sig`, compared +case-insensitively. The acceptance payload has always carried `offer_sig` as field 1 for exactly +this purpose; the recipe simply never read it. The Exchange MUST perform the same comparison before +persisting a row. `conformance/evidence_offline_verify_test.go` now executes all three steps and +carries a splice case: two genuine halves joined, both signatures verifying, caught only by the +binding check. + +**Who may file a usage report is now stated (contract fix, no wire change).** The dedupe namespace +for `UsageReport` and `DisputeRequest` is `(transaction_id, key)`, which deliberately leaves the +verified signer out so that a Broker relaying an agent's report unchanged collapses into one report +rather than two. Dropping the signer also removed a protection that was never restated: when the +namespace included the authenticated caller, an unauthorized filer could only pollute their own +slot. The slot is now shared. + +The rule is therefore explicit: only the agent the transaction was bound to at execute time may file +against it, or a Broker relaying that agent's report unchanged. Any other filing MUST be rejected +rather than deduped, since an accepted filing from an unbound party would occupy the slot the bound +agent's report needs. An unauthorized filing reports as +`USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND` on purpose -- a distinct "not authorized" +value would confirm to an unbound party that the transaction exists, turning the rejection into an +oracle for probing transaction ids. No enum value was added. + +**The proto no longer describes signed URLs two ways (documentation fix, no wire change).** The +retrieval-URL block said signed URLs use HMAC-SHA256 with an Exchange-to-CDN shared secret, and +offered a fallback to "HMAC + short TTL + TLS", while the same block's identity-binding paragraph +had been rewritten to say "confirm the URL signature". RAMP has two signing schemes and both are +asymmetric: Ed25519 over a canonical message, and a CloudFront RSA canned policy. There is no shared +secret in either. Both lines now say so, and the fallback names the scheme that actually exists -- +a CDN that verifies the URL itself before any function code runs, and therefore cannot check proof +of possession. + +**The C2PA page no longer calls the attestation signature a JWS (documentation fix, no wire +change).** Pinning `ResourceAttestation.signature` to hex left three lines on +`protocol/ext-c2pa.mdx` naming the old format in the RAMP column of a C2PA-versus-RAMP comparison. +The audience for that page is a verification vendor -- a third party who never negotiated with the +publisher, which is the reader the hex settlement exists to protect -- and one building from the +table would emit a value the schema now rejects. Four SDK comments calling `EdDSA` "the JWS alg" +are corrected the same way: the name is the JOSE algorithm identifier, the signature is detached +hex. + +**Two drift gates now cover the detached-signature rule.** +`ramp.v1.ResourceAttestation.signature` carried the hex rule and a comment saying "the same rule", +which is not the phrase `conformance/samerule_test.go` reads, so it was the one copy of five tied to +nothing. It now declares `Same rule as ramp.v1.Offer.signature`. Separately, the equality gate can +only prove the five copies stay EQUAL -- move them all in step and a changed shape passes. Measured: +widening the class to `^[0-9A-Za-z]{128}$` passed every gate and regenerated the corpus +byte-identical. `TestHexSignaturePatternAdmits` now pins what the rule admits: either case accepted, +127 and 129 characters and non-hex characters and the empty string refused. + +**`ResourceAttestation.signature` states its encoding, and enforces it (BREAKING: rule addition on a +live field).** The field carried no rule and its comment described the signed BYTES precisely -- an +Ed25519 signature over the RFC 8785 JCS form of `{verifier, keyid, attested_at, uri, claims}` -- +while never saying how the signature ITSELF is written. A vendor had to guess, and the published +examples guessed base64, which no part of the contract supports. + +It is now hex, 128 characters, either case, with `pattern = "^[0-9A-Fa-f]{128}$"` -- the same rule +and the same convention as `Offer.signature` and `AgentAcceptance.signature`. Every detached +signature in this contract is now written the same way. + +An attestation is the worst place to leave an encoding unstated, which is why this is settled rather +than documented: the signing party is a third party who never negotiated with the reader, so two +vendors guessing differently produce attestations neither side can verify and nothing on the wire +explains why. No SDK produces or verifies an attestation signature today, so nothing conformant is +refused. + +The rule also makes the field mandatory in practice, since the empty string does not match. That +restates what the message already means -- an attestation without a signature is an unverifiable +assertion by an unproven author, which the field comment already calls Level 0 (no attestation +present) rather than an attestation with a field missing. + +**Every published signature example is hex (documentation fix, no wire change).** 71 example values +across 14 pages showed signatures as base64 placeholders (`base64-ed25519-...`), truncated tokens +(`a1b2c3...`), or bare ellipses. They were left behind by the JWS-to-hex settlement and were wrong +for `Offer.signature` and `AgentAcceptance.signature` from that moment; the attestation ones were +merely unpinned until the rule above. All are now full 128-character hex, with one value per +distinct placeholder so a signature that appears in several places in one walkthrough stays the same +value throughout. + +**`broker` is bounded printable ASCII (rule addition on a field added in this revision).** The +field is a server-written value that a ledger renders, so an unbounded string would have re-opened on +a new field exactly the surface `RequestCorrelation.request_id`'s printable-ASCII bound closes: +control characters, terminal escapes and newlines reaching a rendered forensic row. It now carries +`max_len: 255` and `^$|^[!-~]+$`. + +The rule bounds the SHAPE and deliberately does not pin the FORMAT. A thumbprint pattern would +invalidate a row for a transaction that legitimately executed under a server that records provenance +some other way -- the `requester_id` reasoning. The alternation admits the empty string on purpose, +because `''` is one of the field's three states; a bare `^[!-~]+$` would delete it and leave absence +meaning both "not recorded" and "arrived direct". + +**`broker` moves from `TransactionState` to `TransactionEvidence`, and gains explicit presence +(field move on a message that has not shipped).** `TransactionState.broker` was a plain `string` +describing a transaction-log column that no implementation has. Two things were wrong with that. + +The message is a projection of transaction-log columns, and a field with nothing behind it breaks +the property that makes the projection meaningful. Broker routing is not operational state anyway: +it is an execute-time observation about the connection the request arrived on, covered by neither +signature — the same category as `request_correlation`, which already sits on `TransactionEvidence`. +So it moves there, and `TransactionState` goes back to being every-field-backed-by-a-column. + +The field is now `optional`, which turns two states into three. Absent means the Exchange does not +record routing; `''` means it does record it and the acceptance arrived direct; a value means it +arrived through that hop. Without explicit presence the field defaults to `''`, so an Exchange with +nothing to say would have stated "arrived direct" for every row — a forensic plane asserting a +transport fact it never observed. + +The value is defined as implementation-defined provenance for the outermost hop, not a resolvable +identity. A reference Exchange serves the verified RFC 7638 key thumbprint of the hop that presented +the request, and deliberately does not resolve it to a directory host: the relay hop is not +re-identified against a registry, and the recipient's own relay-permission setting is the gate. A +reader may compare the value for equality against a thumbprint it already holds, but must not expect +a hostname and must not read it as an identity the Exchange vouched for. + +**Agent-plane signature fields now enforce the hex shape they describe (BREAKING: rule addition on +live fields).** `ramp.v1.Offer.signature` carried no rule at all and +`ramp.v1.AgentAcceptance.signature` carried only `min_len: 1`, while both comments described a +detached Ed25519 signature in hex. The read plane already enforced exactly that on its stored +copies (`TransactionEvidence.offer_sig`, `.agent_acceptance_signature`), so the forensic copy of a +signature was validated and the live one was not: a malformed signature was accepted on the agent +plane, failed verification later, and only failed VALIDATION once it reached an evidence row it +could never legitimately reach. Both fields now carry +`pattern = "^[0-9A-Fa-f]{128}$"` — 64 bytes of Ed25519 signature, hex-encoded, either case +accepted because hex decoding accepts both. On `AgentAcceptance.signature` the pattern REPLACES +`min_len: 1`, which it subsumes. Breaking in the descriptor, but no conformant caller is refused: +a value outside this shape cannot hex-decode into 64 bytes, so it could never have verified — the +rejection simply moves from the verify step to the validation step, where the error names the +problem. All three SDKs already emit lowercase hex. The `Same rule as` drift directives on the two +admin fields were re-anchored UPSTREAM to the ramp.v1 fields, which they could not point at while +those fields had no rule; the gate now compares the two planes against each other. +`Offer.signature` also becomes mandatory in practice, since the empty string does not match the +pattern — which is what the message already meant, an offer whose terms, pricing and expiry are +not signed being no offer. + +**`transaction_id` is enforced non-empty on `UsageReport` and `DisputeRequest` (BREAKING: rule +addition on live fields).** Both comments said the field MUST be non-empty and both were bare +`string transaction_id = 3;`, so an empty value passed. The rule is not a shape preference: for +these two RPCs the named transaction IS the dedupe namespace for `idempotency_key`, so a message +that names no transaction has no namespace to dedupe within, and the namespace invariant stated on +`TransactionRequest.idempotency_key` — one caller's key never collides with another caller's +cached result — has nothing to hold it up. Both fields now carry `min_len: 1`, with no upper +bound, because the Exchange assigns the id and nothing upstream constrains its length. The prose +MUST and the schema now say the same thing. + +**Operator plane: forensic evidence read — `GetTransactionEvidence` (additive).** +`AdminService` gains its first read: +`GetTransactionEvidence(GetTransactionEvidenceRequest) → GetTransactionEvidenceResponse` +returns the append-once evidence row the Exchange persists for every successfully executed +transaction — the full signed offer (`offer_json` plus the verbatim RFC 8785 JCS +`offer_canonical_bytes`), both Ed25519 proofs (`offer_sig`, `agent_acceptance_signature`) with +the acceptance's four signed inputs, both verifying public keys — plus the transaction-log and +reporting-obligation state a ledger renderer needs. New messages: `TransactionEvidence`, +`TransactionState`, `ReportingObligationState`, `RequestCorrelation`, the request/response +envelopes; new enum `ObligationState`, which is exactly the storage model's persisted vocabulary +(`PENDING`/`FULFILLED`/`EXPIRED`/`WAIVED`/`BLOCKED`). Selection is by the +`(tenant_id, transaction_id)` PAIR — transaction ids legitimately circulate to counterparty +agents, so an id alone must not act as a bearer capability for the forensic row; a tenant +mismatch is `NOT_FOUND`, byte-identical to an unknown id, so existence under another tenant is +not revealed. The row re-verifies OFFLINE, and the contract states the trust boundary +explicitly: offline verification proves the row is internally consistent, while authenticity +requires comparing the embedded keys against independently obtained copies. The Exchange anchor +is signed — `Offer.exchange` inside `offer_canonical_bytes`; the agent side has none, so +`agent_directory_url` is provenance and the agent key must be anchored independently. +The delivery join is hash-only +(`TransactionState.signed_url_hash` against the edge log) — the full signed URL, a live bearer +capability, never appears on this plane, so a signed-URL *signature* match assertion is +deliberately unproducible from this contract. The correlation id the Exchange persisted rides in +`RequestCorrelation` (bounded printable ASCII, with a `minted` provenance flag); the agent plane +still carries no correlation field in any message body. `TransactionState.signed_url_expiry` and +`TransactionState.signed_url_hash` are both optional, because not every delivery method mints a +signed URL: a `DELIVERY_METHOD_DIRECT` transaction returns the resource inline or from the +Exchange's own endpoint, so it has no URL, no expiry and nothing to hash. Requiring them would +leave a successfully executed direct transaction with no legal value to send for two mandatory +fields. Absence is the stated fact that no signed URL existed; a value that IS present must still +be a full 32-byte digest. + +**`transaction_id` entropy: the guarantee is narrowed to what it actually is (wording fix; no +wire change).** `ramp.v1.TransactionResultItem.transaction_id` said RAMP places no entropy +requirement on a transaction id because the evidence read selects by a pair, and concluded that +"nothing rests on this field's format being unguessable". The first half is right and the +conclusion was too strong. What the pair selector buys is that a transaction id ALONE is never a +bearer capability for the forensic row — which matters, because counterparty agents legitimately +hold the ids of their own transactions. It does not make enumeration infeasible: tenant ids are +human brand slugs, and a tenant's slug is handed to every agent holding one of its offers, +because it prefixes the `offer_id` inside the signed offer. A caller who can reach the admin +plane and has done business with a tenant can pair a known tenant with guessed ids. Enumeration +is bounded by the network-layer reachability restriction on `ramp.admin.v1`, which is therefore +the load-bearing control rather than a deployment convenience. Both planes and the threat model's +enumeration entry now say this the same way. + +The cross-package dependency is also gated. The agent-plane claim rested on the shape of +`ramp.admin.v1.GetTransactionEvidenceRequest`, and nothing failed if that shape changed — the +only other mention was inside the generated corpus, which would simply regenerate smaller. A +conformance guard now fails when the selector stops being a pair, so weakening it can no longer +leave a published agent-plane promise quietly false. + +**Evidence row: the offline trust boundary now names a SIGNED anchor, and `agent_directory_url` +is bounded (rule addition on a message that has not shipped).** The trust-boundary recipe told a +verifier to check the embedded keys against independently obtained copies, then pointed at +`agent_directory_url` for the agent side — a field covered by neither signature and written by +the same party as the rest of the row. A fabricated row satisfied the entire documented procedure +using one host its author controlled. The recipe now separates the two sides. The Exchange anchor +is read OUT of `offer_canonical_bytes`: `ramp.v1.Offer.exchange` names the issuing host and sits +under `offer_sig`, so changing it invalidates the signature the check exists to confirm. The +agent side has no signed anchor, and the contract now says so — `agent_directory_url` is +provenance, never authority, and the agent key must be anchored independently, which is equally +true when the field is `''`. It is also added to the list of unsigned self-assertions under +SCOPE OF THE GUARANTEE. + +The field additionally gains `max_len: 512` and a pattern accepting `''` or an https URL whose +host uses the same recipient-host grammar as `Offer.exchange`, with an optional port and an +ASCII-printable path. The rule bounds the damage from tooling that follows the value anyway; it +does not make following it safe, and the comment states what it does not catch — an IPv4-literal +host still matches, because the recipient-host grammar admits all-numeric labels. A conformance +test pins the accepted and refused set so the comment and the rule cannot drift apart. + +The row's replay exposure is now stated rather than left to inference. `offer_json` + `offer_sig` +are a complete Exchange-signed offer, and `ramp.v1.Offer` binds no requester and no tenant, so a +row holder can accept the same offer under their own identity until it expires; `expires_at`, +`Offer.exchange` and this plane's reachability restriction bound that without closing it, and +closing it needs a requester audience inside the signed offer, which belongs upstream in +`ramp.v1`. The acceptance is the opposite case: the row carries a complete resubmittable +acceptance, but resubmitting it lands in the same dedupe namespace and returns the original +result, and the row holds no private key with which to mint a different one. + +**The agent identity for a transaction is the ACCEPTANCE key (wording fix; no wire change).** +The schema named two different keys as the source of the same embedded value. `AgentAcceptance` +said `agent_identity_hash` is the RFC 7638 thumbprint of the acceptance key; the file header and +`TransactionResponse.agent_identity_hash` said the request-signing key. They are the same key +only on a direct hop. A Broker may author a re-packaged transaction as sender, and on that leg +the RFC 9421 signer is the broker while the in-body acceptance is the only agent-authored +signature in the request — so the transport signer cannot be the anchor. `AgentAcceptance` now +carries the normative definition and every other site cites it: the identity is the acceptance +key where an acceptance is present, and the verified request signer otherwise, which is safe +only because an acceptance-less request cannot have been relayed. + +Two rules that shipping code already enforces are now written down. The ONE-KEY RULE: an agent +MUST accept an offer and fetch the delivered resource with the same key, because the URL is bound +to the acceptance-key thumbprint and an enforcing delivery endpoint checks the fetching key +against it — accepting with one key and fetching with another produces a transaction that +succeeds and a retrieval that is refused. It binds only where proof-of-possession is enforced (a +bearer-only CDN keeps the bearer posture), and a custodial registry holding the one key satisfies +it with nothing extra to do. And because `TransactionResponse.agent_identity_hash` is a single +per-request value, every acceptance in one `TransactionRequest` MUST be signed by the same key. + +**Idempotency dedupe scope: one invariant, three named mechanisms (wording fix; no wire +change).** The same sentence — "uniqueness is scoped to the verified RFC 9421 signer" — was +copy-pasted onto `TransactionRequest`, `UsageReport` and `DisputeRequest`, three RPCs that do not +authenticate the same way. Under a broker-repackaged execute it namespaces every agent behind one +broker together, which is the collision the sentence exists to forbid. The invariant is now +stated once on `TransactionRequest.idempotency_key` — a key chosen by one caller MUST NEVER +collide with another caller's cached result — and each RPC names the namespace that makes it +true: `ExecuteTransaction` scopes to the acceptance identity; `ReportUsage` and `FileDispute` +carry no acceptance payload and scope to the transaction the message names, which is bound to +exactly one agent by its acceptance at execute time. Both of those RPCs therefore state that +`transaction_id` MUST be non-empty. That is prose here; the schema rule enforcing it is filed +separately. + +**`Offer.signature` is a detached hex Ed25519 signature, not a JWS (wording fix; no wire +change).** The schema described one field two ways. `Offer.signature`, +`Offer.signature_algorithm` and the file-header summaries called it a JWS with `alg=EdDSA`, while +`AgentAcceptance` described the same convention as "a hex-encoded detached Ed25519 signature +(NOT a JWS)". Hex is the reading every verifier implements, and the operator plane depends on +it: `TransactionEvidence.offer_sig` is pattern-enforced as 128 hex characters and the offline +verification recipe re-verifies those bytes directly, so under the JWS reading every evidence row +would fail its own validation. The JWS wording is now gone from all four sites. The value of +`signature_algorithm` stays `"EdDSA"` — the JOSE algorithm identifier is borrowed, the envelope +is not. Nothing on the wire changes: a client emitting a JWS here was already producing a value +no Exchange accepts. + +**Generated clients: `TransactionRequest.items` is now required in the Pydantic/Zod export +(breaking for the generated clients; no wire change).** The Go server has always rejected an +omitted `items` (`repeated.min_items = 1`); the generated clients accepted the omission and +diverged. The required-fields inference now covers `repeated.min_items ⇒ required`, closing that +gap for a pre-existing agent-plane type. + +**Generated clients: exact-length bytes fields are enforced, and both base64 alphabets are +accepted (no wire change).** A `bytes.len = N` rule (the evidence row's Ed25519 keys and +sha256 hash) now renders in the Pydantic/Zod export as the exact encoded forms of N bytes — the +loose character window protoschema emits would also admit an N+1-byte value — and the pattern +accepts standard and url-safe base64 alike, because Go `protojson` accepts either on decode; a +client rejecting base64url (e.g. a JWK `x` value pasted verbatim) would refuse input the server +accepts. It accepts them as two ALTERNATIVES — one alphabet per value — because that is what the +decoder does: `protojson` switches to the url-safe alphabet as soon as the string contains `-` or +`_`, then decodes strictly, so a value mixing `+` with `_` is refused, and the generated pattern +refuses it too. Padding is derived from the payload length mod 4 rather than left as a free tail, +so `"AA="`, `"AAA=="` and `"AAAAA"` — none of them a legal encoded length — are rejected exactly +as the server rejects them. The signing-algorithm labels are pinned `string.const = "EdDSA"` +rather than `min_len: 1`, so a generated client also rejects a claimed `"none"`. `bytes.min_len = 1` (the +canonical-bytes fields) is translated the same way: the generated pattern now requires the +encoded payload characters of at least one real byte before the padding tail, so the +two-character string `"=="` — pure padding, zero bytes, which Go `protojson` refuses to +decode — no longer passes the clients, and the pipeline fails closed on any bytes rule shape +it cannot translate. + +The conformance tooling grew with the surface: the corpus generator understands `string.const` +and fails closed on any rule shape it cannot classify, and the restated-rule drift gate now +derives its scope from the descriptor itself — every rule-identical field group must carry a +`Same rule as` directive or an explicit coincidence exemption — instead of an opt-in comment +marker plus a hand-maintained list. The base64 wire forms the two generated clients must decide +identically now live in one shared vector file (`conformance/testdata/bytes_wire_forms.json`) +that a conformance test pins against Go `protojson` + protovalidate, so each row is written once, +cannot drift between the Pydantic and Zod suites, and states the server's real verdict rather +than a belief about it. The evidence-read messages contribute 94 of the new corpus cases; the +committed corpus goes from 549 at the branch point to 703, a net +154 made of 179 added and 25 +removed across 31 messages, because tightened rules elsewhere in this revision replace cases +rather than only adding them. + +Nineteen of those additions close a gap in the generator rather than in the contract. Where a +field rejects its zero enum with `not_in: [0]` and has no explicit presence, protojson drops the +value, so the emitted case pins "omission is rejected". The explicit `*_UNSPECIFIED` string is a +different parse path in a generated client — the name is absent from the emitted enum, so the +client must refuse it — and it had no case at all. The enum edge now emits the same +omitted/explicit pair that the `string.min_len`, `bytes.min_len` and `repeated.min_items` edges +already emitted, which is where the shape was copied from. + **Every addressed request names its recipient: `exchange` becomes required (breaking, pre-1.0).** `ResourceQuery` (field 10), `DisputeRequest` (field 10), `RegisterRequest` (field 3), `GetAccountStatusRequest` (field 2), `DomainVerificationRequest` (field 4), @@ -232,7 +600,7 @@ offending `registration_data` members alongside the reason — variadic in Go, a trailing argument in Python and TS, so the six reasons that carry no per-member detail keep their three-argument call. Without this a service refusing a non-conforming registration had to build the `ErrorDetail` by hand or mutate the builder's result, defeating the rule these -helpers exist for: one place per language where the ADR-019 envelope is constructed. This is +helpers exist for: one place per language where the ErrorDetail envelope is constructed. This is the only `*Detail` builder that reaches past the reason enum — the schema refusal is useless without naming what failed, whereas the sibling detail lists (`TransactionDenial.restriction_mismatches`, `CatalogRejection.rejected_paths`) stay @@ -304,7 +672,8 @@ contract's first repeated message field carrying its own `repeated.max_items`, a generator previously produced only scalar list items. **The `ver` envelope field states its contract, and the version string gets one owner -(no wire change).** All 29 `ver` fields — 25 in `ramp.proto`, 4 in `admin.proto` — now name +(no wire change).** All `ver` fields — 29 at the time of this change (25 in `ramp.proto`, 4 in +`admin.proto`); the evidence-read envelopes later added 2 more with the same wording — now name the expected value `"1.0"` and the receive-side rule. Before this, 27 of them said only "Protocol version" or "RAMP protocol version", and `DiscoveryResponse.ver` carried no comment at all — 28 fields from which an integrator could not learn what to stamp. Only @@ -508,7 +877,8 @@ pre-existing `ErrorDetail.registration_failure` / `RegistrationFailureReason` path, which until now had no RPC front door. Pre-v1 additive change. **Operator plane: new `ramp.admin.v1` package with `AdminService` (additive).** -Two full-replace, idempotent setters for Exchange operators — +At introduction, two full-replace, idempotent setters for Exchange operators (the forensic +evidence read joined later in this cycle — see the entry above) — `SetTenantFeeRate(SetTenantFeeRateRequest) → SetTenantFeeRateResponse` and `SetReportingPolicy(SetReportingPolicyRequest) → SetReportingPolicyResponse`. Each request and response is a thin `{ver, }` envelope carrying a diff --git a/proto/ramp/admin/v1/admin.proto b/proto/ramp/admin/v1/admin.proto index 0bb15fdb..57588e37 100644 --- a/proto/ramp/admin/v1/admin.proto +++ b/proto/ramp/admin/v1/admin.proto @@ -1,25 +1,54 @@ -// RAMP Admin v1 — operator-plane configuration service. +// RAMP Admin v1 — operator-plane configuration and forensics service. // -// AdminService carries Exchange operator overrides: the tenant fee rate and -// the tenant reporting policy. It is deliberately a separate package and -// service from ramp.v1.ExchangeService — the operator/config plane is not -// part of the agent hot-path contract, and keeping it out of ramp.v1 keeps -// the agent-facing surface unchanged. +// AdminService carries Exchange operator overrides — the tenant fee rate and +// the tenant reporting policy — plus one forensic read: the append-once +// evidence row the Exchange persists for every executed transaction. It is +// deliberately a separate package and service from ramp.v1.ExchangeService — +// the operator plane is not part of the agent hot-path contract, and keeping +// it out of ramp.v1 keeps the agent-facing surface unchanged. // // Trust model: deployments MUST NOT expose AdminService on the public // agent-facing listener. Reachability is restricted at the network layer // (an internal listener plus a source allowlist); there is no per-operator // identity inside the service in v1. Because the admin plane carries no // RFC 9421 request signing, there is no verified signer to deduplicate -// against — and both RPCs are full-replace overwrites, so they are naturally -// idempotent and carry no idempotency_key. +// against — and no RPC here needs one: the setters are full-replace +// overwrites and the evidence read is side-effect-free, so every RPC is +// naturally idempotent and carries no idempotency_key. // -// Message shape: each RPC takes a thin {ver, } envelope wrapping a +// The evidence read is keyed by the (tenant_id, transaction_id) PAIR. The +// tenant selector exists because transaction ids leave the deployment: +// every counterparty agent legitimately holds the ids of its own +// transactions, so an id alone must not act as a bearer capability for the +// forensic row. Naming the tenant narrows what a leaked id is worth; it is +// NOT an access control, and this plane has none — it carries no request +// signing and no per-operator identity, so there is no caller to attach a +// per-tenant rule to. A tenant +// mismatch is NOT_FOUND, byte-identical to an unknown id, so existence +// under another tenant is not revealed. The id format itself is +// implementation-defined (ramp.v1 places no entropy requirement on +// transaction ids); what bounds this read is the pair selector plus the +// network-layer reachability restriction above. +// +// Those two controls are not interchangeable, and the weaker one must not be +// mistaken for the stronger. The pair selector stops a transaction id ALONE +// from reading a row. It does NOT make enumeration infeasible: tenant ids are +// human brand slugs, and a tenant's slug is visible to every agent holding one +// of its offers, so a caller who reaches this plane can pair a known tenant +// with guessed ids. Enumeration is bounded by reachability — this service MUST +// NOT be exposed on the public agent-facing listener — which is why that +// restriction is the load-bearing control on this plane rather than a +// deployment convenience. +// +// Message shape: each setter takes a thin {ver, } envelope wrapping a // required payload message — TenantFeeRate or ReportingPolicy. The payload // type is shared by the request and its response, so every field rule is // stated ONCE; the read-back response cannot drift from the write. Responses // echo the payload as persisted, giving operator tooling a read-back -// confirmation of the applied values. +// confirmation of the applied values. The evidence read does not share this +// shape — its request carries only the (tenant_id, transaction_id) selector, +// and its response wraps read-only payloads that exist on no write path +// (TransactionEvidence, TransactionState, ReportingObligationState). // // Validation: every constraint here is a FIELD-level protovalidate rule so it // flows into the generated Pydantic/Zod types. Cross-field (message-level CEL) @@ -38,11 +67,12 @@ syntax = "proto3"; package ramp.admin.v1; import "buf/validate/validate.proto"; +import "google/protobuf/timestamp.proto"; option go_package = "github.com/RAMP-Protocol/protocol/gen/go/ramp/admin/v1;rampadminv1"; -// Operator-plane configuration RPCs. See the file banner for the trust model -// and why these setters carry no idempotency_key. +// Operator-plane configuration and forensic-read RPCs. See the file banner +// for the trust model and why no RPC here carries an idempotency_key. service AdminService { // Sets the tenant's fee rate (basis points) and optional operator note. // Full replace: the previous rate and note are overwritten. @@ -51,6 +81,20 @@ service AdminService { // tolerance, reporting window). Full replace: omitted optional fields clear // their value so the receiving Exchange's defaults apply. rpc SetReportingPolicy(SetReportingPolicyRequest) returns (SetReportingPolicyResponse); + // Returns the append-once evidence row for one executed transaction — the + // full signed offer, both Ed25519 proofs and the verbatim bytes each was + // computed over — plus the transaction-log and reporting-obligation state + // needed to render it. Read-only: it exposes what the execute path already + // persisted and writes nothing. Selection is by (tenant_id, transaction_id) + // pair: an unknown transaction_id AND a transaction that exists under a + // different tenant are both NOT_FOUND, indistinguishably — a transaction id + // alone must not act as a bearer capability for another tenant's forensic + // row. What the pair selector buys is exactly that and no more: it narrows + // what a leaked id is worth. It is not an access control. This plane carries + // no request signing and no per-operator identity, so there is no caller to + // attach a per-tenant rule to; the network allowlist is the only gate in + // front of this RPC. + rpc GetTransactionEvidence(GetTransactionEvidenceRequest) returns (GetTransactionEvidenceResponse); } // TenantFeeRate is the fee-rate payload shared by SetTenantFeeRate's request @@ -78,7 +122,8 @@ message TenantFeeRate { // SetReportingPolicy's request and response. The field rules live here once, so // the write and the echoed read-back stay in lockstep. message ReportingPolicy { - // The tenant whose reporting policy is being replaced. + // The tenant whose reporting policy is being replaced. Same rule as + // ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated). string tenant_id = 1 [(buf.validate.field).string = {min_len: 1, max_len: 255}]; // Report field names the usage-report validator requires. The wire constrains @@ -147,3 +192,595 @@ message SetReportingPolicyResponse { // The reporting policy as persisted. ReportingPolicy policy = 2 [(buf.validate.field).required = true]; } + +// ObligationState — lifecycle of a reporting obligation, exactly the +// vocabulary the Exchange persists (the storage model's ObligationPending/ +// Fulfilled/Expired/Waived/Blocked, see components/exchange/storage-model). +// Defined here rather than imported from ramp.v1 (which carries no such +// enum — the agent plane states reporting REQUIREMENTS, never their +// server-side lifecycle) so the admin package stays self-contained. +// +// A REJECTED usage report does not transition the obligation: it stays +// PENDING until an accepted report, a waiver, or expiry. A rejection carries +// no response message at all — ramp.v1.UsageReportResponse is sent only when +// the report was ACCEPTED, and a rejection travels as a non-OK transport +// error carrying ramp.v1.ErrorDetail.usage_report_rejection. So a ledger +// renderer reading a PENDING obligation cannot assume a response was ever +// produced for the attempt that left it PENDING. +enum ObligationState { + OBLIGATION_STATE_UNSPECIFIED = 0; // never sent — the server always maps a persisted state; rejected (not_in:[0]) on ReportingObligationState.state + OBLIGATION_STATE_PENDING = 1; // minted; awaiting an accepted usage report + OBLIGATION_STATE_FULFILLED = 2; // a usage report was received and accepted (including a late report accepted out of BLOCKED) + OBLIGATION_STATE_EXPIRED = 3; // the reporting window elapsed with no accepted report + OBLIGATION_STATE_WAIVED = 4; // the Exchange waived the requirement + OBLIGATION_STATE_BLOCKED = 5; // enforcement gate: an expired obligation met a new transaction attempt; new transactions are rejected until the Exchange lifts the block or accepts a late report +} + +// TransactionEvidence — one append-once evidence row, exactly as the Exchange +// persisted it for a successfully executed transaction. The row is written +// only after both signatures verified, and a denied execute writes nothing, +// so the row's existence is itself the success statement. +// +// The row re-verifies OFFLINE, from this message alone — no agent registry, +// no Exchange key file, no live service: +// * the Exchange signed this exact offer: +// ed25519.Verify(exchange_signing_public_key, offer_canonical_bytes, hex-decoded offer_sig) +// * the agent signed an acceptance: +// ed25519.Verify(agent_public_key, agent_acceptance_canonical_bytes, hex-decoded agent_acceptance_signature) +// * and that acceptance is THIS agreement — not merely a valid acceptance: +// JCS-parse(agent_acceptance_canonical_bytes) matches the row, member for +// member. Three names coincide; the fourth does not: +// payload offer_sig == offer_sig (hex, case-insensitive) +// payload requester_id == requester_id +// payload requester_domain == requester_domain +// payload idempotency_key == request_idempotency_key +// Those four are every field of ramp.v1.AgentAcceptancePayload, and the row +// stores all four so this comparison is possible from the row alone. +// The third step is not bookkeeping, and offer_sig alone is not enough for it. +// Two failures it prevents, which are different: +// SPLICING, by an outsider. A genuine offer from one transaction and a +// genuine acceptance from another, by the same agent, both verify against +// real keys and pass the authenticity step below. offer_sig catches this one. +// FABRICATION, by whoever writes the row. Two acceptances by one agent +// against ONE offer share offer_sig and differ only in the idempotency key, +// which ramp.v1.AgentAcceptancePayload.idempotency_key exists to bind. So an +// Exchange holding a single genuine acceptance can write two rows for two +// different executes against one offer, and both pass an offer_sig-only +// check. Only the idempotency-key comparison separates them. +// Comparing all four leaves no member of the signed payload unchecked, which is +// the only version of this step that means what it says. A verifier that skips +// it has checked two signatures and no agreement. The Exchange MUST perform the +// same comparison before persisting a row, so a bad row is never written. +// Both verifying public keys ride along (not key ids) so re-verification +// survives key rotation, which removes retired ids from the published JWKS. +// The *_canonical_bytes are the verbatim RFC 8785 JCS bytes each signature +// was computed over, stored as-signed and never re-derived: protobuf-binary +// is non-canonical by protocol rule, and a stored-inputs-only row would stop +// re-verifying the day the canonicalization recipe moved. +// +// TRUST BOUNDARY. Offline re-verification proves the row is INTERNALLY +// CONSISTENT: each signature verifies against the key and bytes stored in +// the same row, so anyone able to write a row could mint one that passes. +// To prove AUTHENTICITY — that these parties actually operated these keys — +// a verifier must compare the embedded keys against copies obtained +// independently. WHERE to obtain them is the whole question, and only one of +// the two sides has an anchor inside a signature. +// +// EXCHANGE SIDE — anchored in the signed bytes. offer_canonical_bytes +// carries the offer's `exchange` field (ramp.v1.Offer.exchange, the bare +// host of the issuing Exchange), and offer_sig covers it. A verifier reads +// that host OUT of the canonical bytes, fetches THAT Exchange's published +// JWKS (the authority per protocol/authentication), and checks +// exchange_signing_public_key against it. A fabricated row cannot redirect +// this step: changing `exchange` invalidates the very signature the check +// exists to confirm. +// +// AGENT SIDE — no signed anchor exists, and the row does not supply one. +// agent_directory_url is covered by NEITHER signature and is written by the +// same party as the rest of the row, so a fabricated row satisfies any +// procedure built on it using a host its author controls. It is a record of +// where this Exchange states it pinned the key — provenance, never the +// authority. The agent anchor must be obtained INDEPENDENTLY: from the +// counterparty the audit is being run for, or from the agent's own directory +// located through an identity the verifier already trusts. This is unchanged +// when agent_directory_url is '' (the agent carried no directory anchor): +// there is no fallback to reconstruct, because the field was never the +// authority to fall back from. +// +// The in-row keys are convenience copies that keep old rows verifiable after +// rotation; they are not the root of trust. After matching a key against its +// authority, a verifier should also check that key's RFC 7638 thumbprint +// against a revocation list, because a key can be rotated out BECAUSE it was +// revoked, and a revoked key must not count as authentic. +// +// There is no single list covering both keys. WBAFile.revocation_url is one +// URL per DIRECTORY, so the ramp.v1.KeyRevocationList served there can only +// enumerate that directory's own revoked keys. Each key is therefore checked +// against ITS OWN side's list, reached the same way its anchor was: +// exchange_signing_public_key against the issuing Exchange's list, reached +// from the `exchange` host inside offer_canonical_bytes; agent_public_key +// against the agent's list, reached from the independent directory that +// supplied the agent anchor above — never from agent_directory_url, which is +// provenance and not authority. +// +// SCOPE OF THE GUARANTEE. The signatures cover what was AGREED, not what was +// DELIVERED. transaction_id, request_correlation, broker, created_at and +// agent_directory_url are this Exchange's own assertions, outside both +// signatures; the delivery witness is the edge delivery log, reconciled +// separately (the join key, sha256 of the signed retrieval URL, lives on +// TransactionState.signed_url_hash). agent_directory_url is listed here as well as under the +// trust boundary above because it is the one unsigned field a reader is most +// likely to mistake for an anchor. +// +// WHAT A ROW HOLDER CAN REPLAY. The delivery section below withholds the +// signed retrieval URL because it is a live bearer capability. Applying the +// same test to what the row DOES carry gives two different answers. +// +// THE OFFER IS REPLAYABLE, AND THAT IS A STATED RESIDUAL RISK. offer_json +// plus offer_sig are a complete, valid, Exchange-signed offer. +// ramp.v1.Offer binds NO requester and NO tenant — it has no audience field +// naming who the offer was issued to — and its expires_at is optional. So a +// row holder can present this same offer to its issuing Exchange and accept +// it under their OWN identity, and nothing inside the signed bytes +// contradicts them. Three things bound that, and none of them closes it: +// - expires_at ends the window, when the Exchange set one; +// - Offer.exchange names exactly one Exchange that will accept the offer, +// so a replay is confined to that Exchange's own terms and billing; +// - the network-layer reachability restriction on this plane (see the file +// header) decides who can read a row at all. +// Closing it needs a requester audience INSIDE the signed offer, which +// belongs upstream in ramp.v1 and is not something this plane can add. +// +// THE ACCEPTANCE IS NOT USEFULLY REPLAYABLE. The four fields of +// ramp.v1.AgentAcceptancePayload are offer_sig, requester_id, +// requester_domain and idempotency_key — the last of which this row stores +// under the name request_idempotency_key, to keep it distinct from the +// derived per-item key on TransactionState. agent_acceptance_signature is the +// signature over those four, so the row does hold a complete, resubmittable +// acceptance. Resubmitting it achieves nothing: it carries the same +// request-level idempotency key under the same acceptance identity, so it +// lands in the same dedupe namespace and the Exchange returns the original +// result instead of executing again. What the row does NOT hold is the +// agent's private key, so a holder cannot mint an acceptance for a different +// offer, identity, or key. The replay exposure here is the offer's, not the +// acceptance's. +message TransactionEvidence { + // The evidenced transaction (Exchange-minted transaction identity). The + // format is implementation-defined, exactly as in ramp.v1 (the documented + // storage model mints a 26-char ULID). The 255 bound is NEW to this plane — + // ramp.v1 leaves transaction ids unconstrained — and is safe here because + // the Exchange mints the id itself, far below that bound; it exists so the + // selector stays storable and indexable. + string transaction_id = 1 [(buf.validate.field).string = {min_len: 1, max_len: 255}]; + + // The tenant the transaction executed under. The admin plane is + // deployment-scoped (cross-tenant), so the row states its tenant. Same rule + // as ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated). + string tenant_id = 2 [(buf.validate.field).string = {min_len: 1, max_len: 255}]; + + // Exchange side: the signed offer. + + // The signed Offer.offer_id (which IS the catalog resource_id). Duplicated + // from the offer JSON so the row reads standalone, without parsing it. + string offer_id = 3 [(buf.validate.field).string.min_len = 1]; + + // The signed offer as a raw JSON string, for query and human audit. + // Deliberately NOT a Struct: a Struct re-normalizes, and the canonical + // bytes below remain the arbiter of what was signed. No upper bound, unlike + // this file's 255-capped ids: upstream ramp.v1 places no size bound on an + // offer, and the row must state whatever the parties actually signed — a + // cap here could make the row fail its own validation for a transaction + // that legitimately executed (the requester_id rationale). + string offer_json = 4 [(buf.validate.field).string.min_len = 1]; + + // Verbatim JCS bytes the Exchange's signature was computed over (the offer + // with its signature fields cleared). min_len only, no ceiling: same + // rationale as offer_json — the bytes under the signature are whatever size + // the signed offer was, and a bound could invalidate a legitimate row. + bytes offer_canonical_bytes = 5 [(buf.validate.field).bytes.min_len = 1]; + + // The Exchange's Ed25519 signature over offer_canonical_bytes, hex-encoded + // in the verbatim wire form (either case — hex decoding accepts both, and a + // dispute should read the same characters a request log holds). Named after + // ramp.v1.AgentAcceptancePayload.offer_sig: it is the same value, the one + // the agent's acceptance binds to. Same rule as ramp.v1.Offer.signature + // (drift-gated) — the field this row stores a copy of. + string offer_sig = 6 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]{128}$"]; + + // Signing-algorithm label, server-derived from the Exchange's own verify + // path — never echoed from the wire. The canonical payload clears the wire + // labels before signing, so an echoed label would sit outside signature + // coverage and could claim anything under an otherwise valid signature. + // Pinned to "EdDSA" — the content-signature label + // ramp.v1.Offer.signature_algorithm pins; "ed25519" is the separate label + // reserved for RFC 9421 HTTP request signatures and never appears here. + // const (not min_len) so a generated client also rejects a claimed "none" + // or "HS256". + // + // Spelled sig_algorithm, not signature_algorithm, which is how ramp.v1 and + // the sibling agent_acceptance_signature_algorithm spell it. The short form + // is INHERITED, not chosen: this label names the neighbouring offer_sig, and + // that field copies an upstream field name verbatim + // (ramp.v1.AgentAcceptancePayload.offer_sig). A label that renamed the field + // it describes would be the worse inconsistency. + // + // The long spelling is also not available: ramp.v1 retired a scalar + // offer-signature field (the execute request now reflects the + // full signed Offer instead), and scripts/check-doc-conformance.sh bans that + // identifier across the protos and the docs so the removed name cannot be + // read as live anywhere. A field named after it here would either fail that + // gate or force it open. + string offer_sig_algorithm = 7 [(buf.validate.field).string.const = "EdDSA"]; + + // The Exchange verifying key itself (raw 32-byte Ed25519), not a key id. + bytes exchange_signing_public_key = 8 [(buf.validate.field).bytes.len = 32]; + + // Agent side: the acceptance. + + // The agent's Ed25519 signature over agent_acceptance_canonical_bytes, + // hex-encoded verbatim as it arrived on the wire (either case). Same rule + // as ramp.v1.AgentAcceptance.signature (drift-gated) — the live field this + // row stores a copy of. + // + // Both directives here point UPSTREAM into ramp.v1, which they did not + // always do. This pattern was pinned on the read plane first, while the + // agent plane still described the hex shape in prose and enforced nothing; + // the anchors sat inside this package because there was no upstream rule to + // point at. ramp.v1 now carries the rule on both signature fields, so the + // gate compares the two planes against each other and a future tightening + // on one side can no longer leave the other silently behind. + string agent_acceptance_signature = 9 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]{128}$"]; + + // Verbatim JCS bytes of the AgentAcceptancePayload the agent signed. + // Unbounded for the same reason as offer_canonical_bytes. Same rule as + // ramp.admin.v1.TransactionEvidence.offer_canonical_bytes (drift-gated). + bytes agent_acceptance_canonical_bytes = 10 [(buf.validate.field).bytes.min_len = 1]; + + // Signing-algorithm label, server-derived (see offer_sig_algorithm). + // Pinned to "EdDSA". Same rule as + // ramp.admin.v1.TransactionEvidence.offer_sig_algorithm (drift-gated). + string agent_acceptance_signature_algorithm = 11 [(buf.validate.field).string.const = "EdDSA"]; + + // The acceptance payload's remaining inputs (offer_sig above is the + // fourth), stored so the signed bytes can be independently rebuilt and + // audited rather than merely trusted. + // + // requester_id is the signed Requester.id VERBATIM — the bytes under the + // agent's signature, never rewritten. It NAMES the same agent as the + // Exchange's canonical agent identity but is not byte-equal to it: a signer + // may spell its directory any way it likes (the deployed identity service + // signs "scheme://host"), so the forensic join goes through directory-host + // normalization, not plain equality. No wire rule: the agent plane does not + // constrain Requester.id, and this row states what was signed. + string requester_id = 12; + + // The signed Requester.domain, verbatim. Unbounded HERE even though the + // agent plane bounds it — ramp.v1.Requester.domain carries max_len 260 and + // the bare-host pattern. Those rules govern what an Exchange may ACCEPT on + // the way in; they do not govern what this row may STATE after the fact. The + // row's job is to reproduce the bytes the acceptance actually signed, so a + // rule here could make the row fail its own validation for a transaction + // that legitimately executed — one accepted under an earlier rule set, or + // signed by a party that spelled the value some other way. Same conclusion + // as requester_id, reached differently: Requester.id genuinely carries no + // wire rule at all. + string requester_domain = 13; + + // The REQUEST-level idempotency key the acceptance signs — NOT the derived + // per-item key that TransactionState.idempotency_key carries. Same rule as + // ramp.v1.TransactionRequest.idempotency_key (drift-gated). + string request_idempotency_key = 14 [(buf.validate.field).string = {min_len: 1, max_len: 255}]; + + // The registry-pinned agent verifying key (raw 32-byte Ed25519) the + // acceptance verified against. This is the ACCEPTANCE key, which is the + // agent identity for the transaction — ramp.v1.AgentAcceptance defines that + // normatively under "Agent identity", and this row stores the key that + // definition names. It is deliberately NOT the transport signer: a Broker + // may author a re-packaged execute as sender, so the RFC 9421 signer on that + // leg is the broker, and a row anchored on it would name the wrong party. + // Same rule as + // ramp.admin.v1.TransactionEvidence.exchange_signing_public_key + // (drift-gated). + bytes agent_public_key = 15 [(buf.validate.field).bytes.len = 32]; + + // The anchored well-known directory agent_public_key was pinned from. The + // registry overwrites keys in place on rotation and keeps no history, so + // this — plus created_at — attests where and when this Exchange obtained + // the key. Empty when the agent carries no directory anchor: an append-once + // row states a value for every column, so '' is a stated fact, not a gap. + // + // PROVENANCE, NOT AUTHORITY. This field is covered by neither signature and + // is written by the same party as the rest of the row, so it can never + // establish that agent_public_key is authentic — see TRUST BOUNDARY above, + // which says where the agent anchor must come from instead. Verification + // tooling MUST NOT treat this value as a fetch target it can trust: the row + // author chose it, so following it hands them the choice of what the + // "independent" copy says. + // + // The rules below bound the damage from tooling that follows the field + // anyway; they do not make following it safe. The value must be '' or an + // https URL whose host uses the same recipient-host grammar as + // ramp.v1.Offer.exchange, with an optional port and an ASCII-printable path, + // within 512 bytes. Stated precisely, because a rule that sounds stronger + // than it is would be worse than none: this refuses a plaintext or non-http + // scheme, embedded userinfo or whitespace, and anything that is not a + // host-plus-path shape. It does NOT refuse an IPv4-literal host — the + // recipient-host grammar admits all-numeric labels, so https://169.254.169.254/ + // matches. Blocking link-local and private address space is the fetching + // tool's job, and it is one more reason this field is not a fetch target. + // + // Named directory, not discovery: ramp.v1 uses "discovery" for RESOURCE + // discovery (DiscoveryRequest, OfferGroup.discovery_method), a different + // thing entirely. This is the agent's well-known directory document, which + // is what every sentence describing the field already calls it. + string agent_directory_url = 16 [ + (buf.validate.field).string = { + max_len: 512 + pattern: "^$|^https://[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?/[!-~]*$" + } + ]; + + // Delivery + correlation. Covered by neither signature. + // + // Deliberately NO signed retrieval URL here: the full URL is a live bearer + // capability until expiry, and this plane has no request signing and no + // per-operator identity — serving it back would turn any read into a + // content-access oracle. The delivery join is hash-only, via + // TransactionState.signed_url_hash. Stated consequence: a ledger rendered + // from this contract can NEVER show a signed-URL *signature* match + // assertion — the delivery assertion this plane supports is hash equality, + // TransactionState.signed_url_hash against the transaction log's + // signed_url_hash column. Both sides hold the same SHA-256 digest as 32 RAW + // BYTES, so the comparison is byte-to-byte with nothing to normalize. An + // encoding only enters where a store is rendered as text — a log EXPORT, or + // protojson on this plane, which base64s the field. Two text renderings of + // the same digest are not comparable to each other, so a query that joins + // exports must know which rendering each side used; the transaction-log + // contract owns that decision for its export, and this contract does not + // pin it. + + // Correlation id joining this row outward to whatever else recorded the + // same X-Request-ID for this execute call, with its provenance. One + // message, not two + // sibling fields: presence of the message is the pairing — id and + // provenance flag arrive together or not at all, a constraint two + // optional siblings could not express without message-level CEL (which + // this file forbids). Absent when the Exchange recorded no correlation id. + RequestCorrelation request_correlation = 17; + + // When the Exchange wrote this row (server clock). + google.protobuf.Timestamp created_at = 18 [(buf.validate.field).required = true]; + + // The relay hop that presented this request to the Exchange, if the + // Exchange records one. A transport fact the Exchange observed, covered by + // neither signature — which is why it sits in this section and not on + // TransactionState: TransactionState projects transaction-log columns, and + // broker routing is an execute-time observation about the connection, not + // a property of the transaction's operational state. + // + // Three states, and the `optional` keyword is what makes them distinct: + // ABSENT means this Exchange does not record routing at all; '' means it + // does record it AND the acceptance arrived direct; a value means it + // arrived through that hop. Without explicit presence the field would + // default to '', so an Exchange with nothing to say would state "arrived + // direct" for every row — a forensic plane asserting a transport fact it + // never observed. + // + // WHAT THE VALUE IS: implementation-defined provenance for the outermost + // hop, not a resolvable identity. The reference Exchange serves the + // verified RFC 7638 key thumbprint of the hop that presented the request. + // It deliberately does not resolve that key to a directory host: the relay + // hop is not re-identified against any registry, and the recipient tenant's + // own relay-permission setting is the gate instead. So a reader may compare + // this value for equality and may check it against a thumbprint it already + // holds, but must not expect a hostname, and must not treat it as an + // identity the Exchange vouched for. Only the outermost hop is classified; + // per-hop identity for a longer chain is out of scope here. + // + // The rule bounds the SHAPE without pinning the format. A ledger renders + // this value, so an unbounded string here would re-open on a new field + // exactly the surface request_id's printable-ASCII bound closes — control + // characters, terminal escapes and newlines reaching a rendered forensic + // row. Printable ASCII and 255 characters admit every provenance form a + // server might reasonably record (a thumbprint, a host, an opaque id) while + // refusing the shapes that only matter to a renderer. It is deliberately + // NOT a thumbprint pattern: the value is implementation-defined, and a + // format rule here could invalidate a row for a transaction that + // legitimately executed under a server that spells it some other way — the + // requester_id reasoning. The pattern admits the EMPTY string explicitly, + // because '' is one of the three states — recorded, and the acceptance + // arrived direct. A bare ^[!-~]+$ would need at least one character and + // would delete that state, leaving absence to mean both "not recorded" and + // "arrived direct". Same alternation shape agent_directory_url uses above, + // for the same reason. + optional string broker = 19 [(buf.validate.field).string = { + max_len: 255 + pattern: "^$|^[!-~]+$" + }]; +} + +// RequestCorrelation — the recorded X-Request-ID correlation for one +// evidence row, with its provenance. See TransactionEvidence +// .request_correlation for why this is a message rather than two sibling +// fields. +message RequestCorrelation { + // The correlation id as persisted. GOVERNING INVARIANT, established on the + // WRITE path: a persisted request_id always conforms to the rules below — + // printable ASCII, 1..255 — so a present value has already passed the check + // on the way in, and these rules are not a read-side filter over a laxer + // stored value. HOW a server reaches that invariant is its own choice, and + // two mechanisms both conform: reject the nonconforming header and record a + // server-derived id in its place (minted = true), or record no correlation + // at all (the wrapping message stays absent). The first keeps a correlation + // key for a request whose header was bad, the second states that nothing + // trustworthy arrived; neither can put a nonconforming value in the store, + // which is the only property this contract needs. A server that accepts a + // narrower charset than the rules below still satisfies the invariant. + // Background, for a reader tracing where the value comes from: a propagated + // id is caller-influenceable, which is what `minted` below exists to record. + // Which component performs the check is deliberately not stated here. It is + // server behaviour, this file cannot gate it, and an earlier revision of this + // comment described a particular SDK's middleware and was made wrong by a + // change to that SDK three commits later. + string request_id = 1 [(buf.validate.field).string = { + min_len: 1 + max_len: 255 + pattern: "^[!-~]+$" + }]; + + // Provenance: true = the id is SERVER-DERIVED, false = propagated verbatim + // from a caller-supplied header. True covers both ways a server derives + // one — the header was absent, or it was present but nonconforming and was + // replaced — because the property this flag exists for is INFLUENCE, not + // origin story: false means a caller chose these characters, true means no + // caller did. The two are byte-indistinguishable in request_id alone, so a + // forensic read needs this flag to tell a server-derived correlation key + // from an attacker-influenceable one. + bool minted = 2; +} + +// TransactionState — the thin transaction-log facts a ledger renderer needs +// next to the evidence row. The log row is the operational record (updated +// when a usage report lands); the evidence row is the append-once proof. +// There is deliberately no status field: a denied execute aborts before any +// row is written, so evidence only ever describes a successful execute — +// existence is the status, and a renderer derives its status cell from it. +message TransactionState { + // The transaction's per-item idempotency key as logged. The Exchange + // derives it as TransactionEvidence.request_idempotency_key + ":" + + // offer_id — unconditionally, single-item requests included — so distinct + // items of a batch dedupe independently, and this value is NEVER byte-equal + // to the request-level key. A ledger joining this row against a log export + // matches on this derived form, not on the bare request key, and it reaches + // the TRANSACTION-side events only: a usage-report event stores the report's + // own idempotency key, because a report addresses a whole transaction and + // has no offer id to derive with. Join a usage report on transaction_id + // instead. No upper + // bound: the derivation appends an id whose length nothing constrains. + string idempotency_key = 1 [(buf.validate.field).string.min_len = 1]; + + // When the signed retrieval URL expires. Named to pair with signed_url_hash + // below, so the two fields describing one minted URL read as a pair and + // neither can be mistaken for a property of the transaction itself. Do not + // read the name as a column name: stores spell this one differently + // (TransactionResultItem.expires_at on the wire, and the reference + // Exchange's transaction log calls the column plainly `expiry`), so a + // ledger joining to a log matches this field by MEANING, not by name. + // signed_url_hash is the one that happens to match a real column name. + // + // Absent when the transaction minted no signed URL: DELIVERY_METHOD_DIRECT + // returns the resource inline or from the Exchange's own endpoint, so there + // is nothing to expire. DELIVERY_METHOD_INSTRUCTIONS and + // DELIVERY_METHOD_STREAMING both mint one and always carry this field. + // Absence is a stated fact about the delivery method, not missing data: a + // direct delivery has no value to state here, so there is nothing an empty + // value could honestly mean. + google.protobuf.Timestamp signed_url_expiry = 2; + + // sha256 of the signed retrieval URL — the join key against the transaction + // log's signed_url_hash column, which holds the same digest as 32 raw bytes. + // The join is byte-to-byte; nothing needs normalizing. Text only appears + // when a store is rendered — protojson base64s this field, and a log export + // picks its own spelling — so it is exports, not stores, that a join has to + // reconcile. Hash-only by design: the full URL is a live + // bearer capability until expiry and is deliberately absent from this + // plane (see TransactionEvidence's delivery section). Absent exactly when + // signed_url_expiry is, and for the same reason: no signed URL, nothing to + // hash. The + // `optional` keyword is load-bearing — it gives this scalar explicit + // presence, so protovalidate skips the length rule on an unset value, while + // a PRESENT hash must still be exactly 32 bytes. + optional bytes signed_url_hash = 3 [(buf.validate.field).bytes.len = 32]; + +} + +// ReportingObligationState — the server-side lifecycle record of the +// transaction's reporting obligation, as persisted. Named apart from +// ramp.v1.ReportingObligation, which is the agent-facing requirements +// contract; this is the state those requirements minted. +// +// EVERY field here is backed by a column on the obligation row, with no +// exceptions and no translation step. The timestamp fields carry the store's +// own column names (WindowEnd, FulfilledAt, CreatedAt) in snake_case, so a +// reader can join this record against the storage model by name, and +// consumed_quantity is a column too — written in the same statement as the +// state transition when a report validates. +// +// That completeness is the property worth having, and it is what makes this +// message a projection rather than an assembly. A single field sourced +// elsewhere would mean a reader could not tell, from the message alone, which +// values a server had to go looking for. +message ReportingObligationState { + // Lifecycle state. Always a real persisted state, never UNSPECIFIED. + // Server-output enum: {defined_only, not_in: [0]} — a reader must never + // see a number its schema cannot name. Same rule as + // ramp.v1.TransactionDenial.reason (drift-gated) — the discipline the + // ErrorDetail reason discriminators establish for server-output enums. + ObligationState state = 1 [(buf.validate.field).enum = {defined_only: true, not_in: [0]}]; + + // Reported consumed quantity, in the metering unit from the Offer's + // Pricing — the value the accepted usage report carried. Mirrors + // ramp.v1.Usage.consumed_quantity's wire type (int32, unconstrained) + // exactly: this view must be able to state whatever the report stated, + // and a decimal-string shape here could express values (e.g. "3.5") no + // report can produce. Absent until a usage report has been accepted. + optional int32 consumed_quantity = 2; + + // When the usage report is due (the store's WindowEnd). An absolute + // instant, not the ramp.v1.ReportingObligation.window Duration it was + // derived from: this record states what the store holds, and the store + // resolved the window against created_at when it minted the obligation. + google.protobuf.Timestamp window_end = 3 [(buf.validate.field).required = true]; + + // When a usage report was ACCEPTED (the store's FulfilledAt) — the same + // event that moves state to OBLIGATION_STATE_FULFILLED. Not "when a report + // arrived": a report that arrived and was rejected leaves this absent, and + // the obligation still expires on window_end. + optional google.protobuf.Timestamp fulfilled_at = 4; + + // When the obligation was minted (the store's CreatedAt). + google.protobuf.Timestamp created_at = 5 [(buf.validate.field).required = true]; +} + +message GetTransactionEvidenceRequest { + // RAMP protocol version — "1.0". Stamped by the sender from a single + // constant; advisory on receive. See "Protocol version" in ramp.proto. + string ver = 1; + + // The transaction whose evidence row to fetch. Same rule as + // ramp.admin.v1.TransactionEvidence.transaction_id (drift-gated) — the row + // identity this request selects by. + string transaction_id = 2 [(buf.validate.field).string = {min_len: 1, max_len: 255}]; + + // The tenant the transaction must belong to — the second half of the + // selector, matched against TransactionEvidence.tenant_id. Required: + // counterparty agents legitimately hold transaction ids, so the id alone + // must not be enough to read the row. Naming the tenant narrows what a + // leaked id is worth; it does not authenticate the caller, and nothing on + // this plane does. A mismatch is NOT_FOUND, + // byte-identical to an unknown transaction_id, so existence under another + // tenant is not revealed. Same rule as + // ramp.admin.v1.TenantFeeRate.tenant_id (drift-gated). + string tenant_id = 3 [(buf.validate.field).string = {min_len: 1, max_len: 255}]; +} + +message GetTransactionEvidenceResponse { + // RAMP protocol version — "1.0". Stamped by the sender from a single + // constant; advisory on receive. See "Protocol version" in ramp.proto. + string ver = 1; + + // The append-once evidence row. Required: it exists 1:1 for every found + // transaction — an unknown transaction_id is NOT_FOUND, never an empty + // response. + TransactionEvidence evidence = 2 [(buf.validate.field).required = true]; + + // The transaction-log facts next to it. Required for the same 1:1 reason. + TransactionState transaction_state = 3 [(buf.validate.field).required = true]; + + // The transaction's reporting obligation record, as persisted. The store + // keeps ONE obligation per transaction (keyed on the transaction id, + // transitioning in place — see the storage model), so this is the record + // the Exchange's own reporting path acts on, not a "latest of several". + // Absent when the transaction minted none. + ReportingObligationState obligation_state = 4; +} diff --git a/proto/ramp/v1/ramp.proto b/proto/ramp/v1/ramp.proto index 27510653..48deadbe 100644 --- a/proto/ramp/v1/ramp.proto +++ b/proto/ramp/v1/ramp.proto @@ -33,9 +33,11 @@ import "ramp/v1/vocab.proto"; // spend caps, expiry, delegation chain). The HOW (authorization flow, // token format, signature envelope) is pluggable. // -// Signatures: JWS (RFC 7515) with alg=EdDSA for content signatures -// (offers, attestations). RFC 9421 HTTP Message Signatures for request -// authentication. Same Ed25519 crypto, standard tooling everywhere. +// Signatures: detached Ed25519 (RFC 8032) signatures in hex over an RFC 8785 +// JCS canonical form for content signatures (offers, attestations); the +// algorithm is named with the JOSE identifier 'EdDSA', but the signature is not +// a JWS. RFC 9421 HTTP Message Signatures for request authentication. Same +// Ed25519 crypto everywhere. // // Authorization flows: GNAP (RFC 9635) recommended, OAuth 2.0 + DPoP // as enterprise adoption path. Exchange advertises supported methods. @@ -56,22 +58,30 @@ import "ramp/v1/vocab.proto"; // Request authentication via RFC 9421 HTTP Message Signatures: // Agent → Exchange: Signature header (alg=ed25519) // Agent → Broker → Exchange: multiple Signature headers (one per hop) -// Exchange → Agent: JWS Compact Serialization on Offer (alg=EdDSA) +// Exchange → Agent: detached hex Ed25519 signature on Offer (alg=EdDSA) // -// Signed URLs use HMAC-SHA256 (Exchange↔CDN shared secret, separate concern). +// Signed URLs are asymmetric: Ed25519 over a canonical message, or a CloudFront +// RSA canned policy. The Exchange holds the private key; the verifier holds only +// a public key or a CDN-managed key group. Neither delivery-URL scheme uses a +// shared secret. (CDN key REGISTRATION is a separate plane — see cdn_type on +// DomainVerificationConfirmation, which still admits an HMAC key format.) // // Retrieval-URL identity binding (OPTIONAL, DPoP-style — RFC 9449): // The Exchange MAY bind a signed retrieval_endpoint to the requesting agent // by embedding agent_identity_hash — the RFC 7638 JWK Thumbprint (SHA-256) -// of the agent's Ed25519 request-signing key — inside the HMAC-signed URL, -// and echoing it in the response. A capable delivery endpoint (edge function) -// verifies the binding fully offline: confirm the URL HMAC (proves the hash -// is Exchange-issued and untampered), then require the fetcher to present its +// of the agent's Ed25519 key, as "Agent identity" on AgentAcceptance defines +// it — inside the signed URL, and echoing it in the response. A capable +// delivery endpoint (edge function) +// verifies the binding fully offline: confirm the URL signature (proves the +// hash is Exchange-issued and untampered), then require the fetcher to present its // public key and an RFC 9421 signature over the retrieval request, and check // thumbprint(presented key) == agent_identity_hash. No JWKS fetch required. -// Enforcement is NOT mandatory: a bearer-only signed-URL CDN that cannot run -// code falls back to HMAC + short TTL + TLS. RAMP reference implementations -// run on edge functions and DO enforce it. +// The key that fetches must therefore be the key the identity was derived +// from — see the one-key rule on AgentAcceptance. +// Enforcement is NOT mandatory: a bearer-only CDN that verifies the URL itself +// before any function code runs (the CloudFront RSA scheme) falls back to short +// TTL + TLS. RAMP reference implementations run on edge functions and DO +// enforce it. // ============================================================================ // ============================================================================ @@ -587,9 +597,20 @@ message Offer { } ]; - // REQUIRED. JWS (alg=EdDSA) over the canonical serialization of the ENTIRE - // Offer — every field, including `pricing`, `terms` (the full licensing - // payload), `expires_at`, and `exchange`. Only `signature` and + // REQUIRED. A DETACHED Ed25519 signature over the canonical serialization of + // the ENTIRE Offer, carried as the raw 64 signature bytes in lowercase or + // uppercase hex — 128 hex characters, NOT a JWS. There is no JOSE header and + // no compact serialization here: the signed bytes are defined by the + // canonical-signing recipe below, and this field carries only the signature + // itself. `signature_algorithm` names the algorithm separately. + // Same convention as `AgentAcceptance.signature`, and it is what the admin + // plane's offline verification recipe replays. The hex shape is STATED here + // and ENFORCED there: this field carries no schema rule, while + // ramp.admin.v1.TransactionEvidence.offer_sig — the same value, copied into + // the evidence row — is pattern-bound to 128 hex characters. + // + // The signature covers every field, including `pricing`, `terms` (the full + // licensing payload), `expires_at`, and `exchange`. Only `signature` and // `signature_algorithm` are excluded from the signed bytes. `expires_at` is // signed so the offer's validity window is integrity-protected: a relaying // Broker cannot extend (or shorten) the TTL of a signed offer to replay it @@ -653,9 +674,20 @@ message Offer { // licensing term without invalidating it. // Agent SHOULD verify the signature (RFC 2119) against the Exchange's public // key, and MUST reject an offer whose `expires_at` is in the past. - string signature = 9; - - // JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization. + // + // The rule is the hex shape this comment already describes: 128 characters, + // either case, which is one Ed25519 signature (64 bytes) hex-encoded. Either + // case is accepted because hex decoding accepts both and a dispute should + // read the same characters a request log holds; every SDK in this repo emits + // lowercase. The pattern also makes the field mandatory in practice — the + // empty string does not match it — which restates what this message already + // requires: an unsigned Offer is not an Offer, since the signature is what + // makes its terms, pricing and expiry non-repudiable. + string signature = 9 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]{128}$"]; + + // Signature algorithm. Always 'EdDSA' — the JOSE algorithm identifier for + // Ed25519 (RFC 8032). Only the identifier is borrowed from JOSE: `signature` + // is a detached hex signature, not a JWS. string signature_algorithm = 10; // If set, this offer is available under an existing subscription/deal. @@ -807,7 +839,6 @@ message ResourceIdentity { // Signals whether this resource's content is stable, changes over time, // or does not exist at offer time (live streaming). - // // Drives hash verification behavior: // STATIC: content_hash is stable. Agent SHOULD verify delivered content matches. // DYNAMIC: content changes between offer and fetch (credit reports, drug databases). @@ -815,7 +846,6 @@ message ResourceIdentity { // expected and MUST NOT trigger automatic dispute. // LIVE: content does not exist at offer time (streaming feeds, live broadcasts). // content_hash is not applicable. The "resource" is the stream endpoint. - // // Validated across 18 use cases: static content (articles, patents, legislation), // dynamic data (credit reports, drug interactions, stock snapshots), and live // streams (MarketData quotes, NPR broadcast, news monitoring feeds). @@ -841,7 +871,6 @@ message ResourceIdentity { // Populated by the Exchange or a verification vendor after validating // the C2PA manifest. Enables agents to filter for provenance-verified // content without parsing JUMBF/COSE themselves. - // // The full C2PA validation details (signer identity, trust list, // action history, training/mining status) are carried in a // ResourceAttestation with c2pa.* claims — see ramp-c2pa-v1 profile. @@ -947,7 +976,24 @@ message ResourceAttestation { // ECMAScript number serialization, strict string escaping, no whitespace. // Each attestation is self-contained — new claim fields do not invalidate // old attestations because the signature covers the specific claims instance. - string signature = 6; + // + // HEX, like every other detached signature in this contract: 128 characters, + // either case, one Ed25519 signature (64 bytes) hex-encoded. Same rule as + // ramp.v1.Offer.signature — the same shape, for the same reason. + // + // The encoding was previously unstated, which was the real defect — the + // comment described the signed BYTES precisely and never said how the + // signature itself is written, so a vendor had to guess. Two vendors guessing + // differently is exactly the failure the hex settlement exists to prevent, and + // an attestation is the worst place for it: the verifying party is a third + // party who never negotiated with the reader. + // + // The rule also makes the field mandatory in practice, since the empty string + // does not match. That restates what this message already means. An + // attestation is a signed third-party claim; without the signature it is an + // unverifiable assertion by an unproven author, which is Level 0 — no + // attestation present — rather than an attestation with a field missing. + string signature = 6 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]{128}$"]; } // ============================================================================ @@ -1829,7 +1875,49 @@ enum ResourceMutability { // many brokers relay the request, and binds the agent to THIS specific offer + // requester + transaction. It travels in the execute body alongside the // reflected Offer; the Exchange verifies it and binds the delivery URL to the -// agent's key (RFC 7638 thumbprint of the acceptance key). +// agent's key. +// +// AGENT IDENTITY (normative; every other site cites this one). The agent +// identity for a transaction is the key that proved AGENT authorship of the +// request: +// +// - when an acceptance is present, the ACCEPTANCE key — the key whose +// signature over AgentAcceptancePayload the Exchange verified; +// - otherwise the verified RFC 9421 request signer, which is the agent only +// because an acceptance-less request cannot have been relayed. +// +// `agent_identity_hash` (TransactionResponse, and the value embedded in a bound +// retrieval URL) is the RFC 7638 JWK Thumbprint (SHA-256) of that key. +// +// The transport signer alone is not a usable identity here. A Broker may author +// a re-packaged transaction AS SENDER (see `exchange` in the file header), and +// on that leg the RFC 9421 signer is the broker while the in-body acceptance is +// the only agent-authored signature in the request. Anchoring on the acceptance +// makes the identity the same value whether the request arrived direct or +// through a broker. Where both exist and agree — the ordinary direct hop — the +// two readings coincide, which is why older text called this the +// "request-signing key". +// +// One acceptance key per request: `TransactionResponse.agent_identity_hash` is +// a single per-request value, so a batch whose items were accepted by DIFFERENT +// keys has no one identity to bind its delivery URLs to. Every acceptance in +// one TransactionRequest MUST be signed by the same key. +// +// ONE-KEY RULE. An agent MUST accept an offer and fetch the delivered resource +// with the SAME key. The Exchange derives `agent_identity_hash` from the +// acceptance key, and an enforcing delivery endpoint requires the fetcher to +// present exactly that key, so accepting with one key and fetching with another +// yields a transaction that succeeds and a retrieval that is refused. Two +// qualifiers bound the rule: +// +// - It binds only where proof-of-possession is enforced. A bearer-only +// signed-URL CDN that cannot run code keeps the bearer posture — see +// "Retrieval-URL identity binding" in the file header, which states that +// enforcement is not mandatory. The rule is what an agent must do to be +// servable by an enforcing endpoint, not a universal precondition for +// retrieval. +// - A custodial registry that holds the agent's single key and performs the +// bound fetch itself satisfies the rule with nothing extra to do. // // `signature` is a hex-encoded detached Ed25519 signature (NOT a JWS) over the // CANONICAL SIGNING form of `AgentAcceptancePayload` — RFC 8785 JCS over canonical @@ -1841,8 +1929,17 @@ enum ResourceMutability { // `Offer.signature`; `signature_algorithm` is "EdDSA". message AgentAcceptance { // Hex-encoded detached Ed25519 signature over the canonical AgentAcceptancePayload - // bytes (see the canonical-signing definition on Offer.signature). - string signature = 1 [(buf.validate.field).string.min_len = 1]; + // bytes (see the canonical-signing definition on Offer.signature). Same rule + // as ramp.v1.Offer.signature — the same 128-character hex shape, either + // case, because it is the same kind of value produced by the same + // convention. + // + // The pattern replaced a bare min_len: 1, which it subsumes: a 128-character + // string cannot be empty. Nothing conformant is refused that was accepted + // before — a signature outside this shape could never hex-decode into 64 + // bytes and so could never verify, so it failed at the verify step instead, + // later and with a worse error. + string signature = 1 [(buf.validate.field).string.pattern = "^[0-9A-Fa-f]{128}$"]; // Signature algorithm; "EdDSA" for Ed25519. string signature_algorithm = 2; @@ -1900,9 +1997,18 @@ message TransactionRequest { // Idempotency key (REQUIRED). The server MUST dedupe on this: a replay returns // the original result rather than re-executing. The transaction's durable // identity is the Exchange-assigned transaction_id in the response. - // Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per - // (authenticated caller, key), never globally, so a key chosen by one caller - // cannot collide with another's cached result. + // + // DEDUPE SCOPE — the invariant, stated here once and cited by every other RPC + // that carries an idempotency_key: a key chosen by one caller MUST NEVER + // collide with another caller's cached result. The server dedupes within a + // namespace, never globally. What that namespace IS differs per RPC, because + // the RPCs do not authenticate the same way; each states its own, and each + // namespace has to make the invariant true on its own terms. + // + // For this RPC the namespace is the ACCEPTANCE IDENTITY — the agent key + // defined under "Agent identity" on AgentAcceptance — never the transport + // sender, which may be a Broker relaying many agents behind one key. The + // server dedupes per (acceptance identity, key). string idempotency_key = 2 [ (buf.validate.field).string = {min_len: 1, max_len: 255} ]; @@ -1957,8 +2063,12 @@ message TransactionResponse { string ver = 1; // Identity that a delivered retrieval_endpoint is bound to: the RFC 7638 JWK - // Thumbprint of the agent's Ed25519 request-signing key (see "Retrieval-URL - // identity binding" above). Shared across the request; set once. + // Thumbprint of the agent's Ed25519 key, as "Agent identity" on + // AgentAcceptance defines it — the acceptance key, not the transport signer, + // which may be a Broker. See "Retrieval-URL identity binding" in the file + // header for how a delivery endpoint checks the binding. Shared across the + // request; set once, which is why every acceptance in one request must be + // signed by the same key. string agent_identity_hash = 10; // Per-offer results (one entry per committed item, in original order). @@ -1987,7 +2097,35 @@ message TransactionResultItem { // The offer_id this result is for. string offer_id = 1; - // Exchange-assigned transaction identifier. + // Exchange-assigned transaction identifier. Opaque to agents; the format + // is implementation-defined (the documented storage model mints a + // time-ordered ULID as the record's primary key). + // + // ENTROPY. RAMP places no entropy requirement on this value. An implementer + // choosing a sequential id should know precisely what that does and does not + // cost, because the protection here is narrower than "unguessable ids are + // unnecessary". + // + // WHAT IS GUARANTEED. The admin plane's evidence read + // (ramp.admin.v1.GetTransactionEvidence) selects by the + // (tenant_id, transaction_id) PAIR, so a transaction id ALONE is never a + // bearer capability for the forensic row. Counterparty agents legitimately + // hold the ids of their own transactions, and that pairing is what stops one + // of those ids from reading the row on its own. That is the whole guarantee, + // and a conformance guard fails if the selector stops being a pair. + // + // WHAT IS NOT GUARANTEED: resistance to ENUMERATION. The tenant half of the + // pair is not a secret. Deployments use human brand slugs, and the slug is + // handed to every agent that holds an offer from that tenant — it prefixes + // the offer id inside the signed offer. So a caller who can reach the admin + // plane at all, and who has done business with a tenant, already knows one + // valid tenant value and can walk sequential transaction ids against it. + // What bounds that is the network-layer reachability restriction on the + // admin plane (see the ramp.admin.v1 file header): that plane must not be + // exposed on the public agent-facing listener, and it is the outer control + // an operator must not relax. An Exchange that wants enumeration resistance + // in depth should mint unguessable ids; RAMP does not require it, and no + // agent-plane behavior depends on this format either way. string transaction_id = 2; // Billing record identifier minted by the Exchange's billing adapter for @@ -2121,7 +2259,7 @@ service BrokerService { // licensable (not in catalog, no offers, entitlement/budget absence, upstream // temporarily unavailable) returns OK with DiscoveryResponse.absence_reason // set and empty offer_groups — "no result" is a successful answer, mirroring - // DiscoverResources (ADR-019 §2). Here "authz" means resource entitlement + // DiscoverResources. Here "authz" means resource entitlement // (→ OK + absence); transport authentication failures are a different axis and, // like malformed requests and internal faults, are non-OK transport errors // carrying an ErrorDetail. @@ -2347,15 +2485,48 @@ message UsageReport { // Idempotency key (REQUIRED). The server MUST dedupe on this so a replayed // report does not double-count usage. The report's durable identity is the // Exchange-assigned report_id in UsageReportResponse. - // Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per - // (authenticated caller, key), never globally, so a key chosen by one caller - // cannot collide with another's cached result. + // + // DEDUPE SCOPE. The invariant is the one stated on + // TransactionRequest.idempotency_key. This message carries no acceptance + // payload, so there is no in-body agent signature to anchor on; the namespace + // is instead the TRANSACTION the report addresses, and the server dedupes per + // (transaction_id, key). That satisfies the invariant without depending on + // the transport signer: the transaction was bound to exactly one agent by its + // acceptance at execute time, so two agents relayed by the same Broker report + // against different transactions and never share a namespace. + // Leaving the signer out is also what makes the relay work. "Filed by the + // agent or Broker" above means the Broker FORWARDS the agent's report, not + // that it authors one of its own: the body is unchanged and carries this same + // key, so a direct submission and a relayed copy are one report on two paths + // and MUST collapse. Adding the verified signer to the namespace would split + // them and count the usage twice. + // + // WHO MAY FILE. Dropping the signer from the namespace removes a protection + // that has to be restored explicitly, so the rule is stated rather than + // implied: only the agent the transaction was bound to at execute time may + // file against it, or a Broker relaying that agent's report unchanged. The + // argument above is about honest filers — it shows two legitimate parties + // never collide by accident, which is a different claim from who is allowed + // to write. A filing from any other party MUST be rejected, never deduped: + // the slot is now shared, so an accepted filing from an unbound party would + // occupy the one the bound agent's report needs, and the real usage would + // collapse into it and go uncounted. + // + // An unauthorized filing is reported as USAGE_REPORT_REJECTION_REASON_ + // TRANSACTION_NOT_FOUND, deliberately. There is no distinct "not authorized" + // reason and there should not be one: it would confirm to a party not bound + // to the transaction that the transaction exists, which turns the rejection + // into an oracle for probing transaction ids. string idempotency_key = 2 [ (buf.validate.field).string = {min_len: 1, max_len: 255} ]; - // Transaction ID from the delivery. - string transaction_id = 3; + // Transaction ID from the delivery. MUST be non-empty. It is also the dedupe + // namespace for `idempotency_key` above, so a report that names no + // transaction has no namespace to dedupe within — the rule below is what + // makes that namespace exist, not a shape preference. No upper bound: the + // Exchange assigns this id and nothing upstream constrains its length. + string transaction_id = 3 [(buf.validate.field).string.min_len = 1]; // Billing record identifier from the delivery (TransactionResultItem.billing_id). string billing_id = 4; @@ -3025,7 +3196,6 @@ message DiscoveryResponse { // per-axis detail: DiscoveryResponse has no restriction_filters companion // (unlike OfferGroup). A consumer needing the filtered axes calls // DiscoverResources. - // // Existence-oracle note: an authorization-flavored reason (SCOPE_INSUFFICIENT, // NOT_AUTHORIZED, NOT_IN_CATALOG, CONTENT_BLOCKED) confirms a resource exists // and why access was refused. Resolve surfaces the same oracle at the broker @@ -3067,15 +3237,24 @@ message DisputeRequest { // Idempotency key (REQUIRED). The server MUST dedupe on this so a replayed // filing does not open a duplicate case. The dispute's durable identity is the // Exchange-assigned dispute_id in DisputeResponse. - // Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per - // (authenticated caller, key), never globally, so a key chosen by one caller - // cannot collide with another's cached result. + // + // DEDUPE SCOPE. The invariant is the one stated on + // TransactionRequest.idempotency_key, and the namespace is the same as + // UsageReport's and for the same reason: this message carries no acceptance + // payload, so the namespace is the TRANSACTION being disputed and the server + // dedupes per (transaction_id, key). The "who may file" rule stated there + // applies here unchanged, and for the same reason — a shared slot needs an + // explicit rule about who may write into it. string idempotency_key = 2 [ (buf.validate.field).string = {min_len: 1, max_len: 255} ]; - // Transaction being disputed. - string transaction_id = 3; + // Transaction being disputed. MUST be non-empty. It is also the dedupe + // namespace for `idempotency_key` above, so a filing that names no + // transaction has no namespace to dedupe within — the rule below is what + // makes that namespace exist, not a shape preference. Same rule as + // ramp.v1.UsageReport.transaction_id, for the same reason. + string transaction_id = 3 [(buf.validate.field).string.min_len = 1]; // Billing record identifier from the disputed transaction // (TransactionResultItem.billing_id). @@ -3811,10 +3990,13 @@ message RetrievalAuthFailure { } // UsageReportRejectionReason — why a ReportUsage filing was rejected. Replaces -// the free-text UsageReportResponse.rejection_reason string. +// the free-text UsageReportResponse.rejection_reason string. There is no +// "not authorized" value on purpose; see the who-may-file rule on +// UsageReport.idempotency_key for why an unauthorized filing reports as +// TRANSACTION_NOT_FOUND instead. enum UsageReportRejectionReason { USAGE_REPORT_REJECTION_REASON_UNSPECIFIED = 0; // unset — rejected at ingest - USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND = 1; // transaction_id is unknown + USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND = 1; // transaction_id is unknown, or the filer is not bound to it USAGE_REPORT_REJECTION_REASON_DUPLICATE = 2; // a report was already filed for this transaction USAGE_REPORT_REJECTION_REASON_WINDOW_EXPIRED = 3; // filed outside the reporting window USAGE_REPORT_REJECTION_REASON_MISSING_REQUIRED_FIELDS = 4; // ReportingObligation.required_fields not satisfied diff --git a/scripts/check-doc-conformance.sh b/scripts/check-doc-conformance.sh index e5549dab..dca3bfd5 100755 --- a/scripts/check-doc-conformance.sh +++ b/scripts/check-doc-conformance.sh @@ -44,6 +44,13 @@ patterns=( # the scalar offer_signature pair is gone: the execute-request now reflects the # FULL signed Offer back (offer.signature carries the JWS). offer_id stays live. 'offer_signature' 'offer_signature_algorithm' + # the Offer's Exchange signature is `signature` (with `signature_algorithm` + # beside it). `exchange_signature` never existed in any .proto and had spread + # to two dozen doc sites, so an integrator who searched the schema for it + # found nothing. Denylisted rather than gated generically: see the note at the + # bottom of this file on why prose field names cannot be checked against the + # descriptor. + 'exchange_signature' # token / vocabulary — both the hyphenated token form and the prose spelling; # the canonical optional delegation format is biscuit-v3 (NEVER v2), and # entitlement denials are format-neutral ("entitlement token", not "biscuit"). @@ -227,3 +234,30 @@ if [ "$status" -eq 0 ]; then echo "doc-conformance: clean" fi exit "$status" + +# --- Why there is no generic "prose field name resolves in the descriptor" gate +# +# The tempting generalization of the denylist above: take every backtick-quoted +# snake_case token in the docs and fail if it names no field, message or enum +# value in the contract. That would catch a stale field name the first time it +# is written, instead of after it has spread. +# +# It was measured and rejected. Across website/src/content/docs, 189 distinct +# backticked snake_case tokens (295 occurrences) resolve to nothing in any +# .proto — and essentially all of them are correct prose about something that is +# deliberately NOT a wire field: +# +# - storage-layer column and event-field names (event_id, query_id, buyer_lid) +# - signed-URL HMAC input parameters (agent_id, txn_id) +# - RPC names written in snake_case (execute_transaction, report_usage) +# - external vocabularies the docs quote (oa_status, cited_by_count, +# is_retracted — OpenAlex/Crossref), CoMP fields, registered JWT claims +# - deployment config keys (trusted_key_groups) +# +# Making that gate usable would mean a hand-maintained allow-list of ~189 +# entries spanning five namespaces, growing with every doc that mentions a +# neighbouring system. That is an opt-in list of exceptions, which is the shape +# this repo's guards deliberately avoid: scope should be derived, and a guard +# that needs a human to keep adding exemptions fails open the moment someone +# forgets. The denylist above stays the right tool — it records actual rename +# events, and each entry is added by the change that caused it. diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 58ccbdf6..6aeea178 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -7,7 +7,8 @@ # # Coverage: the proto gate (lint/generate/drift/build/test/docs) AND the SDK types # export gate (regenerate gen-sdk-types + drift + Pydantic/Zod parity + canonical -# round-trip). The two run as SEPARATE CI workflows (proto-ci.yml + sdk-types-ci.yml, +# round-trip + the hand-written sdk/python and sdk/ts suites, which carry the +# API-surface parity gate). The two run as SEPARATE CI workflows (proto-ci.yml + sdk-types-ci.yml, # path-filtered); locally they are one command. proto-ci.yml sets # RAMP_CI_SKIP_SDK_TYPES=1 so it keeps mirroring proto-ci only (sdk-types-ci.yml owns # the sdk-types gate in CI); the block also self-skips if python3/npm are absent. @@ -72,6 +73,20 @@ else note "no drift" fi +step "regenerate the changelog page" +# The published changelog page is proto/CHANGELOG.md plus Starlight frontmatter. +# It used to be a hand-copied second body, which drifted 436 lines and lost a whole +# entry before anyone noticed. Same gate shape as gen/ and the corpus: regenerate, +# then fail on drift. +python3 scripts/gen-changelog-page.py || fail=1 +if ! git diff --quiet HEAD -- website/src/content/docs/reference/changelog.mdx; then + echo "::error:: the changelog page is out of sync with proto/CHANGELOG.md. Edit proto/CHANGELOG.md (never the page), run 'python3 scripts/gen-changelog-page.py', and commit the page." + git status --short -- website/src/content/docs/reference/changelog.mdx + fail=1 +else + note "no drift" +fi + step "go build / vet / test" go build ./... || fail=1 go vet ./... || fail=1 @@ -103,6 +118,20 @@ else note "skipped — run 'npm install' in website/ to enable" fi +step "docs build (mirrors docs-ci.yml)" +# `npm test` above does NOT cover this. Several guards run only inside `astro +# build` — starlight-links-validator among them — so a dead link in a docs page +# passed this script and failed in CI. The build is the gate CI actually runs; +# run the same one here. About ten seconds. +if [ -d website/node_modules ]; then + (cd website && npm run build --silent >/dev/null) || { + echo "::error:: website build failed — run 'cd website && npm run build' to see the report." + fail=1 + } +else + note "skipped — run 'npm install' in website/ to enable" +fi + # --- SDK types export gate (mirrors .github/workflows/sdk-types-ci.yml) --- # The generated Pydantic/Zod types export + its cross-language parity and canonical # round-trip. CI runs this as a SEPARATE, path-filtered workflow; locally it belongs in @@ -136,6 +165,23 @@ else PYTHONPATH=gen/python ".sdk-types-work/venv/bin/python" -m pytest gen/python/tests -q || fail=1 (cd gen/ts && npm ci --no-audit --no-fund && npm test --silent) || fail=1 + # The hand-written SDKs, not the generated types above — a different suite in a + # different directory. sdk-types-ci.yml runs both and this script did not, so a + # change that broke them still reported a green local run. It happened: two new + # wire-constant vectors turned sdk/python/tests and sdk/ts red while ci-local + # passed. The same shape covers the API-surface gate, which fires when a new + # exported Go symbol is neither mapped nor excluded in sdk/parity/symbol-map.json. + step "sdk/python L1 + L2 parity" + # Version floors, not hashes, because sdk-types-ci.yml installs exactly this + # line. Pinning tighter here would gate on something CI does not check. + ".sdk-types-work/venv/bin/pip" install -q --disable-pip-version-check \ + "cryptography>=44" "httpx>=0.27" "httpcore>=1.0" "pydantic>=2.9" "rfc8785>=0.1.2" pytest || fail=1 + PYTHONPATH=gen/python:sdk/python ".sdk-types-work/venv/bin/python" -m pytest sdk/python/tests -q || fail=1 + + step "sdk/ts L1 + L2 parity" + # npm install, not npm ci: sdk/ts ships no lockfile, and this mirrors CI. + (cd sdk/ts && npm install --no-audit --no-fund --silent && npm test --silent) || fail=1 + step "canonical proto-JSON round-trip" ./scripts/check-canonical.sh || fail=1 fi diff --git a/scripts/gen-changelog-page.py b/scripts/gen-changelog-page.py new file mode 100755 index 00000000..ff802998 --- /dev/null +++ b/scripts/gen-changelog-page.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Render the published changelog page from proto/CHANGELOG.md. + +The changelog used to exist twice: proto/CHANGELOG.md beside the schema, and a +hand-copied body inside website/src/content/docs/reference/changelog.mdx. Nothing +connected them, so every new entry had to be written twice and only the author's +memory kept them equal. They did not stay equal — by the time this script was +written the two bodies differed by 436 lines, and one whole entry existed only in +the file, never reaching the page an integrator reads. + +So the page is generated. The file beside the schema is the source: it is the one +a schema change is written next to, and the one a reviewer reads in a proto diff. +The page is that file with Starlight frontmatter in place of the H1 title. + +WHY THIS IS SAFE AS PLAIN COPY. MDX is stricter than Markdown — a bare `<` or `{` +outside a code span is parsed as JSX and fails the build. assert_mdx_safe below +refuses to write a page that would break that way, so the failure surfaces here, +naming the line, rather than as a Vite stack trace during the site build. The +page body uses no MDX-only syntax of its own (no imports, no directives, no +components), which is what makes the copy total rather than a merge. + +LINKS ARE THE OTHER THING A PLAIN COPY GETS WRONG. A link in the source file is +written for a reader browsing the repository, so it is relative to proto/ — +`../docs/design-history.md` reaches the file from there. Copied verbatim onto the +page it reaches nothing, and starlight-links-validator fails the site build with +a message that names the generated page rather than the source line that caused +it. So repo-relative links are rewritten to absolute URLs against the published +repository, and the rewrite is fail-closed twice over: the target must exist on +disk, and any relative link left after the rewrite stops the run. Both failures +name the line in proto/CHANGELOG.md, which is the file an author can act on. + +Run by scripts/ci-local.sh, which then gates on `git diff` exactly like gen/ and +the validation corpus: regenerate, and fail if the committed page moved. +""" + +import pathlib +import posixpath +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SOURCE = ROOT / "proto" / "CHANGELOG.md" +PAGE = ROOT / "website" / "src" / "content" / "docs" / "reference" / "changelog.mdx" + +# Links in the source are relative to its own directory, proto/. +SOURCE_DIR = "proto" + +# Where a repo-relative link is republished. The page is served from the docs +# site, which does not carry the repository tree, so the only address that works +# for both audiences is the file on the published repository. +REPO_BLOB = "https://github.com/RAMP-Protocol/protocol/blob/main/" + +FRONTMATTER = ( + "---\n" + 'title: "Changelog"\n' + 'description: "RAMP protocol changelog"\n' + "---\n" + "\n" + "{/* GENERATED FILE — do not edit. Source: proto/CHANGELOG.md.\n" + " Edit that, then run scripts/gen-changelog-page.py. ci-local.sh gates the drift. */}\n" +) + +# A `<` or `{` that is not inside a code span. Code spans are stripped first, so +# what remains is prose, where MDX would try to parse either character as JSX. +CODE_SPAN = re.compile(r"`[^`]*`") +MDX_HOSTILE = re.compile(r"[<{]") + + +def assert_mdx_safe(body: str, line_offset: int = 0) -> None: + bad = [] + for n, line in enumerate(body.split("\n"), start=1 + line_offset): + if MDX_HOSTILE.search(CODE_SPAN.sub("", line)): + bad.append((n, line.strip())) + if bad: + for n, line in bad[:10]: + print(f"{SOURCE}:{n}: bare '<' or '{{' outside a code span: {line}", file=sys.stderr) + print( + f"\n{len(bad)} line(s) would break the MDX build. Wrap the character in a " + "code span, or write it as an entity.", + file=sys.stderr, + ) + raise SystemExit(1) + + +# A markdown inline link target. Anchors (#x), site-absolute paths (/x), and +# anything with a scheme (https:, mailto:) are already valid on the page; every +# other target is relative to proto/ and has to be rewritten. +MD_LINK = re.compile(r"\]\(([^)\s]+)\)") +ALREADY_ABSOLUTE = re.compile(r"\A(?:[a-zA-Z][a-zA-Z0-9+.-]*:|/|#)") + + +def absolutize_links(body: str, line_offset: int = 0) -> str: + """Rewrite repo-relative link targets to absolute URLs on the published repo. + + Fail-closed in both directions: a target that does not resolve to a file in + this repository stops the run, and so does any relative target still present + afterwards. A dead link is easier to fix at the line that wrote it than in a + site build log that only names the generated page. + """ + missing = [] + out_lines = [] + + # Line by line, so both diagnostics can name the source line. A position an + # author cannot find in the file they edit is barely better than none. + for n, line in enumerate(body.split("\n"), start=1 + line_offset): + + def rewrite(m: "re.Match[str]", n: int = n) -> str: + target = m.group(1) + if ALREADY_ABSOLUTE.match(target): + return m.group(0) + path, _, fragment = target.partition("#") + repo_path = posixpath.normpath(posixpath.join(SOURCE_DIR, path)) + if repo_path.startswith("..") or not (ROOT / repo_path).exists(): + missing.append((n, target, repo_path)) + return m.group(0) + return "](" + REPO_BLOB + repo_path + (("#" + fragment) if fragment else "") + ")" + + out_lines.append(MD_LINK.sub(rewrite, line)) + + if missing: + for n, target, repo_path in missing[:10]: + print(f"{SOURCE}:{n}: link target {target!r} resolves to {repo_path!r}, " + "which is not a file in this repository", file=sys.stderr) + print(f"\n{len(missing)} unresolvable relative link(s). Point them at a real path, " + "or write an absolute URL.", file=sys.stderr) + raise SystemExit(1) + + # Belt and braces: nothing relative may reach the page, whatever shape it had. + for n, line in enumerate(out_lines, start=1 + line_offset): + for target in MD_LINK.findall(line): + if not ALREADY_ABSOLUTE.match(target): + print(f"{SOURCE}:{n}: relative link {target!r} would not resolve on the " + "docs site", file=sys.stderr) + raise SystemExit(1) + return "\n".join(out_lines) + + +def main() -> None: + text = SOURCE.read_text() + + # Drop the H1 — the page's title comes from frontmatter, and two titles would + # render one above the other. Every diagnostic below counts lines in the + # STRIPPED body, so carry the offset and report positions in the source file: + # a line number an author cannot find in the file they edit is worse than no + # line number. + body, n_subs = re.subn(r"\A#[^\n]*\n+", "", text, count=1) + line_offset = (len(text) - len(body)) and text[: len(text) - len(body)].count("\n") + if not n_subs: + line_offset = 0 + + body = absolutize_links(body, line_offset) + assert_mdx_safe(body, line_offset) + + page = FRONTMATTER + "\n" + body + if not page.endswith("\n"): + page += "\n" + + if PAGE.exists() and PAGE.read_text() == page: + print(f"changelog page already current -> {PAGE.relative_to(ROOT)}") + return + PAGE.write_text(page) + print(f"wrote changelog page -> {PAGE.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/gen-sdk-types.sh b/scripts/gen-sdk-types.sh index eb837c85..12122228 100755 --- a/scripts/gen-sdk-types.sh +++ b/scripts/gen-sdk-types.sh @@ -47,8 +47,13 @@ go run ./conformance/requiredgen "$WORK/required_fields.json" # .refine()) and gen_unique_py.py emits wire/unique.py for the wire/base.py seam, because # datamodel-codegen drops `uniqueItems` for pydantic v2. go run ./conformance/uniquegen "$WORK/unique_items.json" +# bytes_len.json: the protovalidate bytes length-rule view (len and min_len). protoschema +# renders an exact-length bytes field as a base64 minLength/maxLength window loose enough +# to admit an off-by-one byte length, and a min_len field as a pattern that a pure-padding +# string ("==") satisfies; merge_schema tightens both to real encoded-byte arithmetic. +go run ./conformance/bytesgen "$WORK/bytes_len.json" "$PY" scripts/sdk-types/merge_schema.py "$JS" gen/descriptor.binpb "$COMBINED" \ - "$WORK/required_fields.json" "$WORK/unique_items.json" + "$WORK/required_fields.json" "$WORK/unique_items.json" "$WORK/bytes_len.json" echo "==> 3/4 Pydantic v2 (datamodel-code-generator, --base-class + --collapse-root-models)" "$WORK/venv/bin/datamodel-codegen" \ diff --git a/scripts/sdk-types/merge_schema.py b/scripts/sdk-types/merge_schema.py index 57708d59..8b00ede9 100644 --- a/scripts/sdk-types/merge_schema.py +++ b/scripts/sdk-types/merge_schema.py @@ -53,14 +53,146 @@ def walk_msgs(msgs): return out -def strip_titles(o): +PROTO_ENUM_VALUE = re.compile(r"^[A-Z][A-Z0-9_]*$") # proto enum value style + + +def is_enum_node(node): + """True when this schema node is the emission of an enum-typed proto field. + + protoc-gen-jsonschema renders one as a name-OR-number union: an anyOf whose + string arm carries the SCREAMING_SNAKE value list. A repeated enum puts the + same shape under `items`. Read the shape rather than the text, so the answer + does not depend on how a value happens to be spelled. + """ + candidates = [node] + list(node.get("anyOf") or []) + for c in candidates: + vals = c.get("enum") if isinstance(c, dict) else None + if isinstance(vals, list) and vals and all( + isinstance(v, str) and PROTO_ENUM_VALUE.match(v) for v in vals): + return True + return False + + +def is_generated_title(title, node, message): + """True when the generator produced this title from a NAME, not from a comment. + + Two shapes, and both are noise in a description: + - a message-level title whose words rejoin to the message name + ("Acceptable Restriction" -> AcceptableRestriction); + - the type label on an enum-typed property ("Obligation State"). + + The enum arm reads the node shape and not the text, and it cannot do better. + For an enum-typed field protoc-gen-jsonschema puts the enum TYPE NAME in the + title slot, and the paragraph that would otherwise have gone there is simply + gone: it is absent from this script's input, so there is nothing here to + classify and nothing to recover. + + THE RULE THAT FOLLOWS: an enum field's leading comment must be EXACTLY ONE + paragraph. A single-paragraph comment lands whole in `description`. Add one + blank `//` line and the text above it reaches Go and neither generated + client, with nothing failing. Merging two paragraphs of a three-paragraph + comment does not help — whatever ends up first is what disappears. + + Three fields were losing text this way and were rewritten as one paragraph + each, rather than worked around here, because the loss happens upstream of + this script. Non-enum fields are unaffected: their first paragraph really + does arrive in `title`, which is what fold_titles puts back. + """ + if message is not None and title.replace(" ", "") == message: + return True + return is_enum_node(node) + + +# Maps whose keys are NAMES CHOSEN BY THE PROTO AUTHOR, not JSON Schema keywords. +# Everywhere else in this document a dict key is a keyword, so `title` means the +# keyword. Inside one of these it means a field called `title` — which ramp.v1 has +# three of (Offer, ResourceEntry, UsageAsset). Treating the two alike deletes the +# field and writes its schema node back under the key `description`, so the wire +# field vanishes from both generated clients and is replaced by one no server +# accepts. Walk the VALUES of these maps as schema nodes; never the map itself. +NAME_KEYED = ("properties", "patternProperties", "$defs", "definitions", "dependentSchemas") + + +def fold_titles(o, message=None, root=True): + """Fold a comment-derived title into its description; drop generated ones. + + protoc-gen-jsonschema SPLITS a leading proto comment: when the comment has a + blank-line-separated first paragraph, that paragraph becomes `title` and only + the remainder becomes `description`. + + This used to drop every title. The first paragraph of 28 field comments and + of every message comment written that way therefore reached Go but never + reached gen/python/wire/models.py or gen/ts/wire/schemas.ts — silently, since + nothing failed and the proto and the Go bindings still read complete. + + Keeping every title is the other wrong answer: datamodel-code-generator names + classes from titles, so a sentence-long title would become a class name. + + So the two kinds are separated by HOW the title was produced, which + is_generated_title reads off the node's shape. Comment text moves to the + front of the description, keeping the paragraph break the author wrote. + + ONE SHAPE THIS CANNOT SEE. For an ENUM-typed field the plugin puts the enum + type name in the title slot and DISCARDS the comment's first paragraph before + this script runs — the text is absent from the input, not misclassified, so + no rule here can recover it. Write an enum field's comment as a single + paragraph, or its opening sentence reaches Go and neither generated client. + """ if isinstance(o, dict): - return {k: strip_titles(v) for k, v in o.items() if k != "title"} + out = {} + for k, v in o.items(): + if k == "title": + continue + if k in NAME_KEYED and isinstance(v, dict): + out[k] = {name: fold_titles(node, message, False) for name, node in v.items()} + else: + out[k] = fold_titles(v, message, False) + title = o.get("title") + if title is not None and not is_generated_title(title, o, message if root else None): + desc = out.get("description") + out["description"] = title if not desc else title + "\n\n" + desc + return out if isinstance(o, list): - return [strip_titles(x) for x in o] + return [fold_titles(x, message, False) for x in o] return o +def assert_no_titles(doc): + """Every title is either folded into a description or dropped as generated. + + A `title` that survives on a SCHEMA NODE means fold_titles met a node shape it + does not classify. Fail here rather than let datamodel-code-generator name a + class from it. + + This walks the document the same way fold_titles does instead of scanning the + serialized text, for two reasons. A text scan cannot tell the keyword from a + field called `title`, so it would report the three legitimate ones. And it + could never fail: fold_titles removes the key from every node it visits, so + nothing survives for a scan to find, and the guard passed on everything. + """ + leftover = [] + + def walk_node(o): + """o is a schema node: its dict keys are JSON Schema keywords.""" + if isinstance(o, dict): + if isinstance(o.get("title"), str): + leftover.append(o["title"]) + for k, v in o.items(): + walk_map(v) if k in NAME_KEYED and isinstance(v, dict) else walk_node(v) + elif isinstance(o, list): + for x in o: + walk_node(x) + + def walk_map(o): + """o is a name -> schema node map: its dict keys are author-chosen names.""" + for node in o.values(): + walk_node(node) + + walk_node(doc) + if leftover: + sys.exit(f"unfolded titles reached the merged schema: {leftover[:5]}") + + def fix_string_null_default(o): """A proto `bytes` field renders as {type: string, pattern: , default: null}. A JSON-Schema string node must never carry a non-string default: its proto3 zero is @@ -280,19 +412,184 @@ def mark_unique(defs, unique_items): return defs -def main(src_dir, desc_path, out_file, required_path=None, unique_path=None): +def base64_encoded_form(n): + """(payload_chars, pad_chars) of the base64 encoding of exactly n bytes. + The SINGLE derivation of the padding length: the exact-length pattern tail + and its maxLength are both built from pad_chars, so they cannot disagree.""" + full, rem = divmod(int(n), 3) + return 4 * full + (0, 2, 3)[rem], (0, 2, 1)[rem] + + +# The two base64 alphabets, as JSON-Schema (ECMA-262 / RE2-compatible) character +# classes. They are kept SEPARATE, never merged into one class: Go's protojson +# picks the alphabet by presence of '-' or '_' and then decodes strictly, so a +# string mixing '+' with '_' is refused. A merged [A-Za-z0-9+/_-] class accepts +# that mix, which is the accept-direction divergence these patterns exist to +# close. ('-' sits last in the url class so it is a literal, not a range.) +BASE64_ALPHABETS = ("A-Za-z0-9+/", "A-Za-z0-9_-") + + +def base64_block_forms(chars, pad): + """Regex branches for base64 of an EXACT payload length: `chars` payload + characters, then the padding that completes the 4-character block (none, one + '=', or two). Padding is OPTIONAL because protojson decodes the unpadded + (raw) form too.""" + tail = {0: "", 1: "=?", 2: "(?:==)?"}[pad] + return ["[%s]{%d}%s" % (a, chars, tail) for a in BASE64_ALPHABETS] + + +def base64_floor_forms(chars): + """Regex branches for base64 of AT LEAST `chars` payload characters. + + A base64 string is p payload characters plus padding, and Go accepts it only + when p % 4 != 1 — 4k characters carry no padding, 4k+2 take an optional '==', + 4k+3 an optional '='. (4k+1 is not a legal encoded length in any form, which + is why "AA=", "AAA==" and "AAAAA" are all refused.) So each accepted residue + gets its own branch, anchored at the smallest payload length that both has + that residue and clears the floor; `(?:[A]{4})*` then walks it up in whole + blocks. This is plain arithmetic in the pattern — no lookahead — because + Pydantic v2 compiles patterns with the Rust regex engine, which has none.""" + out = [] + for a in BASE64_ALPHABETS: + for residue, tail in ((0, ""), (2, "(?:==)?"), (3, "=?")): + floor = chars + (residue - chars) % 4 # smallest p >= chars, p % 4 == residue + out.append("[%s]{%d}(?:[%s]{4})*%s" % (a, floor, a, tail)) + return out + + +def tighten_bytes_len(defs, bytes_len): + """A bytes field with a length rule arrives from protoschema too loose in + both rule kinds, so the generated clients accept values the Go server + rejects: + + - bytes.len = N ({"len": N} in the manifest): rendered as base64 with a + minLength/maxLength CHARACTER window (43..44 for N=32) that also admits + an N+1-byte value — 33 bytes encode to 44 unpadded chars, inside the + window. Rewrite to the EXACT encoded forms of N bytes: the unpadded + encoding, optionally completed to the base64 block with its exact + padding. + - bytes.min_len = N ({"min_len": N} in the manifest): rendered as a + pattern with a free padding tail plus a CHARACTER minLength, so for N=1 + the two-character string "==" (pure padding, zero payload bytes) passes + while Go protojson refuses to decode it. Rewrite to require at least + the encoded payload characters of N bytes BEFORE the padding tail. + + bytes_len (from the Go bytesgen manifest — the authoritative protovalidate + view) names the fields; bytesgen fails closed on any other bytes rule + shape, so every manifest entry is one of the two kinds above. Byte length + becomes enforceable at the schema layer without decoding. + + Both patterns are an ALTERNATION of the two base64 alphabets, standard (+/) + and url-safe (-_), never one merged character class. Go's protojson accepts + either alphabet on decode — so a client rejecting base64url (a JWK "x" value + pasted verbatim) would refuse input the server takes — but it accepts only + ONE of them per value: it selects url-safe when the string contains '-' or + '_', then decodes strictly, so a mixed string is refused. Length arithmetic + is alphabet-independent, so the length guarantees are the same in both arms. + Padding is derived from the payload length mod 4 rather than left as a free + ={0,2} tail, which is what makes "AA=", "AAA==" and "AAAAA" — none of them + legal encoded lengths — rejected here as they are by Go. + + Loud guards: every manifest entry MUST match a string property in the + rendered schema, and MUST carry a rule kind this function translates. A + silent skip would mean a protoschema rendering change (or a manifest format + change) reopened the loose length window with no build failure — the parity + corpus would catch it only later and less legibly.""" + missing = [] + for msg, fields in bytes_len.items(): + d = defs.get(msg) + if not isinstance(d, dict) or "properties" not in d: + missing.extend(f"{msg}.{jname} (message not in schema)" for jname in fields) + continue + for jname, rule in fields.items(): + prop = d["properties"].get(jname) + if not isinstance(prop, dict) or prop.get("type") != "string": + missing.append(f"{msg}.{jname} (no string property in schema)") + continue + if not isinstance(rule, dict) or set(rule) not in ({"len"}, {"min_len"}): + missing.append(f"{msg}.{jname} (untranslatable rule {rule!r})") + continue + n = next(iter(rule.values())) + if not isinstance(n, int) or n < 1: + # bytesgen panics on a zero-valued length rule; this is the same + # check on the consuming side, so a manifest that lost its value + # (or carries a zero) cannot produce a pattern the empty string + # satisfies. + missing.append(f"{msg}.{jname} (rule value must be a positive integer: {rule!r})") + continue + if "len" in rule: + chars, pad = base64_encoded_form(rule["len"]) + prop["pattern"] = "^(?:%s)$" % "|".join(base64_block_forms(chars, pad)) + # unpadded (chars) .. padded to the block (chars + pad) + prop["minLength"] = chars + prop["maxLength"] = chars + pad + else: + chars, _ = base64_encoded_form(rule["min_len"]) + # Open-ended: at least the encoded payload characters of min_len + # bytes, extended a whole 4-character block at a time. No + # exact-length arithmetic applies, so no maxLength. + prop["pattern"] = "^(?:%s)$" % "|".join(base64_floor_forms(chars)) + prop["minLength"] = chars + prop.pop("maxLength", None) + if missing: + sys.exit("bytes_len manifest entries this pipeline cannot apply — the " + f"length tightening would silently not happen: {missing}") + return defs + + +def assert_defaults_valid(combined): + """Loud guard: no string node may keep a default its OWN constraints reject. + Such a pairing means a wire-required field slipped through as optional-with- + invalid-default — the clients then either reject omission inconsistently + (Zod 3 re-validates a ZodDefault, Zod 4 does not) or materialize a value the + server refuses. The fix is never to relax the constraint but to mark the + field required (requiredgen) so the default is dropped; this guard fails the + build until that happens.""" + bad = [] + + def walk(node, path): + if isinstance(node, dict): + d = node.get("default") + if isinstance(d, str) and node.get("type") == "string": + if len(d) < node.get("minLength", 0): + bad.append(f"{path} (default {d!r} under minLength)") + elif "maxLength" in node and len(d) > node["maxLength"]: + bad.append(f"{path} (default {d!r} over maxLength)") + else: + p = node.get("pattern") + if p and not re.search(p, d): + bad.append(f"{path} (default {d!r} fails pattern)") + for k, v in node.items(): + walk(v, f"{path}/{k}") + elif isinstance(node, list): + for i, v in enumerate(node): + walk(v, f"{path}[{i}]") + + walk(combined, "") + if bad: + sys.exit("string default(s) rejected by the field's own constraints — the field " + f"is wire-required; extend requiredgen instead of shipping them: {bad}") + + +def main(src_dir, desc_path, out_file, required_path, unique_path, bytes_len_path): + """Merge the per-message schemas into one file, tightened by three manifests. + + The three manifest parameters are REQUIRED positionals on purpose. They used + to default to None, so calling this with too few arguments skipped the + matching tightening pass and exited 0 — a caller that dropped an argument got + a smaller, laxer schema and a green build, which is the failure the guard at + the bottom of this file says must raise. + """ enum_name = enum_names_from_descriptor(desc_path) enum_defs = {} # name -> {"type":"string","enum":[...]} unnamed = [] - proto_enum = re.compile(r"^[A-Z][A-Z0-9_]*$") # proto enum value style - def hoist_enums(o): if isinstance(o, dict): vals = o.get("enum") # Only hoist SCREAMING_SNAKE proto-enum value sets; leave non-proto string # enums inline (e.g. the Infinity/-Infinity/NaN double-as-string set). - if isinstance(vals, list) and vals and all(isinstance(x, str) and proto_enum.match(x) for x in vals): + if isinstance(vals, list) and vals and all(isinstance(x, str) and PROTO_ENUM_VALUE.match(x) for x in vals): clean = [v for v in vals if not v.endswith("_UNSPECIFIED")] name = enum_name.get(frozenset(clean)) if name: @@ -320,7 +617,7 @@ def hoist_enums(o): if "jsonschema" in base or ".strict." in base or ".bundle." in base: continue name = re.sub(r"^ramp\.(?:admin\.)?v1\.", "", base.split(".schema")[0]) - d = strip_titles(json.load(open(f))) + d = fold_titles(json.load(open(f)), message=name) d.pop("$id", None); d.pop("$schema", None) defs[name] = d defs = fix_string_null_default(open_messages(collapse_numeric_strings(hoist_enums(fix_refs(defs))))) @@ -331,6 +628,8 @@ def hoist_enums(o): mark_required(defs, json.load(open(required_path))) if unique_path: mark_unique(defs, json.load(open(unique_path))) + if bytes_len_path: + tighten_bytes_len(defs, json.load(open(bytes_len_path))) defs.update(enum_defs) combined = { @@ -345,7 +644,9 @@ def hoist_enums(o): sys.exit(f"unresolved external $refs (add to WKT map): {leftover}") if unnamed: sys.exit(f"inline enums with no descriptor match (value sets): {unnamed[:5]}") + assert_no_titles(combined) assert_no_numeric_string_arms(combined) + assert_defaults_valid(combined) json.dump(combined, open(out_file, "w"), indent=2) print(f"merged {len([k for k in defs if k not in enum_defs])} messages + " @@ -353,4 +654,9 @@ def hoist_enums(o): if __name__ == "__main__": - main(*sys.argv[1:6]) + # No slice bound: an argument-count mismatch with gen-sdk-types.sh must raise + # TypeError and fail the pipeline. A silent truncation (the old [1:6]) would + # instead run with a manifest parameter defaulted to None, disabling that + # tightening pass with exit code 0. The defaults are gone too, so too FEW + # arguments now raise for the same reason too many do. + main(*sys.argv[1:]) diff --git a/sdk/go/README.md b/sdk/go/README.md index 30685869..47af879d 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -1,6 +1,6 @@ # RAMP SDK — Go -Layered protocol libraries co-located with the contract (ADR-020). The SDK is +Layered protocol libraries co-located with the contract. The SDK is consumed off-commit from this repo; it imports the generated L0 wire types directly with no `replace` directive. @@ -52,7 +52,7 @@ the SDK closes); the signature covers `pricing`, `terms`, and `expires_at`: err := helpers.VerifyOffer(offer, offer.GetSignature(), exchangePub) ``` -**Signed delivery URLs + proof-of-possession** (ADR-013), byte-identical with the +**Signed delivery URLs + proof-of-possession**, byte-identical with the edge worker: ```go @@ -74,7 +74,7 @@ wire, _ := helpers.FormatMoney(rate.Mul(decimal.NewFromInt(qty))) if err := helpers.Validate(req); err != nil { /* helpers.ValidationRuleIDs(err) */ } ``` -**Agent-binding proof of possession** (ADR-013) — the SIGN face of the header pair +**Agent-binding proof of possession** — the SIGN face of the header pair a bound delivery fetch presents. The covered set is exactly `@method` + `@target-uri`: a GET has no body to digest, and the signed URL is itself the credential. The key arrives as a `Signer` plus the public half, so custody never @@ -140,7 +140,7 @@ held there by a conformance guard. Reach for it wherever you vet a domain that arrived in a message; note that the client's own send path still vets with the wider `IsBareHost`, so passing that one is not yet evidence the wire will accept a value. -**Also:** RFC 7638 `Thumbprint`, ADR-019 `ErrorDetail` constructors + +**Also:** RFC 7638 `Thumbprint`, `ErrorDetail` constructors + `AsConnectError`/`ErrorDetailFrom`/`Reason`, `NewIdempotencyKey`, scope helpers, `RedactURL` (a signed URL carries its credential in the query — never log it raw), and `RetrievalAuthFailureReasonFromToken` (the delivery edge's refusal vocabulary, @@ -200,7 +200,7 @@ host is reserved. The address/scheme decisions are corpus-locked ## Guarantees - **Relocation, not rewrite.** The RFC 9421 / 7638 crypto is byte-identical with - the service-internal implementations it replaces (ADR-020 §8). + the service-internal implementations it replaces. - **Cross-field CEL is pinned** to `conformance/corpus/crossfield.json`, generated by the same protovalidate oracle the rest of the conformance suite uses. - Covered by the repo gate: `go build/vet/test ./...` via `scripts/ci-local.sh`. diff --git a/sdk/go/connect/client.go b/sdk/go/connect/client.go index 3ba81660..27cb11be 100644 --- a/sdk/go/connect/client.go +++ b/sdk/go/connect/client.go @@ -23,7 +23,7 @@ import ( // cross-cutting request-id / validate interceptors wired, plus the fail-closed // offer Verifier that sorts every discovered offer into {verified, rejected}. It // owns NO state — signer, keys, HTTP client, and verification policy are all -// injected (ADR-020 §2/§3). +// injected. type Client struct { rpc rampv1connect.ExchangeServiceClient verifier core.Verifier @@ -285,8 +285,7 @@ type callConfig struct { // WithIdempotencyKey pins the idempotency key for this call. Reusing a key makes // the call a deliberate replay: the server dedupes on it (a fresh key is minted -// per call by default). The SDK never tracks keys — the server owns dedup -// (ADR-019 §4, ADR-020 §3). +// per call by default). The SDK never tracks keys — the server owns dedup. // // Hold the key and pass the same one back when retrying, on every verb that takes // this option. The key identifies the ACTION, not the attempt: a fresh key on a diff --git a/sdk/go/connect/doc.go b/sdk/go/connect/doc.go index 0315ec0c..660cd654 100644 --- a/sdk/go/connect/doc.go +++ b/sdk/go/connect/doc.go @@ -10,7 +10,7 @@ // manifest and vets it before anything signed is sent, the CallError taxonomy that // tells a refusal from a failure from a local decline, the shared bidirectional // protovalidate interceptor (NewValidateInterceptor, the single definition the -// server binding also composes), and the READ direction of the ADR-019 +// server binding also composes), and the READ direction of the // ErrorDetail↔Connect bridge (ErrorDetailFrom — a client reads the typed error // detail an upstream emitted, whether a peer sent it or the content leg // synthesized it). @@ -19,7 +19,7 @@ // guard, signing transport, ReplayStore) and on resolvers for the one thing this // package must not do itself — dial. The content fetch and the offer-derived RPC // leg both run on the resolvers tier's guarded transport; core and helpers stay -// Connect-free (ADR-020 §2/§3). +// Connect-free. // // The SERVER binding is a SEPARATE package, sdk/go/connectserver // (NewExchangeServiceHandler + the verify http-seam + AsConnectError / reject→code). diff --git a/sdk/go/connect/errordetail_test.go b/sdk/go/connect/errordetail_test.go index 99d8b418..a93f34e1 100644 --- a/sdk/go/connect/errordetail_test.go +++ b/sdk/go/connect/errordetail_test.go @@ -1,6 +1,6 @@ package connect_test -// The ADR-019 ErrorDetail↔Connect round-trip, split by direction: AsConnectError +// The ErrorDetail↔Connect round-trip, split by direction: AsConnectError // (emit) lives in the SERVER binding sdk/go/connectserver, ErrorDetailFrom (read) // lives in the CLIENT binding sdk/go/connect; the neutral *rampv1.ErrorDetail // builders and the Reason accessor stay in sdk/go/helpers. This suite exercises the diff --git a/sdk/go/connect/interceptors.go b/sdk/go/connect/interceptors.go index 09256690..a03ef2dd 100644 --- a/sdk/go/connect/interceptors.go +++ b/sdk/go/connect/interceptors.go @@ -27,11 +27,12 @@ type requestIDInterceptor struct { // the delivery fetch is a plain GET that never reaches an interceptor and takes its // own hook. Two nil checks are two places for the default to drift, and the leg // that would drift silently is the one carrying no id at all. +// +// core.MintRequestID supplies both the nil default AND the conformance check, so +// the two legs cannot disagree about what a usable id is. Doing the check per leg +// is what let the delivery fetch ship without one. func requestIDMint(mint core.RequestIDFunc) core.RequestIDFunc { - if mint == nil { - return core.DefaultRequestID - } - return mint + return core.MintRequestID(mint) } func newRequestIDInterceptor(mint core.RequestIDFunc) connectrpc.Interceptor { @@ -40,6 +41,14 @@ func newRequestIDInterceptor(mint core.RequestIDFunc) connectrpc.Interceptor { func (i *requestIDInterceptor) WrapUnary(next connectrpc.UnaryFunc) connectrpc.UnaryFunc { return func(ctx context.Context, req connectrpc.AnyRequest) (connectrpc.AnyResponse, error) { + // Stamp only when absent — a caller who set the header meant it, and the + // client is not the place to police a peer's correlation vocabulary. The + // MINTED value is already checked: i.mint came from core.MintRequestID, + // which never returns a value the admin plane would refuse. That matters + // because an injected RequestIDFunc usually reuses a trace id, and a trace + // id may carry characters the admin plane cannot persist. Sending one + // would make the receiving Exchange replace it, silently breaking the + // correlation this interceptor exists to create. if req.Spec().IsClient && req.Header().Get(core.RequestIDHeader) == "" { req.Header().Set(core.RequestIDHeader, i.mint()) } @@ -57,8 +66,8 @@ func (i *requestIDInterceptor) WrapStreamingHandler(next connectrpc.StreamingHan // Validation selects protovalidate strictness for the SDK validate interceptor. // Strict wires the vetted connectrpc.com/validate interceptor BIDIRECTIONALLY — -// requests, responses, AND error details are one SDK-validated contract -// (ADR-019). Off omits the interceptor. It is a distinct +// requests, responses, AND error details are one SDK-validated contract. +// Off omits the interceptor. It is a distinct // axis from WithVerification (offer authenticity): validation is proto-shape // conformance, verification is signature authenticity. This enum is SHARED by both // faces: the server binding (sdk/go/connectserver) references connect.Validation so @@ -75,8 +84,8 @@ const ( // NewValidateInterceptor returns the bidirectional protovalidate interceptor built // on the vetted connectrpc.com/validate library. It validates requests, responses, -// AND error details (WithValidateResponses) — the two-way SDK-validated contract of -// ADR-019 — reusing the shared protovalidate engine +// AND error details (WithValidateResponses) — the two-way SDK-validated contract, +// reusing the shared protovalidate engine // helpers.Validate wraps so the interceptor and the L1 pre-check share one engine // (zero rule drift). It is the SINGLE definition both the client (this package) and // the server binding (sdk/go/connectserver, which imports it) compose, so the two diff --git a/sdk/go/connect/options.go b/sdk/go/connect/options.go index c31846df..f80de410 100644 --- a/sdk/go/connect/options.go +++ b/sdk/go/connect/options.go @@ -18,7 +18,7 @@ import ( // clientConfig is the resolved set of injected holders a Client is built from. // Everything is INJECTED — signer, offer KeyResolver, HTTP client, request-id // source, verification mode, extra interceptors — and the SDK owns none of it as -// state (ADR-020 §3). +// state. type clientConfig struct { signer helpers.Signer httpClient *http.Client diff --git a/sdk/go/connect/testsupport_test.go b/sdk/go/connect/testsupport_test.go index 83121228..ef8144ec 100644 --- a/sdk/go/connect/testsupport_test.go +++ b/sdk/go/connect/testsupport_test.go @@ -5,7 +5,7 @@ package connect_test // injected core.ReplayStore interface — it is an APPLICATION-supplied dependency // the SDK orchestrates over (the KeyResolver-shaped middle), NOT a mock of the // code under test. The SDK owns the replay-check control-flow; the app owns the -// store and its TTL policy (ADR-020 §3 / Core Invariant). Relocated verbatim from +// store and its TTL policy (Core Invariant). Relocated verbatim from // sdk/go/ramp on the core/connect split (ramp.ReplayStore → core.ReplayStore). import ( diff --git a/sdk/go/connectserver/doc.go b/sdk/go/connectserver/doc.go index 65a3949a..8671afaa 100644 --- a/sdk/go/connectserver/doc.go +++ b/sdk/go/connectserver/doc.go @@ -2,9 +2,9 @@ // transport-neutral sdk/go/core L2 substance: the verify http-seam handler // (NewExchangeServiceHandler — request-id outermost · verify · validate · // error-detail), the reject→connect.Code mapping, and the EMIT direction of the -// ADR-019 ErrorDetail↔Connect bridge (AsConnectError — a server emits a typed error -// detail). KeyResolver and ReplayStore are injected by the application (ADR-020 -// §2/§3; the server interceptor order is deliberate — see NewExchangeServiceHandler). +// ErrorDetail↔Connect bridge (AsConnectError — a server emits a typed error +// detail). KeyResolver and ReplayStore are injected by the application +// (the server interceptor order is deliberate — see NewExchangeServiceHandler). // // It is a SEPARATE package from the client binding sdk/go/connect so that each face // exposes BARE, symmetric option names — this package's WithKeyResolver, diff --git a/sdk/go/connectserver/errordetail.go b/sdk/go/connectserver/errordetail.go index 05873172..b44525b4 100644 --- a/sdk/go/connectserver/errordetail.go +++ b/sdk/go/connectserver/errordetail.go @@ -6,7 +6,7 @@ import ( rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" ) -// AttachErrorDetail builds the ADR-019 ErrorDetail envelope (the +// AttachErrorDetail builds the ErrorDetail envelope (the // non-authoritative developer message, the stable service domain, and any // structured field metadata) and attaches it to an ALREADY-CLASSIFIED // *connect.Error, returning that same error. It is the SDK-owned realisation of @@ -33,7 +33,7 @@ func AttachErrorDetail( return AttachDetail(cerr, NewErrorDetail(domain, message, metadata)) } -// NewErrorDetail builds the ADR-019 ErrorDetail envelope — the stable service +// NewErrorDetail builds the ErrorDetail envelope — the stable service // domain, the non-authoritative developer message, and structured field // metadata stamped only when non-empty (a nil map and an empty map are both // absent on the wire; keeping the field nil avoids allocating an empty map the diff --git a/sdk/go/connectserver/errordetail_test.go b/sdk/go/connectserver/errordetail_test.go index 01531df6..62232bfa 100644 --- a/sdk/go/connectserver/errordetail_test.go +++ b/sdk/go/connectserver/errordetail_test.go @@ -1,6 +1,6 @@ package connectserver_test -// AttachErrorDetail is the SDK-owned realisation of the ADR-019 ErrorDetail<->domain +// AttachErrorDetail is the SDK-owned realisation of the ErrorDetail<->domain // envelope build: a service maps a domain error to a classified // connect.Error, then stamps the generic (Domain + Message + optional Metadata) // detail with this ONE body instead of copying it per service. These tests pin the diff --git a/sdk/go/connectserver/handler.go b/sdk/go/connectserver/handler.go index e70cd62c..64efded4 100644 --- a/sdk/go/connectserver/handler.go +++ b/sdk/go/connectserver/handler.go @@ -21,7 +21,7 @@ import ( // request-id inner to verify would regress that. verify is an http.Handler wrapper // (body-bytes reason), NOT a connect.Interceptor; validate and error-detail ARE true // connect.Interceptors composed onto the generated handler. KeyResolver and -// ReplayStore are injected by the application (ADR-020 §2/§3). +// ReplayStore are injected by the application. func NewExchangeServiceHandler(svc rampv1connect.ExchangeServiceHandler, opts ...ServerOption) (string, http.Handler) { cfg := resolveServerConfig(opts) path, connectHandler := rampv1connect.NewExchangeServiceHandler(svc, cfg.connectHandlerOptions()...) diff --git a/sdk/go/connectserver/options.go b/sdk/go/connectserver/options.go index d699f5c2..aaec9587 100644 --- a/sdk/go/connectserver/options.go +++ b/sdk/go/connectserver/options.go @@ -20,7 +20,7 @@ const replayTTL = 5 * time.Minute // serverConfig is the resolved set of injected holders the server verify face runs // over: the request-signing KeyResolver, the ReplayStore, the hop budget, the // request-id source, and any application interceptors. All are injected — the SDK -// owns no keys, no replay state, and no policy constants (ADR-020 §3). +// owns no keys, no replay state, and no policy constants. type serverConfig struct { resolver helpers.KeyResolver replay core.ReplayStore diff --git a/sdk/go/connectserver/reject.go b/sdk/go/connectserver/reject.go index a57bdad7..0c3a81a8 100644 --- a/sdk/go/connectserver/reject.go +++ b/sdk/go/connectserver/reject.go @@ -46,7 +46,7 @@ func writeReject(w http.ResponseWriter, err error) { } // AsConnectError builds a *connect.Error of the given Code with detail attached -// as a typed error detail (the ADR-019 transport mechanism). The detail's +// as a typed error detail (the transport mechanism). The detail's // Message becomes the error string. It lives in the SERVER binding (the emit // direction: a server EMITS a typed error detail) — not the transport-neutral L1 // helpers — so a non-Connect consumer of helpers/core compiles zero connectrpc; the diff --git a/sdk/go/connectserver/verify_semantics_test.go b/sdk/go/connectserver/verify_semantics_test.go index 1bd65b44..8864813a 100644 --- a/sdk/go/connectserver/verify_semantics_test.go +++ b/sdk/go/connectserver/verify_semantics_test.go @@ -5,7 +5,7 @@ package connectserver_test // // 1. GATE PREDICATE — a /ramp. request that carries NO Signature-Input is not // seam-rejected: it reaches the origin handler, which owns the typed -// Unauthenticated fault (the ADR-019 ErrorDetail contract). Only a request +// Unauthenticated fault (the ErrorDetail contract). Only a request // that presents a signature is verified at the seam. // // 2. REPLAY NONCE — the replay guard keys on the SIGNATURE (per verified diff --git a/sdk/go/core/doc.go b/sdk/go/core/doc.go index 7f2234e5..585f5691 100644 --- a/sdk/go/core/doc.go +++ b/sdk/go/core/doc.go @@ -5,7 +5,7 @@ // compile guard with the loud RejectedOffer.Unsafe escape, the client signing // http.RoundTripper (NewSigningTransport), the injected ReplayStore interface, and // the neutral request-id mint/middleware — all built on the sdk/go/helpers L1 -// primitives with net/http as the only transport dependency (ADR-020 §2/§3). +// primitives with net/http as the only transport dependency. // // The discovery shape is grouped rather than flat because a discovery call is // per-URI: a URI that yielded nothing has no offer to carry its identity back, so diff --git a/sdk/go/core/replay.go b/sdk/go/core/replay.go index ff6e811b..ac4e4184 100644 --- a/sdk/go/core/replay.go +++ b/sdk/go/core/replay.go @@ -11,7 +11,7 @@ import ( // default), but owns NO replay state, TTL, or persistence — the application SUPPLIES // the store and its TTL policy. This is the KeyResolver-shaped middle: the SDK // orchestrates over injected state, exactly as it resolves keys over an injected -// KeyResolver without owning keys (ADR-020 §3 / Core Invariant). +// KeyResolver without owning keys (Core Invariant). // // It lives in package core (not connect) so the client and server faces share // one interface name and an app defines its store once for both. diff --git a/sdk/go/core/requestid.go b/sdk/go/core/requestid.go index 39a61d7b..cae44d3b 100644 --- a/sdk/go/core/requestid.go +++ b/sdk/go/core/requestid.go @@ -1,6 +1,7 @@ package core import ( + "context" "crypto/rand" "encoding/hex" "net/http" @@ -27,22 +28,128 @@ func DefaultRequestID() string { return hex.EncodeToString(b) } +// maxRequestIDLen is the admin plane's ceiling on a persisted correlation id. +// Counted in bytes here and in CHARACTERS by the wire rule, which is the same +// number for any value this function accepts: every character in the allowed +// range is one ASCII byte. +const maxRequestIDLen = 255 + +// ValidRequestID reports whether s is a correlation id the RAMP admin plane can +// carry — 1 to 255 printable ASCII characters, the `^[!-~]+$` rule on +// ramp.admin.v1.RequestCorrelation.request_id. +// +// The check exists because that field sits inside a REQUIRED message inside a +// REQUIRED field of GetTransactionEvidenceResponse. A single stored value +// outside this range does not degrade the response, it INVALIDATES it — so a +// caller who gets a hostile header persisted permanently breaks the forensic +// row for their own transaction. Guarding the write path is what keeps that +// impossible. +// +// Exported because the same test is needed twice: here on the way in, and again +// by anything reading a stored correlation back, since rows written before a +// server had this check may hold a value it would refuse today. +// +// A server MAY be stricter — the reference Exchange accepts only +// `^[A-Za-z0-9._-]{1,128}$`, which avoids every log-injection metacharacter. +// This function deliberately is not, because the SDK should not refuse values +// the contract can represent; narrowing is a deployment's decision to make on +// top of it. +func ValidRequestID(s string) bool { + if len(s) == 0 || len(s) > maxRequestIDLen { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '!' || s[i] > '~' { + return false + } + } + return true +} + +// RequestID is the correlation id a server settled on for one request, with the +// provenance an evidence writer has to persist beside it. +type RequestID struct { + // Value always satisfies ValidRequestID. + Value string + // Derived reports that the SERVER produced this value rather than a caller: + // the header was absent, or it was present and did not conform. It maps + // directly to the evidence store's request_id_minted column and to + // ramp.admin.v1.RequestCorrelation.minted, both of which mean + // "server-derived" rather than "the header was absent". False means a + // caller chose these characters, which is what makes the value + // attacker-influenceable and the flag worth persisting. + Derived bool +} + +type requestIDCtxKey struct{} + +// RequestIDFromContext returns the correlation id RequestIDMiddleware settled on +// for this request. A handler needs both halves to write an evidence row: the +// value alone cannot say whether a caller chose it. +func RequestIDFromContext(ctx context.Context) (RequestID, bool) { + v, ok := ctx.Value(requestIDCtxKey{}).(RequestID) + return v, ok +} + +// MintRequestID wraps mint so that it always returns a value the admin plane can +// persist, and returns DefaultRequestID when mint is nil. +// +// An application supplies its own mint through WithRequestIDFunc, typically to +// reuse a trace id, and a trace id is exactly the kind of value that carries a +// colon, a brace, or nothing at all. A mint that returns something unusable falls +// back to DefaultRequestID rather than letting the trusted side put a value in +// the store that the untrusted side could not. +// +// Every place that stamps X-Request-ID from a mint goes through here. That is the +// point of exporting it: the check is a property of the mint, not of one caller, +// and a stamping site that reimplements it is a site that can be added without +// it. Both client legs — the RPC interceptor and the delivery fetch — take their +// mint from this function, so one call cannot send two different ids. +func MintRequestID(mint RequestIDFunc) RequestIDFunc { + if mint == nil { + return DefaultRequestID + } + return func() string { + if id := mint(); ValidRequestID(id) { + return id + } + return DefaultRequestID() + } +} + +// resolveRequestID picks the id for one request and reports whether the server +// derived it. +// +// A received value is propagated only if it conforms; a nonconforming one is +// REPLACED, not passed through and not dropped. Replacing keeps every request +// correlated — which is the reason this middleware exists — while the Derived +// flag preserves the forensic distinction the value itself cannot carry. The +// contract permits dropping the correlation instead; a server that prefers that +// can read the flag and decline to persist the pair. +func resolveRequestID(received string, mint RequestIDFunc) RequestID { + if ValidRequestID(received) { + return RequestID{Value: received, Derived: false} + } + return RequestID{Value: MintRequestID(mint)(), Derived: true} +} + // RequestIDMiddleware stamps (or propagates) X-Request-ID on the response BEFORE the // next handler runs, so a rejected request still returns a correlated id. It is a // plain net/http middleware — transport-neutral — kept at the http seam so it wraps // the reject path too. The Connect server face composes it OUTERMOST (a reject // response must still carry a stamped X-Request-ID); a non-Connect net/http server // can compose it directly. +// +// The id it settles on is always one the admin plane can persist: a caller's +// header is propagated only when it conforms (see resolveRequestID). Downstream +// handlers read the value and its provenance with RequestIDFromContext. func RequestIDMiddleware(mint RequestIDFunc, next http.Handler) http.Handler { if mint == nil { mint = DefaultRequestID } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - id := r.Header.Get(RequestIDHeader) - if id == "" { - id = mint() - } - w.Header().Set(RequestIDHeader, id) - next.ServeHTTP(w, r) + id := resolveRequestID(r.Header.Get(RequestIDHeader), mint) + w.Header().Set(RequestIDHeader, id.Value) + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDCtxKey{}, id))) }) } diff --git a/sdk/go/core/requestid_contract_test.go b/sdk/go/core/requestid_contract_test.go new file mode 100644 index 00000000..1da3c829 --- /dev/null +++ b/sdk/go/core/requestid_contract_test.go @@ -0,0 +1,122 @@ +// Package core_test — requestid_contract_test.go ties ValidRequestID to the wire +// rule it claims to implement. +// +// ValidRequestID is a hand-written byte loop. The authority is the protovalidate +// rule on ramp.admin.v1.RequestCorrelation.request_id. Two statements of one rule, +// and until this file nothing compared them. +// +// THE FAILURE THAT WAS POSSIBLE. Narrow the rule in the proto — max_len to 128, +// say, or the charset to exclude a metacharacter. Every Go test passes, the corpus +// regenerates cleanly, both generated clients tighten. ValidRequestID keeps +// accepting what it always accepted, so RequestIDMiddleware keeps stamping ids the +// receiving Exchange must now refuse, and the correlation the middleware exists to +// create is silently broken on the write path. +// +// This is a DIFFERENTIAL test, not a restatement: it asks protovalidate for the +// verdict and asks ValidRequestID for the verdict and requires them to agree. No +// literal from the rule — no 255, no `^[!-~]+$` — appears here. A guard that +// spelled the rule a third time would add a third thing to drift. +// +// It lives in sdk/go/core rather than in conformance/ because conformance/ is the +// descriptor-level layer BELOW the SDKs and imports nothing from sdk/. Here the +// direction is the ordinary one: sdk/go/core already imports gen/go. +package core_test + +import ( + "strings" + "testing" + + protovalidate "buf.build/go/protovalidate" + rampadminv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/admin/v1" + "github.com/RAMP-Protocol/protocol/sdk/go/core" +) + +// wireAcceptsRequestID reports what the CONTRACT says about s, by validating a +// RequestCorrelation carrying it and looking only at violations on request_id. +// +// Only that field's violations count: `minted` is a bare bool with no rule today, +// but a rule added there later must not silently turn this guard into a test of +// the wrong field. +func wireAcceptsRequestID(t *testing.T, v protovalidate.Validator, s string) bool { + t.Helper() + err := v.Validate(&rampadminv1.RequestCorrelation{RequestId: s}) + if err == nil { + return true + } + verr, ok := err.(*protovalidate.ValidationError) + if !ok { + t.Fatalf("validating request_id=%q: unexpected error type %T: %v", s, err, err) + } + for _, viol := range verr.Violations { + if els := viol.Proto.GetField().GetElements(); len(els) > 0 && + els[0].GetFieldName() == "request_id" { + return false + } + } + return true +} + +func TestValidRequestIDMatchesTheWireRule(t *testing.T) { + v, err := protovalidate.New() + if err != nil { + t.Fatalf("protovalidate.New: %v", err) + } + + // The table is chosen to straddle every edge the rule can have, so a change to + // ANY of its three clauses shows up as a disagreement rather than as two + // implementations quietly drifting together. Nothing here asserts a verdict of + // its own — each row is judged by both sides and only the agreement is checked. + cases := []string{ + "", " ", "\x1f", "\x20", "\x21", "\x7e", "\x7f", "ÿ", + "a", "abc", "trace-id-1", "trace:id{1}", "with space", "tab\there", + "new\nline", "null\x00byte", "café", "🙂", + strings.Repeat("a", 1), + strings.Repeat("a", 127), + strings.Repeat("a", 128), + strings.Repeat("a", 129), + strings.Repeat("a", 254), + strings.Repeat("a", 255), + strings.Repeat("a", 256), + strings.Repeat("a", 512), + // Multi-byte: 255 CHARACTERS but more than 255 bytes. The rule counts + // characters and ValidRequestID counts bytes; they agree only because every + // character the charset admits is one ASCII byte. This row is what proves + // that reasoning instead of asserting it. + strings.Repeat("é", 255), + } + + for _, s := range cases { + want := wireAcceptsRequestID(t, v, s) + if got := core.ValidRequestID(s); got != want { + t.Errorf("ValidRequestID(%q) = %v, but the wire rule on "+ + "ramp.admin.v1.RequestCorrelation.request_id says %v.\n"+ + "The SDK's copy of the rule has drifted from the contract. Update "+ + "ValidRequestID to match the descriptor, not this table.", s, got, want) + } + } +} + +// TestMintRequestIDAlwaysConforms pins the property every stamping site relies on: +// whatever a caller's mint returns, what comes out is something the admin plane can +// persist. Each site used to re-derive this, and the one that forgot shipped. +func TestMintRequestIDAlwaysConforms(t *testing.T) { + for name, mint := range map[string]core.RequestIDFunc{ + "nil": nil, + "empty": func() string { return "" }, + "space": func() string { return "trace id" }, + "newline": func() string { return "trace\nid" }, + "non-ascii": func() string { return "café" }, + "too long": func() string { return strings.Repeat("a", 256) }, + "already valid": func() string { return "abc-123" }, + } { + got := core.MintRequestID(mint)() + if !core.ValidRequestID(got) { + t.Errorf("%s mint: MintRequestID produced %q, which the wire rule refuses", name, got) + } + } + // A conforming mint is passed through, not replaced — otherwise the caller's + // trace id never reaches the header and the feature is pointless. + if got := core.MintRequestID(func() string { return "abc-123" })(); got != "abc-123" { + t.Errorf("MintRequestID replaced a conforming value: got %q, want %q", got, "abc-123") + } +} diff --git a/sdk/go/core/requestid_test.go b/sdk/go/core/requestid_test.go new file mode 100644 index 00000000..3748d24b --- /dev/null +++ b/sdk/go/core/requestid_test.go @@ -0,0 +1,188 @@ +// Package core — requestid_test.go pins the one property the request-id +// middleware exists to guarantee: whatever it settles on, the value can be +// persisted as ramp.admin.v1.RequestCorrelation.request_id. +// +// WHY THAT MATTERS MORE THAN IT LOOKS. request_id sits inside a REQUIRED message +// inside a REQUIRED field of GetTransactionEvidenceResponse. A stored value +// outside `^[!-~]+$` does not degrade that response, it invalidates the whole of +// it — so a caller who gets a hostile header persisted permanently breaks the +// forensic row for their own transaction, with one HTTP header and no other +// access. These tests are the guard that keeps that unreachable through this +// middleware; before them the header was propagated verbatim. +package core_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/RAMP-Protocol/protocol/sdk/go/core" +) + +// hostileHeaders are the shapes the wire rule refuses. Each is a real way a +// correlation id goes wrong rather than a random invalid string: control +// characters and terminal escapes matter because a ledger RENDERS this value, +// newlines because a log pipeline splits on them, and the oversize case because +// the rule's ceiling is the reason the field cannot hold an arbitrary blob. +var hostileHeaders = map[string]string{ + "newline": "abc\ndef", + "carriage return": "abc\rdef", + "nul": "abc\x00def", + "terminal escape": "\x1b[2Jwiped", + "tab": "abc\tdef", + "space": "two words", + "non-ascii": "café", + "del": "abc\x7f", + "over 255 chars": strings.Repeat("a", 256), + "empty after trim": " ", +} + +// echoRequestID reports what the middleware put in the context, so a test sees +// the value a handler would actually persist rather than only the response +// header. +func echoRequestID(t *testing.T) (http.Handler, *core.RequestID) { + t.Helper() + var seen core.RequestID + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, ok := core.RequestIDFromContext(r.Context()) + if !ok { + t.Error("handler saw no request id in context — RequestIDMiddleware must always place one") + return + } + seen = id + }) + return h, &seen +} + +func TestMiddlewareReplacesNonconformingHeader(t *testing.T) { + for name, hostile := range hostileHeaders { + t.Run(name, func(t *testing.T) { + next, seen := echoRequestID(t) + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set(core.RequestIDHeader, hostile) + rec := httptest.NewRecorder() + + core.RequestIDMiddleware(nil, next).ServeHTTP(rec, req) + + if seen.Value == hostile { + t.Fatalf("middleware propagated a nonconforming header %q — the admin plane cannot persist it", hostile) + } + if !core.ValidRequestID(seen.Value) { + t.Fatalf("middleware settled on %q, which is itself nonconforming", seen.Value) + } + if !seen.Derived { + t.Error("a replaced header must be reported as server-derived, or an evidence writer records it as caller-supplied") + } + if got := rec.Header().Get(core.RequestIDHeader); got != seen.Value { + t.Errorf("response header %q disagrees with the id the handler saw %q", got, seen.Value) + } + }) + } +} + +func TestMiddlewarePropagatesConformingHeader(t *testing.T) { + // The counterpart that keeps the guard honest: a legitimate caller-supplied + // id must survive verbatim and must NOT be reported as server-derived. A + // middleware that replaced everything would pass the test above and be + // useless. + for name, id := range map[string]string{ + "hex token": "0123456789abcdef0123456789abcdef", + "uuid": "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + "braced uuid": "{3f2504e0-4f89-11d3-9a0c-0305e82c3301}", + "trace id": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + "single char": "x", + "exactly 255": strings.Repeat("a", 255), + "punctuation mix": "req:1/2?a=b#c", + } { + t.Run(name, func(t *testing.T) { + next, seen := echoRequestID(t) + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set(core.RequestIDHeader, id) + + core.RequestIDMiddleware(nil, next).ServeHTTP(httptest.NewRecorder(), req) + + if seen.Value != id { + t.Errorf("conforming header %q was rewritten to %q", id, seen.Value) + } + if seen.Derived { + t.Error("a propagated caller value must not be reported as server-derived — the flag is what marks it attacker-influenceable") + } + }) + } +} + +func TestMiddlewareMintsWhenHeaderAbsent(t *testing.T) { + next, seen := echoRequestID(t) + core.RequestIDMiddleware(nil, next).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", nil)) + + if !core.ValidRequestID(seen.Value) { + t.Fatalf("minted id %q does not conform", seen.Value) + } + if !seen.Derived { + t.Error("an absent header must yield a server-derived id") + } +} + +func TestMiddlewareRejectsAHostileCustomMint(t *testing.T) { + // The trusted side is a real source of bad values: an application injects + // its own mint through WithRequestIDFunc, usually to reuse a trace id, and a + // trace id can carry anything. Without this fallback the middleware would + // refuse a caller's newline and then insert its own. + for name, bad := range map[string]string{ + "empty": "", + "newline": "trace\nid", + "oversize": strings.Repeat("z", 300), + "non-ascii": "trace-id-ü", + } { + t.Run(name, func(t *testing.T) { + next, seen := echoRequestID(t) + mint := func() string { return bad } + + core.RequestIDMiddleware(mint, next).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/", nil)) + + if seen.Value == bad { + t.Fatalf("middleware used a nonconforming minted value %q", bad) + } + if !core.ValidRequestID(seen.Value) { + t.Fatalf("fallback produced %q, which is itself nonconforming", seen.Value) + } + if !seen.Derived { + t.Error("a minted id is server-derived by definition") + } + }) + } +} + +func TestDefaultRequestIDConforms(t *testing.T) { + // resolveRequestID's last resort. If this ever stopped conforming, every + // other guarantee here would fall back to a value the admin plane rejects. + for i := 0; i < 64; i++ { + if id := core.DefaultRequestID(); !core.ValidRequestID(id) { + t.Fatalf("DefaultRequestID produced %q, which the wire rule refuses", id) + } + } +} + +func TestValidRequestIDBoundaries(t *testing.T) { + // The rule is `^[!-~]+$`, so the boundary characters are 0x21 and 0x7e, with + // 0x20 (space) and 0x7f (DEL) immediately outside. Pinning the edges catches + // an off-by-one in the comparison that no realistic sample string would. + for _, c := range []struct { + name string + s string + valid bool + }{ + {"0x20 space below range", "\x20", false}, + {"0x21 bang, first in range", "\x21", true}, + {"0x7e tilde, last in range", "\x7e", true}, + {"0x7f del above range", "\x7f", false}, + {"empty", "", false}, + {"255 chars", strings.Repeat("a", 255), true}, + {"256 chars", strings.Repeat("a", 256), false}, + } { + if got := core.ValidRequestID(c.s); got != c.valid { + t.Errorf("%s: ValidRequestID(%q) = %v, want %v", c.name, c.s, got, c.valid) + } + } +} diff --git a/sdk/go/core/transport.go b/sdk/go/core/transport.go index 5717fbf0..41fe1a88 100644 --- a/sdk/go/core/transport.go +++ b/sdk/go/core/transport.go @@ -23,8 +23,8 @@ const signWindow = 5 * time.Minute // the proto message, not the serialized payload). So signing MUST happen at the // HTTP seam, after Connect has marshaled the request. This is the single correct // place to sign; realizing sign as a connect.Interceptor would produce a wrong or -// absent Content-Digest and break interop with every verifier (ADR-020 §2, the -// sign-as-RoundTripper decision). +// absent Content-Digest and break interop with every verifier: the +// signing seam is the RoundTripper, not an interceptor. type signingTransport struct { base http.RoundTripper signer helpers.Signer diff --git a/sdk/go/core/verifier.go b/sdk/go/core/verifier.go index cead60ff..d13cee83 100644 --- a/sdk/go/core/verifier.go +++ b/sdk/go/core/verifier.go @@ -27,7 +27,7 @@ const ( // ErrOfferExpired signals an offer whose expires_at is in the past. Verification // is fail-closed on freshness as well as signature: an expired offer is rejected -// even if its signature is genuine (ADR-020 §4 "verify everything"). +// even if its signature is genuine — verify everything. var ErrOfferExpired = errors.New("ramp: offer expires_at is in the past") // VerifiedOffer wraps an Offer that has passed the SDK's fail-closed verification @@ -37,7 +37,7 @@ var ErrOfferExpired = errors.New("ramp: offer expires_at is in the past") // composite literal: the only way to obtain a VerifiedOffer is the SDK verify path // or the explicit .Unsafe() escape. That is what makes Client.Execute(ctx, // VerifiedOffer) a real COMPILE guard rather than a runtime check a caller can -// forget (ADR-020 §4, ramp-sdk-api.md compile-time guard). +// forget: the type system carries the check, not the caller's memory. type VerifiedOffer struct { offer *rampv1.Offer } @@ -65,7 +65,7 @@ func (r RejectedOffer) Unsafe() VerifiedOffer { return VerifiedOffer{offer: r.Of // Result is the fail-closed {verified, rejected} contract every discover/resolve // call returns. Neither list is silently dropped: a caller can act on Verified and // inspect Rejected (count + reason). It is the canonical cross-language shape -// (ramp-sdk-api.md); Go/TS add the VerifiedOffer compile guard on top. +// Go/TS add the VerifiedOffer compile guard on top. type Result struct { Verified []VerifiedOffer Rejected []RejectedOffer diff --git a/sdk/go/helpers/canonicalsign.go b/sdk/go/helpers/canonicalsign.go index 30c8bb65..82fc5d5a 100644 --- a/sdk/go/helpers/canonicalsign.go +++ b/sdk/go/helpers/canonicalsign.go @@ -10,7 +10,7 @@ import ( "google.golang.org/protobuf/reflect/protoreflect" ) -// Canonical signing payload (ADR-020 §4, ramp.proto "canonical signing" note). +// Canonical signing payload (ramp.proto "canonical signing" note). // // Both signed RAMP payloads — the Offer signature and the agent's detached // offer-acceptance — cover a canonical serialization of a protobuf message. As of diff --git a/sdk/go/helpers/constants.go b/sdk/go/helpers/constants.go index 851e98a6..c5421ae0 100644 --- a/sdk/go/helpers/constants.go +++ b/sdk/go/helpers/constants.go @@ -1,7 +1,7 @@ package helpers // Wire constants shared across the SDK. Encoding is negotiated per hop via -// Content-Type (ADR-020): application/proto for binary, application/json for +// Content-Type: application/proto for binary, application/json for // canonical proto-JSON. connect-go serves both, so each leg picks independently. const ( // ContentTypeProto is the Content-Type for binary protobuf bodies. diff --git a/sdk/go/helpers/context.go b/sdk/go/helpers/context.go index 2560b927..a935f2d5 100644 --- a/sdk/go/helpers/context.go +++ b/sdk/go/helpers/context.go @@ -4,7 +4,7 @@ import "context" // Context plumbing for carrying verified signature(s) through a request's // context. This is the PURE slot + accessors only — L1 has no Middleware (that -// is transport, ADR-020 keeps L1 IO-free); the platform relay/interceptor is the +// is transport, and L1 stays IO-free); the platform relay/interceptor is the // legitimate populator. Tests may populate the slots directly to drive // handler-level authz without standing up the full signing chain. diff --git a/sdk/go/helpers/doc.go b/sdk/go/helpers/doc.go index a1263889..815d102d 100644 --- a/sdk/go/helpers/doc.go +++ b/sdk/go/helpers/doc.go @@ -1,7 +1,7 @@ // Package helpers is the RAMP SDK low-tier L1: stateless protocol helpers // built directly on the L0 generated wire types (github.com/RAMP-Protocol/protocol/gen/go). // -// # Layering (ADR-020) +// # Layering // // L0 generated wire types gen/go/ramp/v1, gen/go/vocab/* (consumed, never rebuilt) // L1 stateless protocol helpers THIS PACKAGE (no IO, no state, no transport) @@ -10,9 +10,9 @@ // // L1 owns exactly the protocol mechanics that are defined by the spec, are // stateless / single-operation, and have two or more consumers (the inclusion -// test, ADR-020 §3): RFC 9421 request signing and verification, RFC 7638 JWK +// test): RFC 9421 request signing and verification, RFC 7638 JWK // thumbprints, signed-URL signing/verification and proof-of-possession, money -// (canonical decimal string) parsing and formatting, the ADR-019 ErrorDetail +// (canonical decimal string) parsing and formatting, the ErrorDetail // mapping, idempotency-key minting, scope/subscription plumbing, the cross-field // CEL validation rules, and the KeyResolver abstraction with a well-known // default. @@ -28,5 +28,5 @@ // on. This package is a relocation of the previously service-internal helpers // (internal/httpsig, internal/rampthumbprint, internal/rampwellknown, // src/exchange/internal/signing), made into one reusable surface — the crypto is -// kept byte-identical across the move (ADR-020 §8). +// kept byte-identical across the move. package helpers diff --git a/sdk/go/helpers/errordetail.go b/sdk/go/helpers/errordetail.go index c0b406e1..d5041809 100644 --- a/sdk/go/helpers/errordetail.go +++ b/sdk/go/helpers/errordetail.go @@ -4,14 +4,14 @@ import ( rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" ) -// ADR-019 error contract. Failure is a typed ErrorDetail attached to the +// The RAMP error contract. Failure is a typed ErrorDetail attached to the // transport error: the Connect/gRPC Code is the coarse class, the ErrorDetail // oneof carries the precise machine-readable reason. Clients branch on the typed // reason, never on a human string. These helpers are the single place the SDK // builds an ErrorDetail and reads its typed reason back — so every service and // client speaks the contract identically instead of re-expressing it. The transport // Code is the caller's to choose (it is service-specific policy which Code a given -// reason maps to, ADR-019 §Consequences). +// reason maps to). // // These builders and the Reason accessor are transport-neutral (they touch only // generated *rampv1 types), so this L1 package imposes no Connect dependency on a diff --git a/sdk/go/helpers/gen_audience_vectors_test.go b/sdk/go/helpers/gen_audience_vectors_test.go index c7fa9d65..bbb618cd 100644 --- a/sdk/go/helpers/gen_audience_vectors_test.go +++ b/sdk/go/helpers/gen_audience_vectors_test.go @@ -1,6 +1,6 @@ package helpers -// Audience-face golden-vector emitter (ADR-020 §5). +// Audience-face golden-vector emitter. // // The bare-domain shape and the audience check are the two halves of "is this // request addressed to me". Both are pure, both are about to exist in three diff --git a/sdk/go/helpers/gen_errordetail_vectors_test.go b/sdk/go/helpers/gen_errordetail_vectors_test.go index ece0b02d..0acb04eb 100644 --- a/sdk/go/helpers/gen_errordetail_vectors_test.go +++ b/sdk/go/helpers/gen_errordetail_vectors_test.go @@ -1,8 +1,8 @@ package helpers -// ErrorDetail cross-language golden-vector emitter (ADR-019 error contract). +// ErrorDetail cross-language golden-vector emitter for the RAMP error contract. // -// The ADR-019 failure envelope — ErrorDetail{Domain, Message, Metadata, typed +// The failure envelope — ErrorDetail{Domain, Message, Metadata, typed // reason oneof} — was, until this corpus, built AND read ONLY in sdk/go. The // Python (mcp shim) and TS (edge) SDKs are the READ side of that contract: they // consume Connect error envelopes that carry an ErrorDetail. With no shared diff --git a/sdk/go/helpers/gen_multisig_vectors_test.go b/sdk/go/helpers/gen_multisig_vectors_test.go index b0a2b55e..734bb83f 100644 --- a/sdk/go/helpers/gen_multisig_vectors_test.go +++ b/sdk/go/helpers/gen_multisig_vectors_test.go @@ -1,6 +1,6 @@ package helpers -// Multisig forwarding-chain golden-vector emitter (ADR-020 §8). +// Multisig forwarding-chain golden-vector emitter. // // The sdk/ts and sdk/python multisig append/verify faces assert // byte-parity against this Go oracle: a chain signed by SignRequest(sig1) + diff --git a/sdk/go/helpers/gen_util_vectors_test.go b/sdk/go/helpers/gen_util_vectors_test.go index a6f7e8a4..7161f16b 100644 --- a/sdk/go/helpers/gen_util_vectors_test.go +++ b/sdk/go/helpers/gen_util_vectors_test.go @@ -1,6 +1,6 @@ package helpers -// Utility-face golden-vector emitter (ADR-020 §5). +// Utility-face golden-vector emitter. // // The sdk/ts and sdk/python DETERMINISTIC utility faces — NormalizeScopes / // ScopesSubset, CanonicalizeMoney (Parse+Format), ValidateIdempotencyKey, @@ -250,6 +250,15 @@ func buildWireConstantsVectors() []wireConstantVector { {"ProtocolVersion", ProtocolVersion}, {"RequestIDHeader", RequestIDHeader}, {"SignatureAgentHeader", SignatureAgentHeader}, + // The two signature-algorithm labels. They are here for a reason the + // others are not: ramp.admin.v1 pins each of them as a string.const on + // the evidence row's algorithm fields, so the value exists twice — once + // as the constant that WRITES it, once as the rule that ACCEPTS it — and + // nothing connected the copies. Exporting them through this file lets + // the conformance layer compare each constant against the descriptor's + // const without importing sdk/go, which it deliberately never does. + {"OfferSignatureAlgorithm", OfferSignatureAlgorithm}, + {"AcceptanceSignatureAlgorithm", AcceptanceSignatureAlgorithm}, } } diff --git a/sdk/go/helpers/gen_vectors_test.go b/sdk/go/helpers/gen_vectors_test.go index 6f6cf5b5..e2068f8f 100644 --- a/sdk/go/helpers/gen_vectors_test.go +++ b/sdk/go/helpers/gen_vectors_test.go @@ -1,6 +1,6 @@ package helpers -// Golden-vector emitter for the cross-language parity corpus (ADR-020 §8). +// Golden-vector emitter for the cross-language parity corpus. // // The sdk/ts and sdk/python L1 helpers assert byte-parity against the sdk/go // oracle for the signed-URL and RFC 9421 GET-PoP schemes. Rather than diff --git a/sdk/go/helpers/idempotency.go b/sdk/go/helpers/idempotency.go index 4bdb07b2..a0364cae 100644 --- a/sdk/go/helpers/idempotency.go +++ b/sdk/go/helpers/idempotency.go @@ -7,7 +7,7 @@ import ( "fmt" ) -// Idempotency (ADR-019 §4). idempotency_key is a required, persisted, +// Idempotency. idempotency_key is a required, persisted, // settlement-bound field on the mutating RPCs (ExecuteTransaction, ReportUsage, // DisputeTransaction): the server dedupes on it so a replay returns the original // result and cannot double-charge. The SDK mints a fresh key per call by default; diff --git a/sdk/go/helpers/keyresolver.go b/sdk/go/helpers/keyresolver.go index 38a9e7d4..22855559 100644 --- a/sdk/go/helpers/keyresolver.go +++ b/sdk/go/helpers/keyresolver.go @@ -9,7 +9,7 @@ import ( "sync" ) -// KeyResolver is the injection point for verifying-key lookup (ADR-020 §4). The +// KeyResolver is the injection point for verifying-key lookup. The // pure Verifier takes a key directly; the resolver is how an application supplies // keys — from a well-known endpoint, a private registry, a preloaded set, a // proxy, or mTLS. It is ONE interface for both faces: the client verifying offers diff --git a/sdk/go/helpers/library_adoption_guard_test.go b/sdk/go/helpers/library_adoption_guard_test.go index 2372fce7..8c265a39 100644 --- a/sdk/go/helpers/library_adoption_guard_test.go +++ b/sdk/go/helpers/library_adoption_guard_test.go @@ -1,7 +1,7 @@ package helpers_test // Structural guard for the SDK-quality library-adoption refactor -// (partial-adoption decision under ADR-020 L1). This is a behavior-PRESERVING +// (the partial-adoption decision for L1). This is a behavior-PRESERVING // upgrade whose Core Invariant is BYTE-PARITY, so there is no new // runtime-observable behavior to pin — the shared-vector tests // (thumbprint-vectors.json, verify_test.go, keyresolver_test.go, the @@ -26,7 +26,7 @@ package helpers_test // // DELIBERATELY OUT OF SCOPE — the retained hand-built signature BASE builder in // sigbase.go (buildSignatureBase/renderComponent/renderParamsTail/ -// reconstructTargetURI) and sign.go: per ADR-020 L1 the base is the cross-language +// reconstructTargetURI) and sign.go: at L1 the base is the cross-language // BYTE CONTRACT (injectable clock, pluggable Signer), NOT a reinvented crypto // primitive. This guard MUST NOT flag those sites, and it does not read them. @@ -85,7 +85,7 @@ func TestLibraryAdoptionGuard_ThumbprintUsesGoJose(t *testing.T) { // Signature-Input / Signature dictionaries via dunglas/httpsfv. // // It deliberately does NOT touch sigbase.go's retained signature-BASE builder: -// buildSignatureBase and its helpers stay hand-built (ADR-020 L1 byte contract). +// buildSignatureBase and its helpers stay hand-built (the L1 byte contract). func TestLibraryAdoptionGuard_VerifyUsesHTTPSFVParser(t *testing.T) { assertSite(t, siteGuard{ file: "verify.go", diff --git a/sdk/go/helpers/offer.go b/sdk/go/helpers/offer.go index f3b81b8b..c162c0d4 100644 --- a/sdk/go/helpers/offer.go +++ b/sdk/go/helpers/offer.go @@ -10,15 +10,16 @@ import ( "google.golang.org/protobuf/proto" ) -// Offer authenticity (ADR-020 §4, ramp.proto Offer.signature). An Exchange signs +// Offer authenticity (ramp.proto Offer.signature). An Exchange signs // the canonical serialization of an Offer; an agent SHOULD verify it before // selecting or executing. Until this SDK no client verified received offers — a // malicious Broker or MITM could steer selection with doctored terms that only // fail later at execute. VerifyOffer is the building block the {verified, // rejected} split (the L2 Discover/Resolve surface) is built on. -// OfferSignatureAlgorithm is the JWS alg advertised on signed offers -// (Offer.signature_algorithm). Always EdDSA for Ed25519. +// OfferSignatureAlgorithm is the JOSE algorithm identifier advertised on signed +// offers (Offer.signature_algorithm). Always EdDSA for Ed25519. The name is +// borrowed from JOSE; the signature itself is detached hex, not a JWS. const OfferSignatureAlgorithm = "EdDSA" // ErrOfferSignatureInvalid signals offer verification failure (wrong key or a diff --git a/sdk/go/helpers/pop.go b/sdk/go/helpers/pop.go index bb789ba5..52fca9f8 100644 --- a/sdk/go/helpers/pop.go +++ b/sdk/go/helpers/pop.go @@ -10,7 +10,7 @@ import ( "strings" ) -// Agent-binding proof of possession for signed delivery URLs (ADR-013). +// Agent-binding proof of possession for signed delivery URLs. // // When a signed URL carries an agent_id — the RFC 7638 thumbprint of the key // that signed the offer acceptance — a code-capable edge requires the fetcher to @@ -29,8 +29,8 @@ import ( // testdata/pop-vectors.json. // AgentKeyHeader carries the raw Ed25519 public key the fetcher presents, as -// base64url with no padding. ADR-013 chose a dedicated header over an inline JWK -// in keyid: the edge hashes this value and requires the digest to equal the +// base64url with no padding. RAMP uses a dedicated header rather than an inline +// JWK in keyid: the edge hashes this value and requires the digest to equal the // URL's agent_id, so a fetcher cannot present one key while naming another. const AgentKeyHeader = "X-RAMP-Agent-Key" diff --git a/sdk/go/helpers/scopes.go b/sdk/go/helpers/scopes.go index 5d5b42be..42d2098c 100644 --- a/sdk/go/helpers/scopes.go +++ b/sdk/go/helpers/scopes.go @@ -6,7 +6,7 @@ import ( rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" ) -// Scopes / entitlements (ADR-020 §5, ramp-sdk-api.md "Scopes / entitlements"). +// Scopes / entitlements. // The subscriptions/entitlements a requester holds are a SUPPLIED credential: // the application hands the SDK what it holds and the SDK plumbs it into the // request (Requester.scopes / Delegation.scopes). The caller never constructs an diff --git a/sdk/go/helpers/sigbase.go b/sdk/go/helpers/sigbase.go index 5319cf10..d1458ddc 100644 --- a/sdk/go/helpers/sigbase.go +++ b/sdk/go/helpers/sigbase.go @@ -13,7 +13,7 @@ import ( // signer (sign.go) and the verifier (verify.go). The base is the exact byte // string both sides feed to the crypto: keeping it in one place is what makes // sign→verify round-trip and what keeps this SDK byte-identical with the -// service-internal implementation it relocates (ADR-020 §8). +// service-internal implementation it relocates. // // Coverage is the RAMP-required set: @method and @target-uri (bind the verb and // destination so a signature cannot be replayed against another path), diff --git a/sdk/go/helpers/sign.go b/sdk/go/helpers/sign.go index af8be10b..32d6707d 100644 --- a/sdk/go/helpers/sign.go +++ b/sdk/go/helpers/sign.go @@ -25,8 +25,7 @@ const BrokerKeyIDPrefix = "broker." // builds the signature base (covered components + parameters); the Signer signs // exactly those bytes. Splitting it this way means a KMS/HSM/remote signer // satisfies the same interface and the SDK never sees the private key — custody -// stays with the application (ADR-020 §3, ramp-sdk-api.md "The core abstraction: -// Signer"). +// stays with the application. type Signer interface { // KeyID is the RFC 9421 keyid the verifier resolves a public key for. KeyID() string diff --git a/sdk/go/helpers/signedurl.go b/sdk/go/helpers/signedurl.go index 1ffaf27f..3026ca0a 100644 --- a/sdk/go/helpers/signedurl.go +++ b/sdk/go/helpers/signedurl.go @@ -13,7 +13,7 @@ import ( "time" ) -// Ed25519 signed delivery URLs (ADR-013). The Exchange issues a URL signed over +// Ed25519 signed delivery URLs. The Exchange issues a URL signed over // the canonical message "GET\n"; the edge worker (src/edge/src/verify.ts) // and this SDK verify it identically. The signature covers the URL as OPAQUE // BYTES: neither signer nor verifier re-normalizes diff --git a/sdk/go/helpers/testdata/wire-constants-vectors.json b/sdk/go/helpers/testdata/wire-constants-vectors.json index 51479686..2584f742 100644 --- a/sdk/go/helpers/testdata/wire-constants-vectors.json +++ b/sdk/go/helpers/testdata/wire-constants-vectors.json @@ -27,6 +27,14 @@ { "name": "SignatureAgentHeader", "value": "Signature-Agent" + }, + { + "name": "OfferSignatureAlgorithm", + "value": "EdDSA" + }, + { + "name": "AcceptanceSignatureAlgorithm", + "value": "EdDSA" } ] } diff --git a/sdk/go/helpers/thumbprint.go b/sdk/go/helpers/thumbprint.go index a3c80336..c23f2981 100644 --- a/sdk/go/helpers/thumbprint.go +++ b/sdk/go/helpers/thumbprint.go @@ -11,7 +11,7 @@ import ( // RFC 7638 JWK Thumbprint of an Ed25519 public key, base64url-no-pad encoded. // -// The thumbprint is the RAMP agent-identity value (ADR-009 D5, ADR-013 D4): the +// The thumbprint is the RAMP agent-identity value: the // Exchange embeds it in signed delivery URLs as the agent_id parameter, echoes // it on TransactionResponse.agent_identity_hash, and a capable delivery edge // recomputes it from the fetcher's presented key to enforce the binding. @@ -25,7 +25,7 @@ import ( // // This implementation MUST stay byte-identical to the TS edge and Python shim // implementations; a divergence rejects every bound fetch. All three are pinned -// to the shared vectors in testdata/thumbprint-vectors.json (ADR-013 D4). +// to the shared vectors in testdata/thumbprint-vectors.json. // ErrInvalidKeyLength is returned when the supplied public key is not exactly // ed25519.PublicKeySize (32) bytes. @@ -44,7 +44,7 @@ func Thumbprint(pub ed25519.PublicKey) (string, error) { // ThumbprintBytes returns the raw 32-byte SHA-256 digest underlying the // thumbprint. The digest is what the transaction_log.agent_identity_hash BYTEA -// column stores (ADR-013 17.6); base64url-no-pad of it is the wire form. +// column stores; base64url-no-pad of it is the wire form. func ThumbprintBytes(pub ed25519.PublicKey) ([32]byte, error) { if len(pub) != ed25519.PublicKeySize { return [32]byte{}, fmt.Errorf("%w: got %d", ErrInvalidKeyLength, len(pub)) diff --git a/sdk/go/helpers/thumbprint_test.go b/sdk/go/helpers/thumbprint_test.go index a4ae5800..7fe59f62 100644 --- a/sdk/go/helpers/thumbprint_test.go +++ b/sdk/go/helpers/thumbprint_test.go @@ -12,7 +12,7 @@ import ( ) // sharedVectors is the cross-language fixture the Go, TS, and Python thumbprint -// implementations are all pinned to (ADR-013 D4). It is the same vector set used +// implementations are all pinned to. It is the same vector set used // by the service-internal implementations this L1 helper relocates. const sharedVectors = "testdata/thumbprint-vectors.json" diff --git a/sdk/go/helpers/transaction_offer_validate_test.go b/sdk/go/helpers/transaction_offer_validate_test.go index 1377d9a5..6cf0afc0 100644 --- a/sdk/go/helpers/transaction_offer_validate_test.go +++ b/sdk/go/helpers/transaction_offer_validate_test.go @@ -1,6 +1,7 @@ package helpers_test import ( + "strings" "testing" rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" @@ -25,15 +26,17 @@ import ( // does not by itself pass the case — the rejection (err != nil) is load-bearing. const minItemsRuleID = "repeated.min_items" -// validOffer returns a minimal Offer that passes protovalidate. While the Offer -// message itself carries no field-level buf.validate rules, its nested Pricing -// does (per_unit requires unit; free requires rate 0), so the embedded Pricing -// must itself be valid. FREE/0 is the simplest valid pricing and matches the -// corpusgen Offer seed. (sampleOffer() in offer_test.go is shaped for SIGNATURE -// tests — PER_UNIT without a unit — which protovalidate rejects.) -// Exchange is presence-enforced: it is the execute-routing target, and a -// TransactionRequest's audience statement is per item, so an offer without it is -// unroutable and does not validate. +// validOffer returns a minimal Offer that passes protovalidate. Three things +// have to hold at once, and each has a different source. The nested Pricing +// carries its own rules (per_unit requires unit; free requires rate 0), so the +// embedded Pricing must itself be valid — FREE/0 is the simplest valid pricing +// and matches the corpusgen Offer seed. (sampleOffer() in offer_test.go is +// shaped for SIGNATURE tests — PER_UNIT without a unit — which protovalidate +// rejects.) Exchange is presence-enforced: it is the execute-routing target, +// and a TransactionRequest's audience statement is per item, so an offer +// without it is unroutable. Signature carries a 128-character hex pattern, so +// an unsigned offer no longer validates at all; the value below is filler in +// that shape, not a signature over anything. func validOffer() *rampv1.Offer { return &rampv1.Offer{ OfferId: "of_valid_1", @@ -42,6 +45,7 @@ func validOffer() *rampv1.Offer { Model: rampv1.PricingModel_PRICING_MODEL_FREE, Rate: "0", }, + Signature: strings.Repeat("ab", 64), } } diff --git a/sdk/go/helpers/validate.go b/sdk/go/helpers/validate.go index 3be3d787..e626d378 100644 --- a/sdk/go/helpers/validate.go +++ b/sdk/go/helpers/validate.go @@ -7,7 +7,7 @@ import ( "google.golang.org/protobuf/proto" ) -// Validation (ADR-014 / ADR-019, ramp-sdk-api.md "Wire-encoding reality"). The +// Validation. The // SDK validates messages against the proto's protovalidate rules — including the // cross-field message-level CEL (FREE⇒rate 0, REFERENCE_ONLY⇒uri, PER_UNIT⇒unit, // one-restriction-per-kind, …) — so a client catches a violation before the diff --git a/sdk/go/helpers/verify.go b/sdk/go/helpers/verify.go index 06da50a9..8ad4c23c 100644 --- a/sdk/go/helpers/verify.go +++ b/sdk/go/helpers/verify.go @@ -19,7 +19,7 @@ import ( // performs NO IO — key resolution is the caller's (the injected KeyResolver, // keyresolver.go) and the body is supplied, not read off req.Body — so it is the // same logic the server interceptor, the edge, and a client verifying what it -// received all share (ADR-020 §4). +// received all share. // Verifier-side error sentinels. var ( @@ -328,7 +328,7 @@ func verifyContentDigest(h http.Header, body []byte, covered []CoveredComponent) // Signature-Input and Signature dictionaries, so quoting, inner-list, integer, // and byte-sequence edge cases are the library's contract, not this package's. // The signature BASE construction stays hand-built (sigbase.go) — it is the -// cross-language byte contract (ADR-020 §8), a distinct concern from parsing. +// cross-language byte contract, a distinct concern from parsing. // parseAllSignatures extracts ALL signature labels from the Signature-Input and // Signature headers, returning one sigParams per label (in header order) and a diff --git a/sdk/go/resolvers/contentfetch.go b/sdk/go/resolvers/contentfetch.go index 44e62c9e..61e0b567 100644 --- a/sdk/go/resolvers/contentfetch.go +++ b/sdk/go/resolvers/contentfetch.go @@ -92,6 +92,14 @@ type ContentFetchOptions struct { // point of the header is that each carries its own id. It is `func() string` // rather than the transport tier's named RequestIDFunc so this package needs no // dependency on that tier for one alias; a named type is assignable here. + // + // IT MUST RETURN A VALUE THE ADMIN PLANE CAN PERSIST — 1 to 255 printable ASCII + // characters. This tier does not check, because the check lives one tier up + // with the wire rule it comes from. A trace id reused as a correlation id is + // exactly the value that fails it, and a receiving Exchange replaces what it + // cannot store, so a bad value here does not error: it silently decorrelates + // this leg from the RPC legs. Clients built by sdk/go/connect are already safe + // — their mint comes from core.MintRequestID. Wrap your own the same way. RequestID func() string } diff --git a/sdk/parity/symbol-map.json b/sdk/parity/symbol-map.json index 10f7ea84..245b42e0 100644 --- a/sdk/parity/symbol-map.json +++ b/sdk/parity/symbol-map.json @@ -53,13 +53,17 @@ "connectserver.WithValidation": "Go functional-option builder; py/ts pass options via kwargs/options objects.", "connectserver.WithVerifyGate": "Go functional-option builder; py/ts pass options via kwargs/options objects.", "connectserver.WithoutReplayStore": "Go functional-option builder; py/ts pass options via kwargs/options objects.", - "core.DefaultRequestID": "Go default request-id minter; py/ts mint request-ids inline.", + "core.DefaultRequestID": "Go default request-id minter. Go-only because py/ts mint nothing: neither SDK sets X-Request-ID on any request — both export the RequestIDHeader constant and nothing more. That is a real parity gap, tracked separately, not a difference in API shape.", "core.DiscoveryResult": "Go per-URI discovery result carrying the fail-closed split plus the typed absence reasons; py/ts gain the same shape with their client verbs.", "core.ErrOfferExpired": "Go errors.Is sentinel; py/ts express verification failures via typed failure unions / exception classes, not per-reason named sentinels.", + "core.MintRequestID": "Go wrapper that makes a caller-supplied RequestIDFunc always return a value the admin plane can persist, and supplies the default when it is nil. Go-only because there is no py/ts mint to wrap: neither SDK sets X-Request-ID on any request — both export the RequestIDHeader constant and nothing more — so every RPC from a py/ts client arrives with no correlation id and the Exchange mints one. That is a real parity gap, tracked separately; this entry documents why the SYMBOL is Go-only, not that the behaviour is at parity. It exists as one exported function because Go has three stamping sites (the RPC interceptor, the delivery fetch, and the server middleware) and the check is a property of the mint, not of any one site.", "core.OfferGroupResult": "Go per-URI group within a discovery result; py/ts gain the same shape with their client verbs.", - "core.RequestIDFunc": "Go request-id function type; py/ts pass a callable inline.", + "core.RequestID": "Go-only, and tied to core.RequestIDMiddleware which is already excluded. Python (ramp_sdk.server_verify) and TypeScript (core/verify-request.ts) DO have server-verify faces, but neither carries a request-id seam: they verify RFC 9421 signatures and return a verdict, and never read, mint, or stamp a correlation id. ValidRequestID is the wire rule on RequestCorrelation.request_id; RequestID and RequestIDFromContext carry the settled value and its provenance to a handler. These move to 'symbols' if and when the py/ts server faces gain a request-id seam — not merely when a server face exists, which it already does.", + "core.RequestIDFromContext": "Go-only, and tied to core.RequestIDMiddleware which is already excluded. Python (ramp_sdk.server_verify) and TypeScript (core/verify-request.ts) DO have server-verify faces, but neither carries a request-id seam: they verify RFC 9421 signatures and return a verdict, and never read, mint, or stamp a correlation id. ValidRequestID is the wire rule on RequestCorrelation.request_id; RequestID and RequestIDFromContext carry the settled value and its provenance to a handler. These move to 'symbols' if and when the py/ts server faces gain a request-id seam — not merely when a server face exists, which it already does.", + "core.RequestIDFunc": "Go request-id function type. Go-only because there is nothing in py/ts to pass one to: neither SDK mints or stamps a correlation id at all. That is a real parity gap, tracked separately, not a difference in API shape.", "core.RequestIDMiddleware": "Go-only request-id middleware (matrix SERVER-role request-id row: TS/Py absent).", "core.SigningOption": "Go functional-option type for the signing transport; py/ts pass options objects.", + "core.ValidRequestID": "Go-only, and tied to core.RequestIDMiddleware which is already excluded. Python (ramp_sdk.server_verify) and TypeScript (core/verify-request.ts) DO have server-verify faces, but neither carries a request-id seam: they verify RFC 9421 signatures and return a verdict, and never read, mint, or stamp a correlation id. ValidRequestID is the wire rule on RequestCorrelation.request_id; RequestID and RequestIDFromContext carry the settled value and its provenance to a handler. These move to 'symbols' if and when the py/ts server faces gain a request-id seam — not merely when a server face exists, which it already does.", "core.WithAppendSigner": "Go functional-option builder; py/ts pass options via kwargs/options objects.", "core.WithSignPredicate": "Go functional-option builder; py/ts pass options via kwargs/options objects.", "core.WithSignatureAgent": "Go functional-option builder; py/ts pass options via kwargs/options objects.", diff --git a/sdk/python/ramp_sdk/__init__.py b/sdk/python/ramp_sdk/__init__.py index bb228e50..3ac7875b 100644 --- a/sdk/python/ramp_sdk/__init__.py +++ b/sdk/python/ramp_sdk/__init__.py @@ -1,10 +1,10 @@ -"""RAMP L1 protocol-mechanics helpers (ADR-020 tree sdk/{go,ts,python}/). +"""RAMP L1 protocol-mechanics helpers (the sdk/{go,ts,python}/ tree). Stateless, IO-free protocol mechanics — RFC 7638 thumbprint, Ed25519 signed-URL verify, RFC 9421 request sign/verify + GET proof-of-possession (both faces), offer-acceptance sign/verify, base64url codec, and cross-field (message-CEL) validation — byte-parity-guarded against the sdk/go oracle. Clock and key -resolution are injected (ADR-020 §1/§4); the helpers hold no state and touch no +resolution are injected; the helpers hold no state and touch no IO. Acceptance canonicalization is JCS (RFC 8785): the canonical names are the diff --git a/sdk/python/ramp_sdk/core.py b/sdk/python/ramp_sdk/core.py index 7f12179a..27cb7bdd 100644 --- a/sdk/python/ramp_sdk/core.py +++ b/sdk/python/ramp_sdk/core.py @@ -8,8 +8,7 @@ It carries three things: * the offer Verifier that splits received offers into {verified, rejected} by - ed25519-verifying the canonical offer signature. Per the JCS switch (ADR-020 - §4) the signed payload is RFC 8785 JCS over the canonical proto-JSON of the + ed25519-verifying the canonical offer signature. Since the JCS switch the signed payload is RFC 8785 JCS over the canonical proto-JSON of the offer with signature/signature_algorithm cleared:: signed_payload = JCS(protojson(offer with sig+alg cleared)) @@ -46,8 +45,10 @@ Ed25519PublicKey, ) -# OFFER_SIGNATURE_ALGORITHM / ACCEPTANCE_SIGNATURE_ALGORITHM — the JWS alg advertised -# on signed offers/acceptances. Always EdDSA for Ed25519 (mirror the Go constants). +# OFFER_SIGNATURE_ALGORITHM / ACCEPTANCE_SIGNATURE_ALGORITHM — the JOSE algorithm +# identifier advertised on signed offers/acceptances. Always EdDSA for Ed25519 +# (mirror the Go constants). The name is borrowed from JOSE; the signature itself +# is detached hex, not a JWS. OFFER_SIGNATURE_ALGORITHM = "EdDSA" ACCEPTANCE_SIGNATURE_ALGORITHM = "EdDSA" diff --git a/sdk/python/ramp_sdk/errordetail.py b/sdk/python/ramp_sdk/errordetail.py index c422627e..5d9471a3 100644 --- a/sdk/python/ramp_sdk/errordetail.py +++ b/sdk/python/ramp_sdk/errordetail.py @@ -1,4 +1,4 @@ -"""ADR-019 ErrorDetail reader + typed detail builders (both halves of the contract). +"""ErrorDetail reader + typed detail builders (both halves of the contract). RAMP's failure envelope is a typed ``ErrorDetail`` attached to the transport error: the Connect/gRPC ``Code`` is the coarse class, the ``ErrorDetail`` oneof diff --git a/sdk/python/ramp_sdk/httpsig.py b/sdk/python/ramp_sdk/httpsig.py index 1ea90eaa..9c6deed3 100644 --- a/sdk/python/ramp_sdk/httpsig.py +++ b/sdk/python/ramp_sdk/httpsig.py @@ -11,7 +11,7 @@ signature-agent`` (no conditional biscuit component). Signature-Agent joined the required set with the WBA identity split: every signature commits to the signer's key-directory URL, empty included — this supersedes the earlier -four-component pin. L1 purity (ADR-020 §1/§4): ``created``/``expires`` are +four-component pin. L1 purity: ``created``/``expires`` are INJECTED — sign reads no wall clock. The ``Signature`` value is STANDARD base64 (``sig1=::``), NOT b64url-nopad — the two encodings are not unified. @@ -35,7 +35,7 @@ from .multisig_parse import max_sig_label_n, signature_bytes_by_label -# RAMP coverage set (ADR-001 §2.1) for the originating sig1 — MUST match the +# RAMP coverage set for the originating sig1 — MUST match the # Broker/Exchange verifiers' required base components and the Go oracle's # requiredCoveredComponents. _COVERED_COMPONENTS: tuple[str, ...] = ( diff --git a/sdk/python/ramp_sdk/idempotency.py b/sdk/python/ramp_sdk/idempotency.py index 0565004b..cadf77f3 100644 --- a/sdk/python/ramp_sdk/idempotency.py +++ b/sdk/python/ramp_sdk/idempotency.py @@ -1,4 +1,4 @@ -"""Idempotency (ADR-019 §4) — Python port of the sdk/go oracle +"""Idempotency — Python port of the sdk/go oracle (helpers/idempotency.go). idempotency_key is a required, persisted, settlement-bound field on the mutating RPCs: the server dedupes on it so a replay returns the original result and cannot double-charge. The SDK mints a fresh key diff --git a/sdk/python/ramp_sdk/keyresolver.py b/sdk/python/ramp_sdk/keyresolver.py index d0e45e27..74895f65 100644 --- a/sdk/python/ramp_sdk/keyresolver.py +++ b/sdk/python/ramp_sdk/keyresolver.py @@ -1,4 +1,4 @@ -"""Verifying-key resolution seam (ADR-020 §4) — the injection point for key lookup. +"""Verifying-key resolution seam — the injection point for key lookup. Mirrors the sdk/go split (sdk/go/helpers/keyresolver.go): the pure L1 verify takes a resolved key directly; the resolver is how an application supplies keys — from a diff --git a/sdk/python/ramp_sdk/money.py b/sdk/python/ramp_sdk/money.py index c59e50a0..271630f9 100644 --- a/sdk/python/ramp_sdk/money.py +++ b/sdk/python/ramp_sdk/money.py @@ -1,4 +1,4 @@ -"""Money (ADR-020) — Python port of the sdk/go oracle (helpers/money.go). +"""Money — Python port of the sdk/go oracle (helpers/money.go). RAMP money fields (Pricing.rate, Cost.amount, *.unit_cost) are exact decimal strings — never floats — constrained by protovalidate to the wire pattern below: diff --git a/sdk/python/ramp_sdk/pop.py b/sdk/python/ramp_sdk/pop.py index 455b602f..1cf42ac1 100644 --- a/sdk/python/ramp_sdk/pop.py +++ b/sdk/python/ramp_sdk/pop.py @@ -1,4 +1,4 @@ -"""RFC 9421 GET proof-of-possession verification (ADR-013) — pure L1 helper. +"""RFC 9421 GET proof-of-possession verification — pure L1 helper. Mirrors the sdk/ts sibling (sdk/ts/src/pop.ts ``verifyAgentBinding``). When a signed URL carries an ``agent_id`` (the agent's RFC 7638 thumbprint), a @@ -168,7 +168,7 @@ def sign_agent_binding( Returns ``(presented_key_b64url, signature_input_value, signature_value)``: the ``X-RAMP-Agent-Key`` value, the ``Signature-Input`` header value, and the ``Signature`` header value the fetcher attaches to its GET. The covered set is - exactly ``@method @target-uri`` (ADR-013), keyid is the RFC 7638 thumbprint of + exactly ``@method @target-uri``, keyid is the RFC 7638 thumbprint of the signer's public key (the 3-way identity anchor), and the signed bytes are the same :func:`signature_base` the verify face reconstructs — byte-identical to the sdk/go signer (pinned by pop-vectors.json). ``created``/``expires`` are diff --git a/sdk/python/ramp_sdk/resolvers/__init__.py b/sdk/python/ramp_sdk/resolvers/__init__.py index 0965ed07..7ddd67f5 100644 --- a/sdk/python/ramp_sdk/resolvers/__init__.py +++ b/sdk/python/ramp_sdk/resolvers/__init__.py @@ -1,4 +1,4 @@ -"""RAMP SDK fetching resolver faces (ADR-020 §4). +"""RAMP SDK fetching resolver faces. These are the FIRST IO in the Python SDK, so they live OUTSIDE ``ramp_sdk.core`` (which keeps its httpx-ban / IO-free guard green) in this dedicated package. Three diff --git a/sdk/python/ramp_sdk/scopes.py b/sdk/python/ramp_sdk/scopes.py index 9ca920c6..c412f329 100644 --- a/sdk/python/ramp_sdk/scopes.py +++ b/sdk/python/ramp_sdk/scopes.py @@ -1,4 +1,4 @@ -"""Scopes / entitlements (ADR-020 §5) — Python port of the sdk/go oracle +"""Scopes / entitlements — Python port of the sdk/go oracle (helpers/scopes.go). The subscriptions/entitlements a requester holds are a SUPPLIED credential: the application hands the SDK what it holds and the SDK plumbs it into the request. ``normalize_scopes``/``scopes_subset`` are pure, diff --git a/sdk/python/ramp_sdk/server_verify.py b/sdk/python/ramp_sdk/server_verify.py index bd5f6779..7d8ceec0 100644 --- a/sdk/python/ramp_sdk/server_verify.py +++ b/sdk/python/ramp_sdk/server_verify.py @@ -1,4 +1,4 @@ -"""Framework-agnostic RFC 9421 single-signature SERVER-verify face (ADR-020 §4). +"""Framework-agnostic RFC 9421 single-signature SERVER-verify face. The Python sibling of sdk/go/connectserver's single-sig verify path. Where ``httpsig.verify_request`` is the pure primitive (already-resolved key, explicit diff --git a/sdk/python/ramp_sdk/signedurl.py b/sdk/python/ramp_sdk/signedurl.py index 4964fc53..ef1fa4fe 100644 --- a/sdk/python/ramp_sdk/signedurl.py +++ b/sdk/python/ramp_sdk/signedurl.py @@ -1,9 +1,9 @@ -"""Ed25519 signed delivery-URL verification (ADR-013) — pure, IO-free L1 helper. +"""Ed25519 signed delivery-URL verification — pure, IO-free L1 helper. Mirrors the sdk/ts sibling (sdk/ts/src/verify.ts ``verifyEd25519SignedUrl``) and the sdk/go oracle (sdk/go/helpers/signedurl.go ``VerifyURLEd25519``). Key resolution is INJECTED (``resolve_key``) so no IO/state lives in the SDK — the -ADR-020 §4 KeyResolver split; ``now`` is INJECTED so the verify reads no wall +KeyResolver split; ``now`` is INJECTED so the verify reads no wall clock. Byte contract: the signature covers ``"GET\\n"`` diff --git a/sdk/python/ramp_sdk/thumbprint.py b/sdk/python/ramp_sdk/thumbprint.py index 93fd2ee1..d6c0cb93 100644 --- a/sdk/python/ramp_sdk/thumbprint.py +++ b/sdk/python/ramp_sdk/thumbprint.py @@ -1,4 +1,4 @@ -"""RFC 7638 JWK Thumbprint of an Ed25519 public key (ADR-013 D4). +"""RFC 7638 JWK Thumbprint of an Ed25519 public key. Relocated verbatim from the app MCP shim (src/mcp/src/ramp_mcp_shim/thumbprint.py). The agent's RFC 9421 ``keyid`` on a bound retrieval GET is this thumbprint (Web diff --git a/sdk/python/ramp_sdk/wire.py b/sdk/python/ramp_sdk/wire.py index 0488838f..52d39218 100644 --- a/sdk/python/ramp_sdk/wire.py +++ b/sdk/python/ramp_sdk/wire.py @@ -1,6 +1,6 @@ """Wire constants shared across the SDK — Python port of the sdk/go oracle (helpers/constants.go + core/requestid.go). Encoding is negotiated per hop via -Content-Type (ADR-020): application/proto for binary, application/json for +Content-Type: application/proto for binary, application/json for canonical proto-JSON. The Go layer splits RequestIDHeader across helpers/constants.go and core/requestid.go; the single Python module exposes all seven values once. Pinned to wire-constants-vectors.json. diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py index c7c2b0a5..52aa29c4 100644 --- a/sdk/python/tests/conftest.py +++ b/sdk/python/tests/conftest.py @@ -27,6 +27,10 @@ # helpers). GO_TESTDATA stays the L1 crypto-vector home; this is its L2 sibling. GO_RESOLVERS_TESTDATA = _REPO_ROOT / "sdk" / "go" / "resolvers" / "testdata" CONFORMANCE_CORPUS = _REPO_ROOT / "conformance" / "corpus" +# The Go->Python/TS symbol mapping the API-surface parity gate already +# maintains. Suites that need the local name of a Go symbol read it from here +# instead of keeping a private copy, so the two cannot disagree. +SYMBOL_MAP = _REPO_ROOT / "sdk" / "parity" / "symbol-map.json" def load_json(path: pathlib.Path) -> Any: diff --git a/sdk/python/tests/test_client_binding_smoke.py b/sdk/python/tests/test_client_binding_smoke.py index 740862ac..cb84a380 100644 --- a/sdk/python/tests/test_client_binding_smoke.py +++ b/sdk/python/tests/test_client_binding_smoke.py @@ -2,7 +2,7 @@ MCP shim (src/mcp) is a CLIENT: httpsig.SigningTransport auto-signs every outbound httpx POST to the Broker (RFC 9421), receives offers, and today does NOT verify -their signatures — the exact gap ADR-020 §4 names. This is the ONLY in-repo Python +their signatures — the exact gap this binding closes. This is the ONLY in-repo Python consumer this binding is built for. The smoke drives MCP's real shape through the opt-in sdk/python httpx client binding (a SigningTransport analogue = the Go RoundTripper analogue over core's sign seam) AND the core Verifier: diff --git a/sdk/python/tests/test_errordetail_parity.py b/sdk/python/tests/test_errordetail_parity.py index 86ca4c39..348625a7 100644 --- a/sdk/python/tests/test_errordetail_parity.py +++ b/sdk/python/tests/test_errordetail_parity.py @@ -3,7 +3,7 @@ Mirrors the sdk/ts sibling sdk/ts/tests/errordetail.parity.test.ts and the Go leg sdk/go/helpers/errordetail_corpus_test.go. -The ADR-019 failure envelope — ErrorDetail{domain, message, metadata, typed reason +The failure envelope — ErrorDetail{domain, message, metadata, typed reason oneof} — was, until the shared corpus, built AND read only in sdk/go. The mcp shim is the READ side of that contract. ``error-detail-vectors.json`` carries, per vector, the canonical proto-JSON wire form (``wire_json``) plus the field diff --git a/sdk/python/tests/test_signedurl_pop_parity.py b/sdk/python/tests/test_signedurl_pop_parity.py index d4191926..58318027 100644 --- a/sdk/python/tests/test_signedurl_pop_parity.py +++ b/sdk/python/tests/test_signedurl_pop_parity.py @@ -63,7 +63,7 @@ def test_signedurl_verify_matches_go_oracle(vector: dict[str, object]) -> None: pub = _b64url_nopad_decode(str(vector["pub_b64url"])) kid = str(vector["kid"]) - # Key resolution is INJECTED (ADR-020 §4): the resolver returns the vector's + # Key resolution is INJECTED: the resolver returns the vector's # public key for the matching kid, None otherwise. `now` is injected so the # L1 verify reads no wall clock. def resolve_key(claimed_kid: str | None) -> bytes | None: diff --git a/sdk/python/tests/test_signrequest_parity.py b/sdk/python/tests/test_signrequest_parity.py index 4341086e..44c6a4fa 100644 --- a/sdk/python/tests/test_signrequest_parity.py +++ b/sdk/python/tests/test_signrequest_parity.py @@ -30,7 +30,7 @@ Signature, and that each vector round-trips (verify at a pinned now inside the window). -L1 purity (ADR-020 §1/§4): created/expires are INJECTED — sign_request reads no +L1 purity: created/expires are INJECTED — sign_request reads no wall clock. The Signature value is STANDARD base64 (`sig1=::`), NOT b64url-nopad — do not unify with the thumbprint encoding. """ diff --git a/sdk/python/tests/test_wire_parity.py b/sdk/python/tests/test_wire_parity.py index c8a96c6c..c19d5387 100644 --- a/sdk/python/tests/test_wire_parity.py +++ b/sdk/python/tests/test_wire_parity.py @@ -2,44 +2,106 @@ Mirrors the sdk/ts sibling sdk/ts/tests/wire.parity.test.ts. -``ramp_sdk.wire`` MUST expose the seven wire constants with the EXACT values the -sdk/go oracle carries. The shared vectors at -sdk/go/helpers/testdata/wire-constants-vectors.json carry {name, value}, -referenced from the real Go exported constants (never hand-typed). The Go layer -splits RequestIDHeader across helpers/constants.go and core/requestid.go; the -single Python wire module exposes all seven once. - -RED now purely because ``ramp_sdk.wire`` does not exist yet. +Every constant in the shared vector file +sdk/go/helpers/testdata/wire-constants-vectors.json MUST be reachable from the +``ramp_sdk`` package root with the EXACT value the sdk/go oracle carries. The +vectors carry {name, value} and are referenced from the real Go exported +constants, never hand-typed. + +SCOPE IS DERIVED, NOT LISTED. The Go identifier and the Python identifier do not +always match: the wire constants keep their Go spelling, while the two signature +algorithms are ``OFFER_SIGNATURE_ALGORITHM`` and +``ACCEPTANCE_SIGNATURE_ALGORITHM`` and live in ``ramp_sdk.core``. That +translation is already maintained in sdk/parity/symbol-map.json for the +API-surface gate, so this suite reads it from there. + +An earlier version kept a private Go-name -> attribute map instead. That made +the check opt-in: a vector with no entry raised a KeyError naming the map rather +than reporting an unported constant, and two vectors did exactly that. Reading +the mapping from the shared file makes it opt-out — a new vector must be mapped +and ported, and it cannot pass by being unlisted. """ from __future__ import annotations import pytest -from conftest import GO_TESTDATA, load_json +from conftest import GO_TESTDATA, SYMBOL_MAP, load_json -# RED: sdk/python/ramp_sdk/wire.py does not exist yet (TDD red — missing face). -from ramp_sdk import wire # type: ignore[import-not-found] +import ramp_sdk +from ramp_sdk import wire _VECTORS = load_json(GO_TESTDATA / "wire-constants-vectors.json")["vectors"] -# Go identifier → the attribute the Python wire module must expose (same name). -_ATTR_FOR = { - "ContentTypeProto": "ContentTypeProto", - "ContentTypeJSON": "ContentTypeJSON", - "ConnectProtocolVersionHeader": "ConnectProtocolVersionHeader", - "ConnectProtocolVersion": "ConnectProtocolVersion", - "ProtocolVersion": "ProtocolVersion", - "RequestIDHeader": "RequestIDHeader", - "SignatureAgentHeader": "SignatureAgentHeader", -} + +def _python_names() -> dict[str, str]: + """Bare Go identifier -> the Python name symbol-map.json pins it to. + + Keys in the map are package-qualified (``helpers.ProtocolVersion``), while a + vector names the bare identifier. One identifier can appear under two + packages — the Go layer defines RequestIDHeader in both helpers and core — + so agreeing duplicates collapse to one entry and a disagreement is an error + worth failing on rather than picking a winner. + """ + resolved: dict[str, str] = {} + for qualified, entry in load_json(SYMBOL_MAP)["symbols"].items(): + bare = qualified.rsplit(".", 1)[-1] + python_name = entry.get("python") + if python_name is None: + continue + if bare in resolved and resolved[bare] != python_name: + raise AssertionError( + f"symbol-map.json maps {bare} to two different Python names: " + f"{resolved[bare]!r} and {python_name!r}" + ) + resolved[bare] = python_name + return resolved + + +_PYTHON_NAME = _python_names() def test_wire_vector_set_nonempty() -> None: assert len(_VECTORS) > 0 +@pytest.mark.parametrize("vector", _VECTORS, ids=[v["name"] for v in _VECTORS]) +def test_every_vector_is_mapped(vector: dict) -> None: + """A vector with no Python mapping is an unported constant, not a skip.""" + assert vector["name"] in _PYTHON_NAME, ( + f"{vector['name']} has a wire-constant vector but no Python symbol in " + "sdk/parity/symbol-map.json — port it and map it, or drop the vector." + ) + + @pytest.mark.parametrize("vector", _VECTORS, ids=[v["name"] for v in _VECTORS]) def test_wire_constant_matches_go_oracle(vector: dict) -> None: - attr = _ATTR_FOR[vector["name"]] - assert getattr(wire, attr) == vector["value"] + name = _PYTHON_NAME.get(vector["name"]) + # test_every_vector_is_mapped reports the unmapped case; repeat the check + # here so this test fails with a sentence instead of a raw KeyError. + assert name is not None, ( + f"{vector['name']} has no Python symbol in sdk/parity/symbol-map.json" + ) + assert hasattr(ramp_sdk, name), ( + f"{vector['name']} maps to {name}, which the ramp_sdk root does not " + "export" + ) + assert getattr(ramp_sdk, name) == vector["value"] + + +def test_wire_module_constants_match_their_vectors() -> None: + """The wire module is the home of the constants that kept their Go names. + + Derived rather than listed: every public name the module defines that also + has a vector must carry the vector's value. So the module cannot drift, and + it cannot quietly lose a constant to a rename without the vector check above + failing too. + """ + by_name = {v["name"]: v["value"] for v in _VECTORS} + checked = 0 + for attr in dir(wire): + if attr.startswith("_") or attr not in by_name: + continue + assert getattr(wire, attr) == by_name[attr] + checked += 1 + assert checked > 0, "ramp_sdk.wire exposes none of the vectored constants" diff --git a/sdk/ts/core/verifier.ts b/sdk/ts/core/verifier.ts index 9da92684..1a5be380 100644 --- a/sdk/ts/core/verifier.ts +++ b/sdk/ts/core/verifier.ts @@ -5,7 +5,7 @@ // core, never the reverse. // // The Verifier splits received offers into {verified, rejected} by ed25519- -// verifying the canonical offer signature. Per the JCS switch (ADR-020 §4), the +// verifying the canonical offer signature. Per the JCS switch, the // signed payload is RFC 8785 JCS over the canonical proto-JSON of the offer with // signature/signature_algorithm cleared: // @@ -23,8 +23,9 @@ import canonicalize from "canonicalize"; import { utf8Bytes } from "../src/base64url.ts"; -// OFFER_SIGNATURE_ALGORITHM is the JWS alg advertised on signed offers. Always -// EdDSA for Ed25519 (mirror helpers.OfferSignatureAlgorithm). +// OFFER_SIGNATURE_ALGORITHM is the JOSE algorithm identifier advertised on signed +// offers. Always EdDSA for Ed25519 (mirror helpers.OfferSignatureAlgorithm). The +// name is borrowed from JOSE; the signature itself is detached hex, not a JWS. export const OFFER_SIGNATURE_ALGORITHM = "EdDSA"; // Mode selects offer-verification strictness. "strict" (the default) is diff --git a/sdk/ts/core/verify-request.ts b/sdk/ts/core/verify-request.ts index e2051452..c801b655 100644 --- a/sdk/ts/core/verify-request.ts +++ b/sdk/ts/core/verify-request.ts @@ -37,7 +37,7 @@ import { export type RejectReason = "signature" | "replay"; /** - * The injected keyid-keyed verifying-key resolver (ADR-020 §4). Distinct from + * The injected keyid-keyed verifying-key resolver. Distinct from * core/verifier.ts::OfferKeyResolver which is EXCHANGE-keyed for offer verify: a * request-verify resolver is keyed by the Signature-Input keyid. Returns the raw * 32-byte Ed25519 public key, or undefined when the key is unknown. diff --git a/sdk/ts/resolvers/index.ts b/sdk/ts/resolvers/index.ts index 1742dab3..0cdaa490 100644 --- a/sdk/ts/resolvers/index.ts +++ b/sdk/ts/resolvers/index.ts @@ -1,4 +1,4 @@ -// Public surface of the RAMP SDK resolver faces (ADR-020 §4). These are the FIRST +// Public surface of the RAMP SDK resolver faces. These are the FIRST // IO in the TS SDK, so they live OUTSIDE the IO-free core/src tree (exported via // package.json `exports`) to keep the transport-neutrality invariant on core // green. Four faces port the Go oracle: a static named map, a well-known JWKS diff --git a/sdk/ts/src/acceptance.ts b/sdk/ts/src/acceptance.ts index da4ee194..a4243c51 100644 --- a/sdk/ts/src/acceptance.ts +++ b/sdk/ts/src/acceptance.ts @@ -21,8 +21,9 @@ import canonicalize from "canonicalize"; import { utf8Bytes } from "./base64url.ts"; -/** The JWS alg advertised on AgentAcceptance.signature. Always EdDSA for Ed25519 - * (mirror helpers.AcceptanceSignatureAlgorithm). */ +/** The JOSE algorithm identifier advertised on AgentAcceptance.signature. Always + * EdDSA for Ed25519 (mirror helpers.AcceptanceSignatureAlgorithm). The name is + * borrowed from JOSE; the signature itself is detached hex, not a JWS. */ export const ACCEPTANCE_SIGNATURE_ALGORITHM = "EdDSA"; /** The acceptance binding fields. EVERY empty field is omitted from the canonical diff --git a/sdk/ts/src/errordetail.ts b/sdk/ts/src/errordetail.ts index 67e3325e..dbb74c2e 100644 --- a/sdk/ts/src/errordetail.ts +++ b/sdk/ts/src/errordetail.ts @@ -10,7 +10,7 @@ import type { } from "../../../gen/ts/wire/schemas.ts"; import { ErrorDetailSchema } from "../../../gen/ts/wire/schemas.ts"; -// ADR-019 ErrorDetail reader + typed detail builders (both halves of the contract). +// ErrorDetail reader + typed detail builders (both halves of the contract). // // RAMP's failure envelope is a typed ErrorDetail attached to the transport error: // the Connect/gRPC Code is the coarse class, the ErrorDetail oneof carries the diff --git a/sdk/ts/src/idempotency.ts b/sdk/ts/src/idempotency.ts index c1dd4cb1..e4634af3 100644 --- a/sdk/ts/src/idempotency.ts +++ b/sdk/ts/src/idempotency.ts @@ -1,4 +1,4 @@ -// Idempotency (ADR-019 §4) — TS port of the sdk/go oracle (helpers/idempotency.go). +// Idempotency — TS port of the sdk/go oracle (helpers/idempotency.go). // idempotency_key is a required, persisted, settlement-bound field on the // mutating RPCs: the server dedupes on it so a replay returns the original // result and cannot double-charge. The SDK mints a fresh key per call by diff --git a/sdk/ts/src/money.ts b/sdk/ts/src/money.ts index 94723f5a..daa14334 100644 --- a/sdk/ts/src/money.ts +++ b/sdk/ts/src/money.ts @@ -1,4 +1,4 @@ -// Money (ADR-020) — TS port of the sdk/go oracle (helpers/money.go). RAMP money +// Money — TS port of the sdk/go oracle (helpers/money.go). RAMP money // fields (Pricing.rate, Cost.amount, *.unit_cost) are exact decimal strings — // never floats — constrained by protovalidate to the wire pattern below: // non-negative, no sign, no exponent, optional fractional part, empty string diff --git a/sdk/ts/src/pop.ts b/sdk/ts/src/pop.ts index 7aa5a60b..7828c985 100644 --- a/sdk/ts/src/pop.ts +++ b/sdk/ts/src/pop.ts @@ -2,7 +2,7 @@ import { thumbprint } from "./thumbprint.ts"; import { decodeBase64Url, utf8Bytes } from "./base64url.ts"; import { opaqueUrl } from "./opaque-url.ts"; -// Proof-of-possession verification for delivery-URL identity binding (ADR-013), +// Proof-of-possession verification for delivery-URL identity binding, // relocated from the app edge (src/edge/src/pop.ts) as a pure L1 helper. // // When a signed URL carries an `agent_id` (the agent's RFC 7638 thumbprint), a diff --git a/sdk/ts/src/scopes.ts b/sdk/ts/src/scopes.ts index d6103269..813813c3 100644 --- a/sdk/ts/src/scopes.ts +++ b/sdk/ts/src/scopes.ts @@ -1,4 +1,4 @@ -// Scopes / entitlements (ADR-020 §5) — TS port of the sdk/go oracle +// Scopes / entitlements — TS port of the sdk/go oracle // (helpers/scopes.go). The subscriptions/entitlements a requester holds are a // SUPPLIED credential: the application hands the SDK what it holds and the SDK // plumbs it into the request. NormalizeScopes/ScopesSubset are pure, byte- diff --git a/sdk/ts/src/signurl.ts b/sdk/ts/src/signurl.ts index 06277a9a..620221d1 100644 --- a/sdk/ts/src/signurl.ts +++ b/sdk/ts/src/signurl.ts @@ -1,4 +1,4 @@ -// sdk/ts Ed25519 signed delivery-URL SIGN face (ADR-013) — the Exchange-minting +// sdk/ts Ed25519 signed delivery-URL SIGN face — the Exchange-minting // sibling of src/verify.ts. Mirror of the Go oracle helpers.SignURLEd25519. // // THE CONTRACT: the signature covers "GET\n" as diff --git a/sdk/ts/src/thumbprint.ts b/sdk/ts/src/thumbprint.ts index a8ebfd2f..f5eae55a 100644 --- a/sdk/ts/src/thumbprint.ts +++ b/sdk/ts/src/thumbprint.ts @@ -4,7 +4,7 @@ const ED25519_PUBLIC_KEY_BYTES = 32; /** * Compute the RFC 7638 JWK Thumbprint of a raw 32-byte Ed25519 public key, - * base64url-no-pad encoded (ADR-013 D4). + * base64url-no-pad encoded. * * The canonical JWK is fixed by RFC 7638 §3.2 for OKP keys — * `{"crv":"Ed25519","kty":"OKP","x":""}`, members in diff --git a/sdk/ts/src/verify.ts b/sdk/ts/src/verify.ts index 6e2954ad..801234d0 100644 --- a/sdk/ts/src/verify.ts +++ b/sdk/ts/src/verify.ts @@ -3,10 +3,10 @@ import { decodeBase64Url, utf8Bytes } from "./base64url.ts"; import { opaqueUrl } from "./opaque-url.ts"; import { canonicalUrl } from "./signurl.ts"; -// Ed25519 signed delivery-URL verification (ADR-013), relocated from the app +// Ed25519 signed delivery-URL verification, relocated from the app // edge (src/edge/src/verify.ts) as a pure, IO-free L1 helper. Key resolution is -// INJECTED (VerifyDeps.resolveKey) so no IO/state lives in the SDK — the ADR-020 -// §4 KeyResolver split. Byte-parity guard: the signed-URL vectors are produced by +// INJECTED (VerifyDeps.resolveKey) so no IO/state lives in the SDK — the +// KeyResolver split. Byte-parity guard: the signed-URL vectors are produced by // the sdk/go signer (SignURLEd25519), so the "no re-sort on verify" canonical // message is fed the exact canonically-sorted string the signer emitted. diff --git a/sdk/ts/src/wire.ts b/sdk/ts/src/wire.ts index 4f26a71a..b1c6f5c9 100644 --- a/sdk/ts/src/wire.ts +++ b/sdk/ts/src/wire.ts @@ -1,6 +1,6 @@ // Wire constants shared across the SDK — TS port of the sdk/go oracle // (helpers/constants.go + core/requestid.go). Encoding is negotiated per hop via -// Content-Type (ADR-020): application/proto for binary, application/json for +// Content-Type: application/proto for binary, application/json for // canonical proto-JSON. The Go layer splits RequestIDHeader across // helpers/constants.go and core/requestid.go; the single TS module exposes all // seven values once. Pinned to wire-constants-vectors.json. diff --git a/sdk/ts/tests/errordetail.parity.test.ts b/sdk/ts/tests/errordetail.parity.test.ts index a04290b2..b0343b7c 100644 --- a/sdk/ts/tests/errordetail.parity.test.ts +++ b/sdk/ts/tests/errordetail.parity.test.ts @@ -3,7 +3,7 @@ // Mirrors the sdk/python sibling test_errordetail_parity.py and the Go leg // sdk/go/helpers/errordetail_corpus_test.go. // -// The ADR-019 failure envelope — ErrorDetail{domain, message, metadata, typed +// The failure envelope — ErrorDetail{domain, message, metadata, typed // reason oneof} — was, until the shared corpus, built AND read only in sdk/go. The // edge worker is the READ side of that contract. error-detail-vectors.json carries, // per vector, the canonical proto-JSON wire form (wire_json) plus the field diff --git a/sdk/ts/tests/multisig-base-reuse.guard.test.ts b/sdk/ts/tests/multisig-base-reuse.guard.test.ts index 989fca86..ad03773d 100644 --- a/sdk/ts/tests/multisig-base-reuse.guard.test.ts +++ b/sdk/ts/tests/multisig-base-reuse.guard.test.ts @@ -18,7 +18,7 @@ import { describe, expect, it } from "vitest"; // A new face that forks the template adds a second renderer and trips this guard. // // The 2-component GET-PoP base (src/pop.ts signatureBase) is a DISTINCT byte -// contract (ADR-013) and is intentionally NOT counted — the fingerprint below +// contract and is intentionally NOT counted — the fingerprint below // requires the content-digest + authorization + signature-agent lines that only // the 5-component request base carries, so the GET-PoP base never matches. // diff --git a/sdk/ts/tests/wire.parity.test.ts b/sdk/ts/tests/wire.parity.test.ts index 31fc2f90..85016cac 100644 --- a/sdk/ts/tests/wire.parity.test.ts +++ b/sdk/ts/tests/wire.parity.test.ts @@ -1,47 +1,85 @@ -// Wire-constants parity (TypeScript side): wire.ts exposes the seven wire -// constants with the exact values the Go oracle carries. +// Wire-constants parity (TypeScript side): the SDK exposes every constant in +// the shared vector file with the exact value the Go oracle carries. // -// sdk/ts `wire.ts` MUST expose the seven wire constants with the EXACT values the -// sdk/go oracle carries. The shared vectors at -// sdk/go/helpers/testdata/wire-constants-vectors.json carry {name, value}, -// referenced from the real Go exported constants (never hand-typed). The Go -// layer splits RequestIDHeader across helpers/constants.go and core/requestid.go; -// the single TS wire module exposes all seven once. +// The vectors at sdk/go/helpers/testdata/wire-constants-vectors.json carry +// {name, value}, referenced from the real Go exported constants and never +// hand-typed. The Python sibling is sdk/python/tests/test_wire_parity.py. // -// RED now purely because sdk/ts/src/wire.ts does not exist yet. +// SCOPE IS DERIVED, NOT LISTED. The Go identifier and the TS identifier do not +// always match: the wire constants keep their Go spelling, while the two +// signature algorithms are OFFER_SIGNATURE_ALGORITHM and +// ACCEPTANCE_SIGNATURE_ALGORITHM and live in offer-sign.ts / acceptance.ts. +// That translation is already maintained in sdk/parity/symbol-map.json for the +// API-surface gate, so this suite reads it from there. +// +// An earlier version kept a private Go-name -> export map instead. That made +// the check opt-in: a vector with no entry asserted "expected undefined to be +// defined" rather than reporting an unported constant, and two vectors did +// exactly that. Reading the mapping from the shared file makes it opt-out — a +// new vector must be mapped and ported, and cannot pass by being unlisted. import { describe, it, expect } from "vitest"; -// RED: sdk/ts/src/wire.ts does not exist yet (TDD red — missing face). import * as wire from "../src/wire.ts"; +import * as offerSign from "../src/offer-sign.ts"; +import * as acceptance from "../src/acceptance.ts"; import vectorsFile from "../../go/helpers/testdata/wire-constants-vectors.json"; +import symbolMap from "../../parity/symbol-map.json"; type WireVector = { name: string; value: string }; type WireVectorsFile = { vectors: WireVector[] }; +type SymbolMapFile = { symbols: Record }; const vectors = (vectorsFile as WireVectorsFile).vectors; -// The Go identifier → the TS export name (same PascalCase→SCREAMING or camel -// choice is the implementer's; here we assert the VALUE is present under a -// matching export). Map by Go name to the TS symbol the port must export. -const exportFor: Record = { - ContentTypeProto: "ContentTypeProto", - ContentTypeJSON: "ContentTypeJSON", - ConnectProtocolVersionHeader: "ConnectProtocolVersionHeader", - ConnectProtocolVersion: "ConnectProtocolVersion", - ProtocolVersion: "ProtocolVersion", - RequestIDHeader: "RequestIDHeader", - SignatureAgentHeader: "SignatureAgentHeader", +// The modules a vectored constant may be exported from. A constant that lands +// in a module absent here fails the lookup below rather than passing silently, +// so this list is a scope declaration, not an allow-list. +const surface: Record = { + ...(wire as Record), + ...(offerSign as Record), + ...(acceptance as Record), }; +// Bare Go identifier -> the TS name symbol-map.json pins it to. Map keys are +// package-qualified (helpers.ProtocolVersion) while a vector names the bare +// identifier, and one identifier can appear under two packages — the Go layer +// defines RequestIDHeader in both helpers and core. Agreeing duplicates +// collapse to one entry; a disagreement is left to fail loudly. +const tsName = new Map(); +for (const [qualified, entry] of Object.entries( + (symbolMap as SymbolMapFile).symbols, +)) { + const bare = qualified.split(".").pop() as string; + const name = entry.ts; + if (name === undefined || name === null) continue; + const seen = tsName.get(bare); + if (seen !== undefined && seen !== name) { + throw new Error( + `symbol-map.json maps ${bare} to two different TS names: ${seen} and ${name}`, + ); + } + tsName.set(bare, name); +} + describe("sdk/ts wire constants match the sdk/go oracle vectors", () => { it("wire-constants vector set is non-empty", () => { expect(vectors.length).toBeGreaterThan(0); }); for (const v of vectors) { - it(`wire.${v.name} === ${JSON.stringify(v.value)}`, () => { - const sym = exportFor[v.name]; - expect(sym).toBeDefined(); - expect((wire as Record)[sym as string]).toBe(v.value); + it(`${v.name} is mapped to a TS symbol`, () => { + expect( + tsName.has(v.name), + `${v.name} has a wire-constant vector but no TS symbol in sdk/parity/symbol-map.json — port it and map it, or drop the vector`, + ).toBe(true); + }); + + it(`${v.name} === ${JSON.stringify(v.value)}`, () => { + const name = tsName.get(v.name) as string; + expect( + name in surface, + `${v.name} maps to ${name}, which none of the modules this suite imports export`, + ).toBe(true); + expect(surface[name]).toBe(v.value); }); } }); diff --git a/website/proto-symbols-ignore.json b/website/proto-symbols-ignore.json index af4064e5..7854511c 100644 --- a/website/proto-symbols-ignore.json +++ b/website/proto-symbols-ignore.json @@ -1,5 +1,6 @@ { "_comment": "Proto-symbol-shaped tokens in prose that intentionally do NOT resolve against the current descriptor \u2014 references kept for historical contrast with a removed or renamed field, where the owning type still exists. The remark-proto plugin fails the build on any reference whose proto type exists but whose member is gone, UNLESS it is listed here. Entries are self-cleaning: the build fails if a listed token starts resolving again (stale). Keep this list as small as possible \u2014 a live symbol belongs in the proto, not here.", + "_comment_changelog": "The last five entries are cited by the CHANGELOG's older revisions, describing the removals themselves. They arrived here when the published changelog page became generated from proto/CHANGELOG.md: the hand-copied page had quietly dropped or reworded those sentences, so the full history had never been symbol-checked. Removing an entry here means going back and rewriting a shipped changelog entry, which is not something to do casually \u2014 a changelog records what a field WAS called.", "ignore": [ "Requester.billing_ref", "WellKnownManifest.registration_schema", @@ -16,6 +17,12 @@ "Image.alt", "Text.authority", "Retrieval.ratelmt", - "Package.license" + "Package.license", + "TransactionState.broker", + "DENIAL_REASON_ENTITLEMENT_STALE_ATTENUATION", + "Image.caption", + "Requester.license_id", + "Text.originality", + "TransactionRequest.offer_id" ] } diff --git a/website/src/content/docs/architecture/production-architecture.mdx b/website/src/content/docs/architecture/production-architecture.mdx index e12688b7..c21c5e2e 100644 --- a/website/src/content/docs/architecture/production-architecture.mdx +++ b/website/src/content/docs/architecture/production-architecture.mdx @@ -54,7 +54,7 @@ Every party in the protocol authenticates via Ed25519 key pairs. Each party anno |---|---|---|---| | **Requester** (Agent or other client) | `{domain}/.well-known/ramp.json` (`role=ROLE_AGENT`) | RFC 9421 HTTP Message Signature (Ed25519) over the HTTP request (`@method`, `@target-uri`, `content-digest` — request body bound via `content-digest`) | Exchange | | **Intermediary** (e.g. Broker) | `{domain}/.well-known/ramp.json` (`role=ROLE_AGENT`, same format) | each intermediary adds its own RFC 9421 HTTP Message Signature (Ed25519) to the request — the forwarding chain is the stack of signatures, each covering the request plus the prior hop's signature | Exchange | -| **Exchange** | `{domain}/.well-known/ramp.json` (`role=ROLE_EXCHANGE`); offer-signing keys in the WBA directory at `{domain}/.well-known/http-message-signatures-directory` | `exchange_signature` on every Offer | Agent, Broker | +| **Exchange** | `{domain}/.well-known/ramp.json` (`role=ROLE_EXCHANGE`); offer-signing keys in the WBA directory at `{domain}/.well-known/http-message-signatures-directory` | `signature` on every Offer | Agent, Broker | | **Provider** | `{domain}/.well-known/ramp.json` (`role=ROLE_PUBLISHER`) | Authorization declaration (not signatures) | Agent, Exchange | | **Verification Vendor** | WBA directory at `{domain}/.well-known/http-message-signatures-directory` | `ResourceAttestation.signature` on catalog content (v1.0) | Exchange, Agent | diff --git a/website/src/content/docs/components/agent-sdk/overview.mdx b/website/src/content/docs/components/agent-sdk/overview.mdx index 5d6b5f47..9ac1f469 100644 --- a/website/src/content/docs/components/agent-sdk/overview.mdx +++ b/website/src/content/docs/components/agent-sdk/overview.mdx @@ -358,7 +358,7 @@ RampClient | +-- TransactionExecutor | Sends ExecuteTransaction RPC to winning Exchange. - | Stateless offer verification via exchange_signature. + | Stateless offer verification via Offer.signature. | Idempotency key on every request. | +-- ContentFetcher diff --git a/website/src/content/docs/components/broker/deployment.mdx b/website/src/content/docs/components/broker/deployment.mdx index e4f98ece..b397b641 100644 --- a/website/src/content/docs/components/broker/deployment.mdx +++ b/website/src/content/docs/components/broker/deployment.mdx @@ -139,7 +139,7 @@ fanout: # Selection engine selection: policy: lowest_unit_cost # lowest_unit_cost | preferred_exchange | freshness_weighted - verify_signatures: true # Verify exchange_signature on offers + verify_signatures: true # Verify Offer.signature on offers # Budget defaults (overridden per-session by DiscoveryRequest) budget: diff --git a/website/src/content/docs/components/broker/selection-engine.mdx b/website/src/content/docs/components/broker/selection-engine.mdx index ef6fb0cc..9330fe61 100644 --- a/website/src/content/docs/components/broker/selection-engine.mdx +++ b/website/src/content/docs/components/broker/selection-engine.mdx @@ -155,7 +155,7 @@ graph LR B --> C[Filter by
RequestConstraints] C --> D[Deduplicate by
ResourceIdentity] D --> E[Rank by unit_cost
lowest wins] - E --> F[Verify
exchange_signature] + E --> F[Verify
Offer.signature] F --> G[Winner] ``` @@ -421,9 +421,9 @@ func unitCostOf(o rankedOffer) float64 { } ``` -## Stage 5: Verify exchange_signature +## Stage 5: Verify Offer.signature -The Broker SHOULD verify `exchange_signature` on the winning Offer before proceeding. This prevents an Exchange from tampering with its own offer fields after signing. +The Broker SHOULD verify the winning Offer's `signature` before proceeding. This prevents an Exchange from tampering with its own offer fields after signing. ```go func verifyOfferSignature(offer *rampv1.Offer, exchange string, pubKeys map[string]crypto.PublicKey) error { @@ -433,7 +433,8 @@ func verifyOfferSignature(offer *rampv1.Offer, exchange string, pubKeys map[stri payload := canonicalizeOffer(offer) - alg := "EdDSA" // JWS/JOSE alg for offer signatures + alg := "EdDSA" // the JOSE algorithm identifier for Ed25519; the signature + // itself is detached hex, not a JWS if offer.SignatureAlgorithm != "" { alg = offer.SignatureAlgorithm } @@ -530,10 +531,10 @@ sequenceDiagram participant Orch as Broker participant Agent as AI Agent - MP->>Orch: ResourceResponse (Offer with exchange_signature) + MP->>Orch: ResourceResponse (Offer with signature) Note over Orch: Broker CANNOT modify
offer fields without
invalidating signature Orch->>Agent: DiscoveryResponse (includes original signed Offer) - Note over Agent: Agent verifies exchange_signature
against MP's public key.
Price tampering detected. + Note over Agent: Agent verifies signature
against MP's public key.
Price tampering detected. ``` ### Fee Disclosure diff --git a/website/src/content/docs/components/edge-function/signed-url-verification.mdx b/website/src/content/docs/components/edge-function/signed-url-verification.mdx index 00067929..3cc6b8ea 100644 --- a/website/src/content/docs/components/edge-function/signed-url-verification.mdx +++ b/website/src/content/docs/components/edge-function/signed-url-verification.mdx @@ -1,132 +1,110 @@ --- title: "Signed URL Verification" -description: "HMAC-SHA256 verification, URL parameter parsing, TTL checking, agent identity binding, and timing-safe comparison" +description: "The two signing schemes, the Ed25519 canonical form, TTL checking, agent identity binding, and single-use enforcement" --- -The Edge Function verifies signed URLs to gate access to protected content. Two verification modes are available, chosen per deployment: CDN-native verification and custom HMAC verification. +A signed URL is the capability that lets an agent fetch paid content. This page defines how one is built and how a verifier checks it. -## Verification Modes +## Two schemes, chosen when the URL is minted -### Mode A: CDN-Native Verification (CloudFront, Akamai) +RAMP defines two signing schemes. Both are **asymmetric**: the Exchange holds a private key, the verifier holds only a public key or a CDN-managed key group. There is no shared secret anywhere in either scheme, and no deployment holds signing material at the edge. -The CDN platform handles signature verification at the infrastructure level, before the edge function code runs. - -```mermaid -sequenceDiagram - participant Agent as AI Agent - participant CDN as CDN Infrastructure - participant Edge as Edge Function - participant Origin as Origin Server - - Agent->>CDN: GET /premium/article.html?Signature=...&Key-Pair-Id=...&Expires=... - - Note over CDN: CDN infrastructure verifies signature
against trusted_key_groups (CloudFront)
or EdgeAuth token (Akamai) - - alt Signature invalid or expired - CDN-->>Agent: 403 Forbidden (CDN-generated) - else Signature valid - CDN->>Edge: Forward request (signature already verified) - Note over Edge: Agent identity binding check
Single-use enforcement
(if enabled) - Edge->>Origin: Forward to origin - Origin-->>Agent: 200 OK + content - end -``` - -**CloudFront implementation**: Configure a `trusted_key_groups` reference on the cache behavior for `/premium/*`. Upload the RSA public key to CloudFront Key Management. CloudFront verifies the `Signature`, `Key-Pair-Id`, and `Expires` (or canned/custom `Policy`) parameters before the request reaches any function code. +| Scheme | Signature | Verified by | Proof of possession | +|---|---|---|---| +| **Ed25519** | Ed25519 over a canonical message, `sig` parameter, base64url unpadded | Edge function code (Cloudflare Workers, Fastly Compute, Lambda@Edge) | **Yes** — the edge can require the fetcher to prove it holds the bound key | +| **CloudFront RSA** | RSA canned policy, AWS parameter format | CloudFront infrastructure, before any function code runs | **No** — native verification cannot run the check | -**Akamai implementation**: Configure EdgeAuth token verification on the property manager for protected paths. The EdgeAuth token includes a hash, expiry, and optional IP binding. Verification happens at the property level. +**The scheme is a mint-time selector. No verifier reads it.** The Exchange picks the scheme per tenant when it creates the URL; a verifier never consults it, because each scheme's URL is self-describing. That is why there is no "unknown scheme" case at fetch time: an unrecognized scheme fails at mint, before any URL exists, and the transaction fails with it. -The Edge Function does not verify the cryptographic signature in this mode. It only performs additional checks (agent binding, single-use) that the CDN cannot do natively. +**Verifiers fail closed on their own terms.** An edge function denies any URL without a valid signature and an unexpired `exp`. CloudFront verifies natively and denies on its own rules. Neither needs to know which scheme was intended. -### Mode B: Custom HMAC Verification (Cloudflare, Fastly) +That difference in proof-of-possession is the important one. It decides whether a leaked URL is usable (see [Agent Identity Binding](#agent-identity-binding)), and it decides whether a stored URL is a live capability at rest — which is why the [Exchange storage model](/components/exchange/storage-model/#storing-the-full-signed-url) treats the two schemes differently. -The edge function code verifies the signature using HMAC-SHA256. +## Ed25519: the canonical form -```mermaid -sequenceDiagram - participant Agent as AI Agent - participant Edge as Edge Function (Worker/Compute) - participant Origin as Origin Server +This is protocol, not implementation detail. A signer and a verifier that disagree by one byte reject every URL, so the form is stated exactly. - Agent->>Edge: GET /premium/article.html?sig=abc...&expires=1773451434&agent_id=&txn_id=txn-mp-93a7f2 +``` +message = "GET\n" + canonical_url + +canonical_url = prefix [ "?" query ] (no "?" when the query is empty) + +prefix = every byte before the first "?" — scheme://host/path VERBATIM. + No host, port, or path normalization of any kind. + +query = take the raw query; + split on "&", split each pair on the FIRST "="; + percent-DECODE each key and value ("+" decodes to space); + DROP the "sig" pair — before sorting, before anything else; + (exp, and kid / agent_id when present, are set here); + SORT pairs by key — stable, per-key value order preserved; + re-ENCODE each key and value: space -> "+", + A-Za-z0-9 - . _ ~ kept, every other byte -> %XX uppercase hex; + join as "k=v" with "&". + +sig = Ed25519 over the message, encoded base64url with NO padding, + then appended as the "sig" parameter through the SAME + canonicalization — so the published URL is itself the + canonical form, with sig included. +``` - Note over Edge: 1. Extract sig, expires, agent_id, txn_id from URL
2. Reconstruct base URL
3. Canonicalize: baseURL\nexpires\nagent_id\ntxn_id
4. HMAC-SHA256(canonical_string, secret)
5. Timing-safe compare expected vs provided
6. Check expiry +Three points a reader gets wrong otherwise: - alt Verification fails - Edge-->>Agent: 403 Forbidden - else Verification passes - Note over Edge: Agent identity binding check
Single-use enforcement
(if enabled) - Edge->>Origin: Forward to origin - Origin-->>Agent: 200 OK + content - end -``` +- **The prefix is verbatim.** A mixed-case host stays mixed case, an explicit `:443` stays, a space in the path stays a space, and `%2F` is not decoded. Normalizing any of it produces a different message and a failed verification. +- **`sig` is dropped before sorting**, not after. Sorting first and removing later gives the same set but a different intermediate, and an implementation that reorders around the removal will drift. +- **The query is decoded and re-encoded**, not passed through. The re-encode is idempotent on input that was already encoded this way, so a URL that round-trips unchanged is the normal case rather than a special one. -## URL Parameter Parsing +`exp` is Unix seconds and is covered by the signature, so a verifier checks the signature **before** trusting the expiry. -**HMAC signed URL format** (current implementation): +The Go, Python and TypeScript SDKs all implement this form and are pinned against one shared vector file, which fixes complete signed URLs byte for byte from a fixed key seed. The awkward cases above each have their own vector. -``` -https://cdn.provider.com/premium/article.html?expires=1773451434&sig=a7f3b2c1... -``` +## CloudFront RSA -- `expires` -- Unix timestamp (seconds since epoch). -- `sig` -- HMAC-SHA256 hex digest of `{baseURL}{expires}` using the shared secret. +The signature, the policy and the parameter format are AWS's contract, not RAMP's. Use [AWS's CloudFront signed-URL documentation](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-urls.html) as the definition; restating it here would create a second copy that drifts. -**Production enhancement** -- add delimiter and agent binding: +Two RAMP-specific facts are worth stating: -``` -https://cdn.provider.com/premium/article.html - ?expires=1773451434 - &agent_id=NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs - &txn_id=txn-mp-93a7f2 - &sig=HMAC-SHA256(baseURL\nexpires\nagent_id\ntxn_id, secret) -``` +- **`agent_id` is set on the resource URL before signing**, so the canned policy commits to it. It cannot be swapped afterwards, even though CloudFront itself does not check who presents the URL. +- **The reconciliation digest is scheme-independent.** Both schemes store SHA-256 over the verbatim signed-URL string, so a delivery join works the same way whichever scheme minted the URL. -- `agent_id` -- the agent's `agent_identity_hash` from `TransactionResponse`: the RFC 7638 JWK Thumbprint (SHA-256, base64url) of the agent's Ed25519 request-signing key. Because it is part of the HMAC input, a URL holder cannot swap the Exchange-issued value. -- `txn_id` -- transaction ID for reconciliation and single-use enforcement. -- `\n` delimiter between fields prevents canonicalization ambiguity (as noted in the threat model's HMAC implementation note). +Configure a trusted key group on the cache behaviour for the protected path and upload the RSA public key to CloudFront Key Management. CloudFront verifies before the request reaches any function code. -## HMAC Canonicalization +## Key identification -Both the Exchange (signer) and the edge function (verifier) MUST use the same canonical format for HMAC input. Fields are concatenated with `\n` delimiters in this fixed order: +**Ed25519.** The URL carries `kid`, the [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) thumbprint of the tenant's signing public key. A thumbprint rather than an opaque identifier means the verifier can confirm that the key it resolved is the key the `kid` names, without trusting a lookup table. -``` -baseURL\nexpires\nagent_id\ntxn_id -``` +A verifier resolves `kid` to a public key however its deployment allows, and **must fail closed** when it cannot: a URL whose key does not resolve is denied, and so is a URL with no `kid` at all, because nothing can be resolved for it. A resolution failure caused by *infrastructure* — a directory fetch that times out — is a different outcome from a signature that does not verify, and a verifier should keep them distinguishable so a broken dependency is not read as an attack. -Where: -- `baseURL` -- the content URL without query parameters (e.g., `https://cdn.example.com/premium/article.html`) -- `expires` -- Unix timestamp in seconds (string representation) -- `agent_id` -- the agent's RFC 7638 JWK Thumbprint (matches `TransactionResponse.agent_identity_hash`) -- `txn_id` -- the transaction ID (ULID) +**CloudFront RSA.** There is no `kid`. AWS's `Key-Pair-Id` plays that role and its URL format requires it. -All four fields are REQUIRED in production signed URLs. The `agent_id` field binds the URL to a specific agent (preventing sharing, Threat T8). The `txn_id` field enables three-sided reconciliation (CDN logs, Exchange transactions, Usage reports). +:::note[Reference implementation, not protocol] +The reference edge worker resolves `kid` against pre-provisioned static keys where they cover it, otherwise against the Exchange's WBA directory at `/.well-known/http-message-signatures-directory`, with a one-shot refetch on a miss so a key rotation heals itself. It answers `403` for an unresolvable key or a bad signature, and `503` for a directory fetch that failed, so a retryable outage is distinguishable from a denial. -This canonicalization format MUST match the Exchange's signing format. Any mismatch between signer and verifier will cause all signed URLs to be rejected. +A second implementation must fail closed. It does not have to fail closed in these two flavours. +::: ## TTL Checking -Signed URLs include an expiry timestamp. The edge function enforces two checks: +Signed URLs carry an expiry. A verifier enforces two checks: -1. **Expiry check** -- reject URLs where `expires` < current time. -2. **Max TTL check** -- reject URLs where `expires` is more than `maxUrlTtlSeconds` from the current time (prevents URLs signed too far in the future). +1. **Expiry check** — reject when `exp` is in the past. +2. **Max TTL check** — reject when `exp` is further ahead than `maxUrlTtlSeconds`, which stops a URL signed far into the future. -The default max TTL is 300 seconds (5 minutes). This limits the replay window even without single-use enforcement. +The default max TTL is 300 seconds. This bounds the replay window even without single-use enforcement, and it is the reason a stored URL is a short-lived capability rather than a permanent one. -## Timing-Safe Comparison +## Constant-time comparison -HMAC comparisons use constant-time comparison (XOR loop), never `===`. This prevents timing side-channel attacks where an attacker could determine partial signature matches based on response timing. +Compare signature bytes in constant time, never with a short-circuiting equality operator. A comparison that returns early on the first differing byte leaks, through response timing, how much of a guessed signature was correct — which turns forgery into a byte-at-a-time search. -| Operation | Time | -|---|---| -| HMAC-SHA256 computation (Web Crypto) | 0.1-0.5ms | -| Timing-safe comparison (64-byte hex) | < 0.01ms | +This applies to any fixed-length secret comparison in the verify path. Ed25519 verification itself is constant-time in every reputable library, so the concern is the surrounding code, not the primitive. ## Agent Identity Binding -The signed URL is bound to the agent that purchased the resource, so a leaked URL is useless to anyone else. The binding follows the **DPoP pattern ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449))**: the Exchange embeds the agent's key thumbprint into the URL it HMAC-signs, and a capable edge function enforces proof-of-possession at fetch time — **with no outbound network call**. +The signed URL is bound to the agent that paid for the resource, so a leaked URL is useless to anyone else. The binding follows the **DPoP pattern ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449))**: the Exchange puts the agent's key thumbprint inside the signed URL, and a capable verifier requires proof of possession at fetch time — **with no outbound network call**. -**Definition.** `agent_identity_hash` (URL parameter `agent_id`) is the **[RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) JWK Thumbprint (SHA-256, base64url)** of the agent's Ed25519 request-signing key — the same key published in the agent's manifest at `{domain}/.well-known/ramp.json` and used for RFC 9421 request signatures. RFC 7638 defines one canonical JSON form and one hash, so signer and verifier compute the identical value. It is present whenever a signed `retrieval_endpoint` is returned, and it is covered by the URL HMAC. +**Definition.** `agent_identity_hash` (URL parameter `agent_id`) is the **[RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) JWK Thumbprint (SHA-256, base64url)** of the agent's Ed25519 **acceptance key** — the key whose signature over `AgentAcceptancePayload` the Exchange verified at execute time. RFC 7638 defines one canonical JSON form and one hash, so signer and verifier compute the identical value. It is present whenever a signed `retrieval_endpoint` is returned, and it is covered by the URL signature. + +The acceptance key, not the RFC 9421 request signer: a Broker may author a re-packaged transaction as sender, and on that leg the request signer is the broker while the in-body acceptance is the only agent-authored signature. An agent that runs the ordinary direct flow signs its requests and its acceptances with one key — the key published in its manifest at `{domain}/.well-known/ramp.json` — so the two are the same value there. Using two keys is what breaks: the URL binds to the acceptance key, and an enforcing endpoint refuses a fetcher presenting the other one. :::caution This is **not** a hash of an account identifier such as `billing_ref` — an identifier is not a secret, so hashing it binds nothing an attacker cannot reproduce. The thumbprint binds the URL to a key whose *private* half the fetcher must prove it holds. @@ -134,17 +112,17 @@ This is **not** a hash of an account identifier such as `billing_ref` — an ide **What the agent presents at fetch:** -1. Its **public key** — in a header or a query parameter; the choice is security-irrelevant because the thumbprint is HMAC-locked (see below). +1. Its **public key** — in a header or a query parameter; the choice is security-irrelevant because the thumbprint is inside the signed URL (see below). 2. An **RFC 9421 HTTP Message Signature** over the retrieval request (covering `@target-uri` + `created`), proving possession of the corresponding private key. -**What the edge function checks -- fully offline:** +**What the verifier checks — fully offline:** -1. Recompute the URL HMAC with the shared secret → proves `agent_id` is Exchange-issued and untampered. -2. Check `expires` (local clock only). +1. Verify the URL signature → proves `agent_id` is Exchange-issued and untampered. +2. Check `exp` (local clock only). 3. `thumbprint(presented public key) == agent_id`. -4. Verify the RFC 9421 signature with the presented key (proof-of-possession). +4. Verify the RFC 9421 signature with the presented key (proof of possession). -All four pass → serve. No JWKS fetch is required: the Exchange already authenticated the key at transaction time and froze its thumbprint into the HMAC; the edge inherits that authentication through the HMAC it can verify locally. +All four pass → serve. No JWKS fetch is required: the Exchange already authenticated the key at transaction time and froze its thumbprint into the signed message, so the verifier inherits that authentication from a signature it can check locally. ```mermaid sequenceDiagram @@ -153,11 +131,11 @@ sequenceDiagram participant Edge as Edge Function Agent->>MP: ExecuteTransaction (signed, RFC 9421) - Note over MP: T = RFC 7638 thumbprint(agent key)
embed T as agent_id, HMAC the whole URL + Note over MP: T = RFC 7638 thumbprint(agent key)
embed T as agent_id, sign the canonical URL MP-->>Agent: retrieval_endpoint (?agent_id=T&sig=...)
+ agent_identity_hash = T Agent->>Edge: GET retrieval_endpoint
present public key + RFC 9421 signature - Note over Edge: 1. Recompute URL HMAC (T untampered)
2. Check expiry
3. thumbprint(presented key) == T
4. Verify RFC 9421 sig (proof-of-possession) + Note over Edge: 1. Verify URL signature (T untampered)
2. Check expiry
3. thumbprint(presented key) == T
4. Verify RFC 9421 sig (proof of possession) alt All four pass Edge-->>Agent: 200 OK + content else Any check fails @@ -165,26 +143,30 @@ sequenceDiagram end ``` -**Why a stolen URL is harmless.** To pass step 3 the attacker must present the agent's *public* key; to pass step 4 they must hold its *private* key. They can do one or the other, never both. Rewriting `agent_id` to match their own key fails step 1 (they do not hold the HMAC secret). The defense is `(HMAC-locked thumbprint) ∧ (proof-of-possession)`, not key secrecy — which is why the public key may travel in the clear. +**Why a stolen URL is harmless.** To pass step 3 the attacker must present the agent's *public* key; to pass step 4 they must hold its *private* key. They can do one or the other, never both. Rewriting `agent_id` to match their own key fails step 1, because they cannot re-sign the URL. The defence is `(signature-locked thumbprint) ∧ (proof of possession)`, not key secrecy — which is why the public key may travel in the clear. | Attacker with a stolen URL tries to… | Fails at | |---|---| | present their own key + sign with their own private key | step 3 — `thumbprint(their key) ≠ agent_id` | -| rewrite `agent_id` to match their own key | step 1 — HMAC fails; they lack the shared secret | +| rewrite `agent_id` to match their own key | step 1 — they cannot produce the Exchange's signature | | present the agent's public key (it is public) | step 4 — they cannot produce the RFC 9421 signature | -**Enforcement is OPTIONAL -- capability depends on the delivery node:** +**Enforcement is OPTIONAL, and capability depends on the delivery node:** -| Delivery node | Enforces binding? | Behavior | +| Delivery node | Enforces binding? | Behaviour | |---|---|---| -| Edge function (Cloudflare Workers, Lambda@Edge, Fastly Compute) | Yes | Full steps 1-4. RAMP reference implementations run here and **do** enforce. | -| Bearer-only signed-URL CDN (CloudFront / S3 presigned, native Fastly) | No | Validates its own HMAC + expiry only; falls back to **HMAC + short TTL + TLS**. | +| Edge function (Cloudflare Workers, Lambda@Edge, Fastly Compute) | Yes | Full steps 1–4. RAMP reference implementations run here and **do** enforce. | +| CDN-native verification (CloudFront) | No | Validates its own signature and expiry only; falls back to **signature + short TTL + TLS**. | + +This is the same split as the scheme table at the top, seen from the other end: Ed25519 URLs are verified by code that can run steps 3 and 4, CloudFront URLs are verified by infrastructure that cannot. -Cost budget on a capable edge: HMAC-SHA256 (µs) + SHA-256 thumbprint (µs) + one Ed25519 verify (~50-100 µs) ≪ 1 ms, no socket. Because the RFC 9421 signature covers `@target-uri` + `created`, a captured fetch signature cannot be replayed against a different URL or outside the short TTL. +Cost on a capable edge: one Ed25519 verify for the URL, one SHA-256 thumbprint, one Ed25519 verify for the request signature — well under a millisecond, with no socket. Because the RFC 9421 signature covers `@target-uri` and `created`, a captured fetch signature cannot be replayed against a different URL or outside the short TTL. ## Single-Use URL Enforcement -For providers wanting additional replay protection, the edge function can enforce single-use URLs via edge KV: +For providers wanting additional replay protection, the edge function can enforce single-use URLs via edge KV. + +This needs a per-URL key. The signer itself adds only `exp`, `kid`, `agent_id` and `sig`, so a transaction identifier is present only when the Exchange already put it on the resource URL before signing — it is covered by the signature either way, because the canonical form takes the query verbatim. A deployment without one can key on `sig` instead, which is unique per URL by construction. ```mermaid sequenceDiagram @@ -209,7 +191,7 @@ sequenceDiagram **KV TTL**: Set to `maxUrlTtlSeconds + 60s` (URL max TTL plus a safety buffer). After this period, the signed URL is expired anyway, so the KV entry can be evicted. -**Single-use enforcement is best-effort.** CDN edge KV stores are eventually consistent across locations. The primary replay protection is agent identity binding + short TTL (5 minutes). Authoritative deduplication happens during reconciliation via CDN access logs and Exchange transaction records. +**Single-use enforcement is best-effort.** CDN edge KV stores are eventually consistent across locations. The primary replay protection is agent identity binding plus a short TTL. Authoritative deduplication happens during reconciliation, against CDN access logs and Exchange transaction records. **CDN KV consistency reality**: @@ -221,28 +203,24 @@ sequenceDiagram | Akamai EdgeKV | EdgeKV | Eventually consistent | ~5-10s in-region | | Fastly KV Store | KV Store | Eventually consistent | Seconds across PoPs | -**Recommendation: Do not enforce strict single-use at the edge.** Instead, use: - -1. **Agent identity binding** -- the signed URL is bound to a specific agent_id. Even if replayed, only the authorized agent can use it. -2. **Short TTL (5 minutes)** -- limits the replay window to a narrow period. -3. **Reconciliation (detect abuse after the fact)** -- the Exchange transaction log and CDN access logs contain `txn_id` for every request. Cross-reference to detect replays and take action. +**Recommendation: do not enforce strict single-use at the edge.** Instead, use: -This matches ad-tech's approach to impression deduplication: real-time is best-effort, reconciliation is authoritative. +1. **Agent identity binding** — the signed URL is bound to a specific `agent_id`, so a replay by anyone else fails proof of possession. +2. **Short TTL** — limits the replay window to a narrow period. +3. **Reconciliation** — the Exchange transaction log and CDN access logs both carry `txn_id`. Cross-reference to detect replays after the fact. -## Key Custody Model +This matches ad-tech's approach to impression deduplication: real time is best-effort, reconciliation is authoritative. -**The edge function verifies, it does not sign.** +## Key Custody -| Signing Scheme | Who Signs | Who Verifies | Edge Function Has | -|---|---|---|---| -| CloudFront RSA (asymmetric) | Exchange (private key) | CloudFront infra (public key) | Nothing (CDN-native) | -| Akamai EdgeAuth (symmetric) | Exchange (shared secret) | Akamai infra (shared secret) | Nothing (CDN-native) | -| HMAC-SHA256 (symmetric) | Exchange (shared secret) | Edge function (same secret) | **The shared secret** | -| RSA/Ed25519 (asymmetric, custom) | Exchange (private key) | Edge function (public key) | Public key only | +**The verifier never holds signing material.** That is true of both schemes, and it is what makes a compromised edge node unable to mint URLs. -**Concern with HMAC**: When using HMAC verification (Cloudflare, Fastly), the edge function must hold the shared secret. If the edge function is compromised, the attacker can forge signed URLs. Key rotation requires updating both the Exchange and the edge function simultaneously. +| Scheme | Exchange holds | Verifier holds | +|---|---|---| +| Ed25519 | The private signing key | The public key only, resolved by `kid` | +| CloudFront RSA | The private signing key | Nothing — CloudFront holds the public key in a trusted key group | -**Recommendation**: Use asymmetric signing (Ed25519 or RSA) when the CDN does not provide native signed URL verification. +Rotation follows from that. An Ed25519 rotation publishes a new public key and mints with a new `kid`; URLs already in flight keep verifying against the old key until they expire, so there is no coordinated switchover. A CloudFront rotation adds the new public key to the key group before the Exchange starts using it. ```mermaid graph LR @@ -250,17 +228,17 @@ graph LR PRIV_KEY["Private Key
(signs URLs)"] end - subgraph "Edge Function (Provider CDN)" + subgraph "Verifier (Provider CDN)" PUB_KEY["Public Key
(verifies URLs)"] end subgraph "Key Distribution" - RAMP_JSON["ramp.json or
config endpoint"] + DIR["WBA directory or
CloudFront key group"] end - PRIV_KEY -->|"Signs: URL + expiry + agent_id + txn_id"| SIGNED_URL[Signed URL] + PRIV_KEY -->|"Signs the canonical URL; adds exp, kid, agent_id"| SIGNED_URL[Signed URL] SIGNED_URL -->|"Agent fetches"| PUB_KEY PUB_KEY -->|"Verifies signature"| DECISION{Valid?} - PRIV_KEY -.->|"Public key published at"| RAMP_JSON - RAMP_JSON -.->|"Edge function fetches public key"| PUB_KEY + PRIV_KEY -.->|"Public key published at"| DIR + DIR -.->|"Verifier resolves by kid"| PUB_KEY ``` diff --git a/website/src/content/docs/components/exchange/multi-tenant.mdx b/website/src/content/docs/components/exchange/multi-tenant.mdx index 1bd06a6a..988a5b59 100644 --- a/website/src/content/docs/components/exchange/multi-tenant.mdx +++ b/website/src/content/docs/components/exchange/multi-tenant.mdx @@ -181,7 +181,7 @@ Both endpoints return `404 Not Found` if the domain is not associated with any t ### Public Key Exchange -Brokers and agents fetch the Exchange's WBA directory (the JWK Set at `/.well-known/http-message-signatures-directory`) and read its `keys` to verify `exchange_signature` on Offers without manual key exchange. The Exchange's offer-signing Ed25519 public keys are inline in that directory as RFC 7517 JWKs, each identified by its RFC 7638 thumbprint (the RFC 9421 `keyid`). +Brokers and agents fetch the Exchange's WBA directory (the JWK Set at `/.well-known/http-message-signatures-directory`) and read its `keys` to verify the `signature` on Offers without manual key exchange. The Exchange's offer-signing Ed25519 public keys are inline in that directory as RFC 7517 JWKs, each identified by its RFC 7638 thumbprint (the RFC 9421 `keyid`). **Note:** The Exchange's WBA directory keys serve the Exchange's offer-signing keys. Agent keys are separate — they are fetched from the agent's own WBA directory (self-signup) or stored from enterprise registration (out-of-band key exchange). diff --git a/website/src/content/docs/components/exchange/request-flows.mdx b/website/src/content/docs/components/exchange/request-flows.mdx index 1cae40af..f2e936de 100644 --- a/website/src/content/docs/components/exchange/request-flows.mdx +++ b/website/src/content/docs/components/exchange/request-flows.mdx @@ -546,7 +546,10 @@ func (h *ExchangeHandler) ExecuteTransaction( OfferSnapshotJSON: marshalOfferSnapshot(offer), DeliveryMethod: offer.DeliveryMethod, ReportingRequired: tenantCfg.ReportingPolicy.GetRequired(), - IdempotencyKey: req.Msg.IdempotencyKey, + // Derived PER-ITEM key: distinct items of a multi-item batch get + // distinct keys, so neither the UNIQUE backstop nor billing dedup + // collapses a batch. Applied unconditionally (single-item too). + IdempotencyKey: req.Msg.IdempotencyKey + ":" + offer.OfferId, CreatedAt: time.Now().UTC(), } if err := h.txLog.Write(ctx, record); err != nil { diff --git a/website/src/content/docs/components/exchange/scaling.mdx b/website/src/content/docs/components/exchange/scaling.mdx index a95f65c3..8e6c510f 100644 --- a/website/src/content/docs/components/exchange/scaling.mdx +++ b/website/src/content/docs/components/exchange/scaling.mdx @@ -167,8 +167,10 @@ The idempotency cache uses a concurrent LRU with sharded locks to avoid contenti ```go // IdempotencyCache stores recent TransactionResponse values keyed by the -// request's idempotency key (TransactionRequest.idempotency_key). -// Sharded by hash of key to reduce lock contention. +// BARE request-level key (TransactionRequest.idempotency_key) — correct here, +// and deliberately not the derived per-item key the transaction log stores: +// the cached value is the whole response, so the whole request is what it +// dedupes. Sharded by hash of key to reduce lock contention. type IdempotencyCache struct { shards [256]*idempotencyShard ttl time.Duration @@ -218,9 +220,10 @@ Result: Two signed URLs issued for one billing event. CDN redeems one. No financ **Scale Tier**: At 10K+ RPS, use Redis for distributed idempotency: ```go -// Distributed idempotency check via Redis. The key is the request's -// idempotency key (TransactionRequest.idempotency_key), not the wire -// correlation id (which rides the X-Request-ID HTTP header). +// Distributed idempotency check via Redis. The key is the BARE request-level +// key (TransactionRequest.idempotency_key) — this caches whole responses, so +// it is not the derived per-item key the transaction log stores — and not the +// wire correlation id either (which rides the X-Request-ID HTTP header). func (h *ExchangeHandler) checkIdempotency(ctx context.Context, idempotencyKey string) (*rampv1.TransactionResponse, error) { // 1. Local LRU (fast path, no network) if cached, ok := h.localCache.Get(idempotencyKey); ok { diff --git a/website/src/content/docs/components/exchange/storage-model.mdx b/website/src/content/docs/components/exchange/storage-model.mdx index 0beb5f48..6218ef38 100644 --- a/website/src/content/docs/components/exchange/storage-model.mdx +++ b/website/src/content/docs/components/exchange/storage-model.mdx @@ -111,10 +111,13 @@ type TransactionRecord struct { Currency string `json:"currency"` UnitCost float64 `json:"unit_cost,omitempty"` - // Idempotency. Stores the request's idempotency key - // (TransactionRequest.idempotency_key) so a replay returns the original - // record. This is the durable dedupe key, NOT the wire correlation id — - // request/response correlation rides the X-Request-ID HTTP header. + // Idempotency. Stores the DERIVED per-item key — + // TransactionRequest.idempotency_key + ":" + offer_id — so each item of a + // multi-item batch dedupes independently and a replay returns the original + // record. Derived unconditionally, single-item requests included; the + // request-level key alone is never stored here. This is the durable dedupe + // key, NOT the wire correlation id — request/response correlation rides + // the X-Request-ID HTTP header. IdempotencyKey string `json:"idempotency_key"` Broker string `json:"broker,omitempty"` @@ -133,9 +136,20 @@ type TransactionRecord struct { // Chain hash for tamper-evident audit log. ChainHash string `json:"chain_hash"` - // Signed URL metadata (for reconciliation with CDN logs). - SignedURLHash string `json:"signed_url_hash"` // SHA-256 of the issued signed URL - URLExpiresAt time.Time `json:"url_expires_at"` + // Signed URL metadata (for reconciliation with CDN logs). SHA-256 of the + // full issued URL, query string and signature parameter included, held as + // 32 RAW BYTES — not hex text. The operator plane holds the same digest + // as bytes, so the join between them is byte-to-byte. Hex appears only + // when this store is exported as text. + // + // Both are pointers here because a DELIVERY_METHOD_DIRECT transaction + // mints no signed URL: nothing to hash, nothing to expire, and a value + // type cannot tell that apart from a minted URL recorded as empty or as + // the zero time. An Exchange that never emits DIRECT may declare both + // columns NOT NULL and still conform — the reference one does. The two + // are written together or not at all either way. + SignedURLHash []byte `json:"signed_url_hash,omitempty"` + URLExpiresAt *time.Time `json:"url_expires_at,omitempty"` // Timestamps. CreatedAt time.Time `json:"created_at"` @@ -166,7 +180,7 @@ The transaction record includes a full offer snapshot (offer_id, package.id, pac For Growth tier (100-1K RPS), PostgreSQL with daily partitions provides sufficient write throughput with strong durability guarantees. ```sql --- Daily partitioned transaction log (append-only) +-- Daily partitioned transaction log (append-mostly — see the note below) CREATE TABLE transactions ( transaction_id TEXT NOT NULL, billing_id TEXT NOT NULL, @@ -183,10 +197,30 @@ CREATE TABLE transactions ( CREATE TABLE transactions_2026_03_15 PARTITION OF transactions FOR VALUES FROM ('2026-03-15') TO ('2026-03-16'); --- Append-only enforcement -REVOKE UPDATE, DELETE ON transactions FROM exchange_app; +-- No deletes. UPDATE is NOT revoked here — see below. +REVOKE DELETE ON transactions FROM exchange_app; ``` +**Whether `UPDATE` may be revoked on the transaction log depends on where you put report state.** The protocol does not decide it. If the consumed quantity lands on the transaction row when the usage report arrives, the log is mutable and revoking `UPDATE` blocks a write you need. If report state goes to a separate table — the reference Exchange writes the state transition, the consumed quantity and the validation outcome onto the obligation row, leaving the log insert-only — then `UPDATE` can be revoked here too. + +Append-once is a hard requirement for the **evidence** store described below, and only there. That is the distinction: the log may follow a transaction as it changes, the evidence row states what the two parties agreed to and can never legitimately change afterwards. + +`DELETE` is revoked either way. Retention drops whole partitions after export, which does not need row-level `DELETE`. + +:::caution[`REVOKE` may be a no-op in your topology] +Table grants do nothing when the application connects as the **database owner**, which many deployments do. `REVOKE UPDATE, DELETE` then reads like enforcement and enforces nothing. + +Where append-once has to hold regardless of role, use `BEFORE UPDATE OR DELETE OR TRUNCATE` triggers that raise. The reference Exchange guards its evidence table that way, for exactly this reason. Grants are a useful second layer; they are not the mechanism. +::: + +**Dedupe scope of `idempotency_key`**: the protocol requires that a key chosen by one caller can never collide with another caller's cached result, so dedupe happens inside a namespace and never globally. For `ExecuteTransaction` that namespace is the acceptance identity, which means the uniqueness constraint must be **composite** — over `(agent_identity_hash, idempotency_key)`, never over `idempotency_key` alone. `agent_identity_hash` is derived from the key that signed the offer acceptance, not from the transport caller, so the scoping holds even when a Broker relays many agents over one transport key. + +The DDL above does not spell the constraint out, because a PostgreSQL UNIQUE on a partitioned table must include the partition key. Enforce it per partition, or in a separate non-partitioned dedupe table keyed on the pair; the requirement is the namespace, not the mechanism. + +:::caution[Known divergence] +The reference Exchange currently deploys a **global** UNIQUE on `idempotency_key` alone. A second agent presenting a key the first agent already used is refused by the replay ownership gate rather than deduped in its own namespace, so the observable failure is denial of service across callers, not a leak of another caller's result. Keys are client-chosen UUIDs, so accidental collision is unlikely and squatting requires guessing a key. The composite form described above is the conforming one, and moving the reference Exchange to it is tracked separately. +::: + **Volume estimate at 1K RPS**: ~86M records/day, ~1 KB/record = ~86 GB/day uncompressed, ~30 GB/day with PostgreSQL TOAST compression. Daily partition drop after export keeps disk usage bounded. **Export to S3 Parquet**: A nightly job exports each day's partition to S3 as Parquet files for long-term analytics. Queryable via Athena or Trino. @@ -201,6 +235,85 @@ The local WAL + Kafka architecture feeds ClickHouse as a Kafka consumer. ClickHo --- +## Transaction Evidence (Append-Once, Durable) + +The evidence store is a **separate store from the transaction log**, written on the same request path. The transaction log is the settlement and analytics stream; the evidence store holds the offline-verifiable proof of what the two parties agreed to. One row is written per successfully executed transaction, at the service boundary, after both Ed25519 signatures have verified. A denied execute writes no row, so the existence of a row is itself the success statement. + +The row is **append-once**: written once during `ExecuteTransaction`, then never updated and never deleted before its retention limit. Enforce it with `BEFORE UPDATE OR DELETE OR TRUNCATE` triggers rather than with grants alone — see the caution above on why `REVOKE` may enforce nothing. This is the one table where append-once is a protocol requirement rather than a design choice: the log follows a transaction as it changes, this row states what was agreed and can never legitimately change afterwards. Its only read path is the operator plane's `GetTransactionEvidence` RPC. + +**What the row holds.** Each of the two signatures is stored together with the verbatim canonical bytes it was computed over and the public key it verifies against, so a reader can re-verify the row offline without contacting the Exchange and without re-deriving anything. The row also holds the selector columns (`tenant_id`, `transaction_id`, `offer_id`), the request-level idempotency key the acceptance signed, the requester identity as signed, the directory URL the Exchange states it pinned the agent key from, the correlation pair described below, and the server-clock write time. + +The column-by-column listing is `ramp.admin.v1.TransactionEvidence` in [Proto: Admin v1](/reference/proto-admin/). The admin read returns the row **as persisted**, so that message is the column list, and this page deliberately does not restate it — a second hand-maintained copy would drift from the wire contract with nothing to catch it. + +One name to read carefully across the two stores: `request_idempotency_key` on the evidence row is the **request-level** key the acceptance signed, while the transaction log stores the **derived per-item** key (`request key + ":" + offer_id`). The two are never byte-equal, so joining the stores on an idempotency key means deriving the per-item form first. + +### One request's rows can hold different agent keys + +`ramp.v1` requires every acceptance in one `TransactionRequest` to be signed by the same key. A mixed-key request is not conformant, and the rule exists because `TransactionResponse.agent_identity_hash` is a single per-request value: a batch accepted under two keys has no one identity to bind its delivery URLs to. + +Rows that break it still exist, so a reader of the store has to know what they mean. An Exchange that verifies each item's acceptance against the agent key current *at that item's verification* can persist rows for one request whose `agent_public_key` values differ, when a registry rotation lands between two verifications. The response's single `agent_identity_hash` then names the first item's key, and items 2..N carry URLs bound to a key that did not accept them. + +**Such a row is evidence of a requester in violation, not of legal traffic.** For two items to verify under two different keys, the agent must have signed one with the old key and the other with the new one — an agent that signs every item with a single key cannot produce the pair, because whichever key the registry holds, the other item fails verification. So the mixed pair is a non-conformant request slipping through a narrow enforcement race, not a shape the rule permits. Each row is individually valid in the narrow sense that both keys were genuinely the agent's; the *request* was not. + +A registry that keeps no key history cannot explain the mismatch afterwards. What ties the rows together is their shared `request_idempotency_key` and near-identical `created_at`, not anything in the registry. + +Resolving the key once per request and reusing it for every item closes the race, and an Exchange should. Until it does, the window stays open — and because the store is append-once, rows written while it was open keep the property forever. Read them as the trace of a violating request that was not caught, and do not treat the pair as a licence to send one. + +### Storing the full signed URL + +The evidence row carries no signed URL on the **wire** — `TransactionEvidence` has no such field, and that is structural. Whether the *store* behind it may keep one is a separate question, and the answer depends on the signing scheme, because the schemes differ in what a stored URL actually is. + +| Signing scheme | Is a stored URL a capability? | Rule | +|---|---|---| +| Ed25519, agent-bound | **No.** The edge enforces proof of possession: a fetch must be signed by the agent key whose thumbprint the URL carries. A reader of the store cannot fetch with it. | MAY be stored unconditionally | +| CloudFront RSA (or any scheme the edge verifies natively) | **Yes**, until expiry. Native verification cannot check proof of possession, so whoever holds the URL can fetch. | Store only under `log_full_signed_url`, default off | + +The flag SHOULD be settable **per tenant or coarser**. The signing scheme is a per-tenant property, so the trade-off the flag encodes is per-tenant too, and one global answer forces the same posture onto publishers with different schemes and different dispute exposure. + +**Why store the preimage at all, when the hash is enough for reconciliation.** The hash proves *that* a delivery record matches what was minted. It cannot show *what* was minted — which agent binding, which expiry, which path the capability named. That preimage is not recoverable later: rebuilding the URL would need the tenant's signing key still in custody, the exact signer code path, and the exact parameter encoding of the version that minted it, all preserved across the retention window. A row that stores only inputs re-verifies only as long as the verifier's recipe still matches the signer's, which is the dependency an evidence store exists to remove. + +**Operators choosing the default-off posture should choose it knowing the cost.** The flag is not retroactive. A preimage exists only for transactions executed while it was on, and a dispute always concerns a past transaction — so turning it on after a dispute arrives recovers nothing. The decision is made permanently, per transaction, at execute time. And it bites hardest exactly where the delivery-side evidence is thinnest: on natively-verified schemes there is no proof-of-possession check and the component that writes the delivery record is not in the verification path, so the preimage is the strongest thing the Exchange could have kept. + +Storing the URL with its signature parameter stripped does not work as a middle option. `signed_url_hash` covers the full URL, so a stripped value cannot be hashed to match the stored digest — it is not a weaker preimage, it is no preimage plus text that looks anchored. + +**Storage**: evidence rows share the durable store used for transaction records (PostgreSQL for Growth tier, ClickHouse for Scale tier). They must be indexed on `(tenant_id, transaction_id)` — the pair the admin read selects on. Retention must be at least the transaction log's 13 months, because this row is the proof behind any dispute filed against that transaction. + +### The correlation pair + +**The evidence store is where the request correlation id is persisted.** Two columns carry it: + +| Column | Type | Meaning | +|---|---|---| +| `request_id` | nullable text | The correlation id for the execute request, as recorded. | +| `request_id_minted` | nullable boolean | `true` = the value is **server-derived**; `false` = propagated verbatim from a caller-supplied `X-Request-ID`. | + +**The two are present together or absent together.** A row with one column set and the other null is invalid, and the store enforces this with a table constraint rather than leaving it to application code. The provenance flag is load-bearing: a propagated id is caller-influenceable and a server-derived one is not, and the id value alone cannot tell the two apart. + +The pair is written **once**, at the service boundary during `ExecuteTransaction`. + +**The invariant: a stored `request_id` always conforms.** It must be printable ASCII, 1 to 255 characters — the shape the admin plane can replay into a rendered ledger. The check belongs on the **write** path. A read-side filter would leave the bad value in the row, so every other reader of the store inherits the problem and the row stops being a faithful record of what was recorded. + +**How a server reaches that invariant is its own choice.** Two mechanisms both conform, and they differ only in what a *nonconforming* header produces: + +| Mechanism | Nonconforming header produces | Trade-off | +|---|---|---| +| Reject and derive | A server-derived id, `request_id_minted = true` | Keeps a correlation key for the request; loses the fact that a bad header was sent | +| Record nothing | Both columns null | States that nothing trustworthy arrived; the row has no correlation key at all | + +The reference Exchange does the first, and validates against a charset stricter than the contract requires (`^[A-Za-z0-9._-]{1,128}$`), so nothing it accepts can violate the wider rule. Either mechanism satisfies the contract, because neither can put a nonconforming value in the store — and that is the only property the read plane depends on. + +This is why `request_id_minted` means **server-derived** rather than "the header was absent". A server that rejects and derives sets it for two different reasons — no header, or a bad header — and the distinction the flag exists for is *influence*: `false` means a caller chose those characters, `true` means no caller did. Widening the meaning keeps that property exact under both mechanisms. + +**A read-side guard is still required, as defence in depth.** A server must never populate `request_correlation` from a nonconforming stored value, because rows written before the write check existed may hold one. That is a migration guard, not the primary defence. + +**The transaction log holds no correlation column.** Its `IdempotencyKey` is a durable dedupe key and nothing else; correlation rides the `X-Request-ID` HTTP header and is persisted only here. That split is exactly what the admin plane encodes by placing `RequestCorrelation` on `TransactionEvidence` and not on `TransactionState`. + +**This is not the same key as `query_id`.** The `ResourceQueryReceived` event records a `query_id`, which is the `X-Request-ID` of a *discovery* request. The evidence row's `request_id` is the `X-Request-ID` of an *execute* request. Different legs of the exchange, different requests, different values — they must not be joined against each other or read as one correlation chain. The evidence-plane id joins outward to whatever else recorded the same correlation id for that execute call: the Exchange's own request logs, its admin audit log, and any tracing pipeline that saw the header. + +**It is not the join to the delivery side — `signed_url_hash` is.** The delivery fetch is a separate HTTP request, made later by the agent to the CDN. Nothing in the contract requires a fetcher to carry the execute request's `X-Request-ID` onto it, and the delivery record's field list has no place to keep it. A client MAY propagate the id across all three legs — the reference RAMP SDK does, deliberately — and a delivery-side record MAY log the header it received, in which case the correlation works. Neither is guaranteed, so build the join on the hash and treat any correlation-id match on the delivery side as a convenience. + +--- + ## Reporting Obligations (State Machine) Each transaction that carries a ReportingObligation creates an obligation record. The obligation tracks whether the agent fulfilled its reporting duty. @@ -272,13 +385,13 @@ Providers get read-only access to all signed Offers issued for their content. Th **Returns** (per transaction): - `transaction_id` — Exchange-assigned transaction identifier - `billing_id` — billing reference for settlement -- `offer_snapshot` — full Offer at transaction time, including `exchange_signature` +- `offer_snapshot` — full Offer at transaction time, including `signature` - `cost` — actual amount charged (or `0` for subscription transactions) - `agent_id` — hashed agent identity (`SHA256(agent_key)`) - `timestamp` — when the transaction was executed **Verification capabilities**: -- Provider can independently verify every Offer's `exchange_signature` using the Exchange's published Ed25519 public key (fetch the Exchange's WBA directory, the JWK Set at `/.well-known/http-message-signatures-directory`, and read its `keys`) +- Provider can independently verify every Offer's `signature` using the Exchange's published Ed25519 public key (fetch the Exchange's WBA directory, the JWK Set at `/.well-known/http-message-signatures-directory`, and read its `keys`) - RSL pricing, when present, serves as the **price ceiling** — Offers exceeding the RSL-declared rate are detectable by comparing `offer_snapshot.pricing.rate` against the RSL terms - Private floor pricing is contractual between provider and Exchange, not protocol-enforced — the protocol provides the audit data, the contract governs the terms diff --git a/website/src/content/docs/components/transaction-log/event-types.mdx b/website/src/content/docs/components/transaction-log/event-types.mdx index 9650a5de..11f69004 100644 --- a/website/src/content/docs/components/transaction-log/event-types.mdx +++ b/website/src/content/docs/components/transaction-log/event-types.mdx @@ -29,7 +29,7 @@ Loss is acceptable -- this is telemetry, not financial data. | `event_id` | `string` | yes | UUIDv7, monotonically ordered | | `event_type` | `EventType` | yes | `EVENT_SUPPLY_QUERY_RECEIVED` | | `timestamp` | `time.Time` | yes | Server receipt time (UTC, nanosecond precision) | -| `query_id` | `string` | yes | The supply query's `X-Request-ID` header (correlation left the proto body) | +| `query_id` | `string` | yes | The supply query's `X-Request-ID` header (correlation left the proto body). A different key from the execute request's correlation id, which the [evidence store](/components/exchange/storage-model/) persists -- different leg, different request, do not join them | | `buyer_lid` | `string` | yes | CoMP's `aisystem.aisysuse.lid`; for RAMP requests, the caller's account handle (`RegisterResponse.billing_ref`) resolved from the verified request signature | | `buyer_name` | `string` | yes | `aisystem.name` (e.g. "claude.ai") | | `requested_uris` | `[]string` | yes | `aisystem.aisysuse.uri` | @@ -78,7 +78,7 @@ Logged when an `ExecuteTransaction` RPC is received, before billing authorizatio | `event_id` | `string` | yes | UUIDv7 | | `event_type` | `EventType` | yes | `EVENT_TRANSACTION_REQUESTED` | | `timestamp` | `time.Time` | yes | Server receipt time | -| `idempotency_key` | `string` | yes | `TransactionRequest.idempotency_key` (the request's idempotency key) | +| `idempotency_key` | `string` | yes | The derived per-item key: `TransactionRequest.idempotency_key + ":" + offer_id` (never the bare request key) | | `offer_id` | `string` | yes | `Offer.offer_id` of the committed `TransactionItem` | | `buyer_lid` | `string` | yes | `aisystem.aisysuse.lid` | | `buyer_name` | `string` | yes | `aisystem.name` | @@ -97,7 +97,7 @@ Logged after the Billing Adapter returns its authorization decision. Captures bo | `event_id` | `string` | yes | UUIDv7 | | `event_type` | `EventType` | yes | `EVENT_BILLING_AUTHORIZATION` | | `timestamp` | `time.Time` | yes | Authorization decision time | -| `idempotency_key` | `string` | yes | Corresponding `TransactionRequest.idempotency_key` | +| `idempotency_key` | `string` | yes | The derived per-item key for the authorized item: `TransactionRequest.idempotency_key + ":" + offer_id` (never the bare request key) | | `offer_id` | `string` | yes | The offer being authorized | | `buyer_lid` | `string` | yes | The requester's account handle (`RegisterResponse.billing_ref`) | | `authorized` | `bool` | yes | Whether billing was approved | @@ -129,7 +129,7 @@ The Exchange MUST NOT return the `TransactionResponse` until this event is durab | `timestamp` | `time.Time` | yes | URL generation time | | `transaction_id` | `string` | yes | `TransactionResultItem.transaction_id` | | `billing_id` | `string` | yes | `TransactionResultItem.billing_id` | -| `idempotency_key` | `string` | yes | `TransactionRequest.idempotency_key` (the request's idempotency key) | +| `idempotency_key` | `string` | yes | The derived per-item key: `TransactionRequest.idempotency_key + ":" + offer_id` (never the bare request key) | | `offer_id` | `string` | yes | Selected offer | | `buyer_lid` | `string` | yes | The requester's account handle (`RegisterResponse.billing_ref`) | | `buyer_name` | `string` | yes | AI system name | @@ -139,10 +139,10 @@ The Exchange MUST NOT return the `TransactionResponse` until this event is durab | `cost_amount` | `float64` | yes | Transaction cost | | `cost_currency` | `string` | yes | ISO 4217 currency code | | `cost_unit_cost` | `float64` | yes | Effective cost per unit | -| `signed_url_hash` | `string` | yes | SHA-256 of the issued signed URL | -| `signed_url_expiry` | `time.Time` | yes | When the signed URL expires | -| `agent_identity_hash` | `string` | yes | Hash of agent identity bound to URL | -| `delivery_method` | `string` | yes | DIRECT or INSTRUCTIONS | +| `signed_url_hash` | `[]byte` | cond | SHA-256 of the issued signed URL, 32 raw bytes (see [Signed URL Logging](#signed-url-logging)). Required when the transaction minted a signed URL; absent for `DIRECT` | +| `signed_url_expiry` | `time.Time` | cond | When the signed URL expires. Present and absent under the same condition as `signed_url_hash` | +| `agent_identity_hash` | `string` | yes | RFC 7638 JWK Thumbprint (SHA-256, base64url) of the agent's acceptance key. Bound into the signed URL when one is minted | +| `delivery_method` | `string` | yes | `DIRECT`, `INSTRUCTIONS` or `STREAMING` (`Offer.delivery_method`) | | `offer_snapshot_json` | `string` | yes | JSON-serialized offer at transaction time | | `reporting_required` | `bool` | yes | Whether usage report is mandatory | | `reporting_deadline` | `time.Time` | no | Required when `reporting_required=true` | @@ -154,15 +154,51 @@ The Exchange MUST NOT return the `TransactionResponse` until this event is durab The `offer_snapshot_json` field captures the complete offer as it existed at transaction time (~500 bytes per event). It enables audit queries like "what exactly was promised in transaction X?" without needing to reconstruct from the catalog, which may have changed since. +### Direct Delivery Mints No Signed URL + +The event itself is always produced: it is the settlement record for every authorized transaction, whatever the delivery method. Two of its columns are not. + +`DELIVERY_METHOD_DIRECT` returns the resource inline or from the Exchange's own endpoint. No signed URL is minted, so there is no URL to hash and nothing to expire, and `signed_url_hash` and `signed_url_expiry` are both absent. `DELIVERY_METHOD_INSTRUCTIONS` and `DELIVERY_METHOD_STREAMING` both mint one and always carry both columns. + +The two columns are absent together or present together. A row carrying one without the other is malformed, because both describe the same minted URL. + +**What this requires of a store depends on what that store emits.** An Exchange that can return `DELIVERY_METHOD_DIRECT` must be able to hold *no value* in these two columns — not an empty string and not a zero timestamp — because the absence is a stated fact about the delivery method, and a consumer must be able to tell it apart from a URL that was minted but logged wrong. An Exchange that only ever emits `INSTRUCTIONS` or `STREAMING` mints a URL for every transaction it logs, so it may declare both columns `NOT NULL` and still conform. The reference Exchange is in that second group today: it emits `INSTRUCTIONS` only, has no `DIRECT` code path, and its schema is built on "a row cannot exist without a fully formed signed URL". + +The operator plane accommodates both without a choice of its own: `ramp.admin.v1.TransactionState.signed_url_hash` and `.signed_url_expiry` are optional, so a `DIRECT` transaction's state message omits them and every other transaction's carries them. + +An Exchange adding `DIRECT` later cannot simply start writing `NULL` into `NOT NULL` columns. It needs a schema change, and — separately — a decision this contract has not made: whether a direct-delivery transaction produces a `SignedURLIssued` event at all, or a differently named one. Settle that before implementing `DIRECT`, not after. + ### Signed URL Logging -By default, only the SHA-256 hash of the signed URL is logged (`signed_url_hash`). A configuration option `log_full_signed_url` (default: `false`) enables logging the full URL for debugging. +By default, only the hash of the signed URL is logged (`signed_url_hash`). A configuration option `log_full_signed_url` (default: `false`) enables logging the full URL for debugging. + +**What is hashed.** The SHA-256 digest is taken over the **full** issued URL — scheme, host, path and query string, exactly the bytes handed to the agent, with the signature parameter included. This is what the Go SDK's `HashURL` already computes, and it is the only definition that lets a provider reproduce the value from a CDN access log line. + +**The same digest is held in three different forms, and only some pairs join directly.** + +| Where | Form | +|---|---| +| The Exchange's transaction log | 32 raw bytes | +| `ramp.admin.v1.TransactionState.signed_url_hash` | 32 raw bytes | +| The edge delivery record | lowercase hex, 64 characters | +| A log export (CSV, JSON Lines) | lowercase hex, 64 characters | +| protojson on the operator plane | base64 — that is what protojson does with `bytes` | + +So a query joining the Exchange's log against the operator plane compares byte to byte with nothing to normalize, while a query joining either of them against a delivery record crosses an encoding boundary and must convert one side first. + +**Where a text form is needed, it is lowercase hexadecimal.** That is what the edge delivery record already uses, so an export matches the delivery side with no conversion — which is the join that actually runs in reconciliation. Lowercase hex also has exactly one spelling and compares and sorts predictably in SQL, while base64 has padded, unpadded and URL-safe variants that all look plausible in a log, so two exporters could pick different ones and silently fail to join. | Setting | `signed_url_hash` | `signed_url_full` | Use Case | |---|---|---|---| | `log_full_signed_url=false` (default) | SHA-256 hash | omitted | Production: privacy, minimal attack surface | | `log_full_signed_url=true` | SHA-256 hash | full URL | Debug/security: CDN troubleshooting, leak forensics | +**The default-off posture applies to every store that holds delivery facts, not only to this log.** A signed URL is a live bearer capability until it expires: anyone who can read it can fetch the paid content without paying. Expiry bounds that window to minutes, but a store's retention does not — the transaction log keeps rows for 13 months and the evidence store keeps them at least that long, so an unconditionally stored URL is minutes of capability sitting inside years of retained text. + +**Whether the flag binds depends on the signing scheme.** An agent-bound URL whose edge enforces proof of possession is not a capability at rest — a reader of the store cannot fetch with it — and may be stored unconditionally. A natively-verified URL is a capability until it expires, and belongs behind the flag. The full rule, with the reasoning and the cost of choosing default-off, is stated once on the [Exchange storage model](/components/exchange/storage-model/#storing-the-full-signed-url); everything that stores a full signed URL follows it. + +This is separate from the rule that the operator plane's `TransactionEvidence` message carries no URL field. That one is structural and cannot be configured away. This one is a deployment posture. + ### Batch and Subscription Transactions **Batch**: Each item in a batch gets its own `transaction_id`, `billing_id`, and signed URL. The WAL write-before-sign invariant applies per item. A failure on item N does not affect already-committed items 1 through N-1. @@ -179,7 +215,7 @@ Logged when a `ReportUsage` RPC is received. Completes the transaction lifecycle | `event_type` | `EventType` | yes | `EVENT_USAGE_REPORT_RECEIVED` | | `timestamp` | `time.Time` | yes | Server receipt time | | `report_id` | `string` | yes | Exchange-assigned report id (`UsageReportResponse.report_id`) | -| `idempotency_key` | `string` | yes | `UsageReport.idempotency_key` (the report's idempotency key) | +| `idempotency_key` | `string` | yes | `UsageReport.idempotency_key` verbatim — the report's own key, NOT a derived per-item key. A report addresses one whole transaction and has no `offer_id` to derive with. Join this row on `transaction_id`. | | `transaction_id` | `string` | yes | `UsageReport.transaction_id` | | `billing_id` | `string` | yes | `UsageReport.billing_id` | | `buyer_lid` | `string` | yes | Extracted from transaction record | diff --git a/website/src/content/docs/components/transaction-log/reconciliation.mdx b/website/src/content/docs/components/transaction-log/reconciliation.mdx index 39b1e61b..2bcc3ea2 100644 --- a/website/src/content/docs/components/transaction-log/reconciliation.mdx +++ b/website/src/content/docs/components/transaction-log/reconciliation.mdx @@ -101,7 +101,13 @@ WHERE mp.signed_url_hash IS NULL AND cdn.status_code = 200; ``` -For this to work, the `transaction_id` must be embedded in the signed URL parameters. The provider extracts it from their CDN logs and joins against the Exchange export. +**Both sides must spell the hash the same way.** This query joins two *exports*, not two live tables, and that is where an encoding enters. In the Exchange's own store the digest is 32 raw bytes; an export renders it, and this contract renders it as **lowercase hex** (see [Signed URL Logging](/components/transaction-log/event-types/#signed-url-logging)). + +A CDN access log records the URL that was fetched, not a digest of it, so `cdn.signed_url_hash` is a column the provider derives before the join. Derive it the same way: SHA-256 over the full requested URL including its query string, rendered as lowercase hex. Any other spelling — uppercase hex, base64, or a digest taken over the path alone — makes every row miss the join, and a miss here reads as revenue leakage rather than as a formatting mistake. + +This query only sees transactions that minted a signed URL. `DELIVERY_METHOD_DIRECT` transactions have no `signed_url_hash` on either side and never appear in a CDN access log at all, so they are outside this reconciliation rather than missing from it. + +The join also requires the `transaction_id` to be embedded in the signed URL parameters. The provider extracts it from their CDN logs to identify *which* transaction a matched row belongs to, once the hash join has established that a match exists. ## Dispute Detection diff --git a/website/src/content/docs/components/transaction-log/storage-backends.mdx b/website/src/content/docs/components/transaction-log/storage-backends.mdx index c3017581..76575f77 100644 --- a/website/src/content/docs/components/transaction-log/storage-backends.mdx +++ b/website/src/content/docs/components/transaction-log/storage-backends.mdx @@ -187,9 +187,25 @@ type TransactionEvent struct { TransactionID string `json:"transaction_id,omitempty" db:"transaction_id"` BillingID string `json:"billing_id,omitempty" db:"billing_id"` - // The request's idempotency key (TransactionRequest.idempotency_key / - // UsageReport.idempotency_key). Durable dedupe key, NOT the wire - // correlation id — correlation rides the X-Request-ID HTTP header. + // The dedupe key of whatever the event records. Which key that is depends + // on the event type, and the two are never interchangeable: + // + // - Transaction-side events (TransactionRequested, + // BillingAuthorizationResult, SignedURLIssued) store the DERIVED + // per-item key — TransactionRequest.idempotency_key + ":" + offer_id — + // so each item of a multi-item batch dedupes independently. Derived + // unconditionally, single-item requests included; the bare + // request-level key is never stored on these events. + // - UsageReportReceived stores UsageReport.idempotency_key verbatim. A + // report addresses one whole transaction and has no offer_id to derive + // with, so there is nothing to append. + // + // A ledger joining ramp.admin.v1.TransactionState.idempotency_key against + // this column therefore reaches the transaction-side events and does not + // reach UsageReportReceived — that row is joined on transaction_id instead. + // + // Durable dedupe key either way, NOT the wire correlation id — correlation + // rides the X-Request-ID HTTP header. IdempotencyKey string `json:"idempotency_key,omitempty" db:"idempotency_key"` OfferID string `json:"offer_id,omitempty" db:"offer_id"` SubscriptionID string `json:"subscription_id,omitempty" db:"subscription_id"` diff --git a/website/src/content/docs/getting-started/for-verification-vendors.mdx b/website/src/content/docs/getting-started/for-verification-vendors.mdx index 37f2a7da..5cf333a3 100644 --- a/website/src/content/docs/getting-started/for-verification-vendors.mdx +++ b/website/src/content/docs/getting-started/for-verification-vendors.mdx @@ -43,7 +43,7 @@ The `claims` field is a flexible `Struct` — it carries whatever your domain ne "content_hash": "doubleverify-v1:x9y8z7w6v5u4t3s2r1q0p9o8n7m6l5k4", "hash_method": "doubleverify-v1" }, - "signature": "base64-encoded-ed25519-signature" + "signature": "e0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32df" } ``` @@ -65,7 +65,7 @@ The `claims` field is a flexible `Struct` — it carries whatever your domain ne "compliance_framework": "HIPAA Safe Harbor", "audit_standard": "HHS 45 CFR 164.514(b)" }, - "signature": "base64-encoded-ed25519-signature" + "signature": "e0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32df" } ``` @@ -134,7 +134,7 @@ POST /ramp.v1.CatalogService/PushResources "content_hash": "gumgum-v1:a1b2c3d4...", "hash_method": "gumgum-v1" }, - "signature": "base64-encoded-ed25519-signature" + "signature": "e0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32df" } ] } diff --git a/website/src/content/docs/getting-started/poc-walkthrough.mdx b/website/src/content/docs/getting-started/poc-walkthrough.mdx index 2eb3ecb9..c1d5e10d 100644 --- a/website/src/content/docs/getting-started/poc-walkthrough.mdx +++ b/website/src/content/docs/getting-started/poc-walkthrough.mdx @@ -126,7 +126,7 @@ The response contains **OfferGroups** — one per requested URI — each with Ed "window": "86400s", "required_fields": ["consumed_quantity"] }, - "signature": "ae77b181...32607e08", + "signature": "cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660", "signature_algorithm": "EdDSA" } ] @@ -138,7 +138,7 @@ The response contains **OfferGroups** — one per requested URI — each with Ed Key fields in each offer: - **offer_id** — unique identifier for this offer - **pricing** — rate ($0.05/article), currency, estimated tokens. Pricing models are defined in the [protocol spec](/protocol/standards-layering) and follow [RSL conventions](/protocol/discovery-paths) -- **exchange_signature** — [Ed25519 signature](/protocol/authentication) over the offer. Stateless: the Exchange can verify this signature on ExecuteTransaction without storing the offer +- **signature** — [Ed25519 signature](/protocol/authentication) over the offer. Stateless: the Exchange can verify this signature on ExecuteTransaction without storing the offer - **reporting** — usage reporting is mandatory, due within 24 hours, must include `consumed_quantity`. See [how the billing cycle works](/getting-started/how-money-flows) - **title** — human-readable offer title, plus optional `ramp-comp-v1` `comp.*` ext fields (e.g. `comp.package_id`) carrying [IAB CoMP](/protocol/standards-layering) package metadata @@ -166,11 +166,11 @@ curl -s -X POST https://exchange.ramp-protocol.org/ramp.v1.ExchangeService/Execu }, "delivery_method": "DELIVERY_METHOD_INSTRUCTIONS", "expires_at": "2026-03-16T17:43:20Z", - "signature": "ae77b181...32607e08", + "signature": "cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660cd3f6660", "signature_algorithm": "EdDSA" }, "agent_acceptance": { - "signature": "base64-ed25519-acceptance-sig...", + "signature": "07a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb8507a2fb85", "signature_algorithm": "EdDSA" } } @@ -207,7 +207,7 @@ Key fields: - **retrieval_endpoint** — the [signed URL](/components/edge-function/signed-url-verification) with HMAC-SHA256 signature, expiry, agent identity binding, and transaction ID - **cost** — $0.05 USD charged. See [how money flows](/getting-started/how-money-flows) for the full billing lifecycle - **reporting_obligation** — you MUST report usage within 24 hours. See [budget and reporting](/components/agent-sdk/budget-reporting) for how the Agent SDK handles this -- **agent_identity_hash** — RFC 7638 JWK Thumbprint of your Ed25519 request-signing key, bound into the signed URL to [prevent URL sharing](/security/threat-model). See [Signed URL Verification](/components/edge-function/signed-url-verification) +- **agent_identity_hash** — RFC 7638 JWK Thumbprint of the Ed25519 key you accepted the offer with, bound into the signed URL to [prevent URL sharing](/security/threat-model). Accept and fetch with the same key. See [Signed URL Verification](/components/edge-function/signed-url-verification) ### Step 5: Fetch Resource via [Signed URL](/components/edge-function/signed-url-verification) diff --git a/website/src/content/docs/protocol/authentication.mdx b/website/src/content/docs/protocol/authentication.mdx index fa3cad7f..141a9812 100644 --- a/website/src/content/docs/protocol/authentication.mdx +++ b/website/src/content/docs/protocol/authentication.mdx @@ -373,9 +373,10 @@ RFC 9421 is the **only** request-authentication mechanism. A request without a v A signed `retrieval_endpoint` returned by a transaction is, by default, a bearer token: anyone holding it before `expires_at` can fetch. RAMP **optionally** binds the URL to the purchasing agent, DPoP-style ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)): -- The Exchange embeds `agent_identity_hash` — the [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) JWK Thumbprint (SHA-256) of the agent's Ed25519 request-signing key — inside the HMAC-signed URL, and echoes it on the execute-path response (`TransactionResponse.agent_identity_hash`, produced directly by the Exchange — the Broker is not in the execute response path for this field). +- The Exchange embeds `agent_identity_hash` — the [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) JWK Thumbprint (SHA-256) of the agent's Ed25519 **acceptance key**, the key whose signature over `AgentAcceptancePayload` the Exchange verified — inside the signed URL, and echoes it on the execute-path response (`TransactionResponse.agent_identity_hash`, produced directly by the Exchange — the Broker is not in the execute response path for this field). - A capable delivery endpoint (edge function) verifies the binding **fully offline**: confirm the URL HMAC (proves the hash is Exchange-issued and untampered), then require the fetcher to present its public key and an RFC 9421 signature over the retrieval request, and check `thumbprint(presented key) == agent_identity_hash`. No JWKS fetch required. -- Bound to the agent's request-signing key, never to the principal/delegation — the agent is the fetcher, and is exactly one key per transaction. +- Bound to the agent's acceptance key, never to the principal/delegation — the agent is the fetcher, and is exactly one key per transaction. The acceptance is the anchor because it is the only agent-authored signature guaranteed to be present: a Broker may author a re-packaged transaction as sender, and on that leg the RFC 9421 signer is the broker, not the agent. On an ordinary direct hop the agent signs both the request and the acceptance with the same key, so the two readings coincide. +- **One-key rule.** An agent MUST accept an offer and fetch the resource with the same key. The URL is bound to the acceptance key, and an enforcing endpoint checks the fetching key against that binding, so accepting with one key and fetching with another yields a transaction that succeeds and a retrieval that is refused. A custodial registry holding the agent's single key and making the bound fetch itself satisfies this with nothing extra to do. - Enforcement is **NOT mandatory**: a bearer-only signed-URL CDN that cannot run code falls back to HMAC + short TTL + TLS. RAMP reference implementations run on edge functions and **do** enforce it. See [Signed URL Verification](/components/edge-function/signed-url-verification) for the full edge-function verification flow and the stolen-URL threat analysis. diff --git a/website/src/content/docs/protocol/content-attestation.mdx b/website/src/content/docs/protocol/content-attestation.mdx index 53c920b3..218bb33c 100644 --- a/website/src/content/docs/protocol/content-attestation.mdx +++ b/website/src/content/docs/protocol/content-attestation.mdx @@ -56,7 +56,7 @@ message ResourceAttestation { "word_count": 2424, "language": "en" }, - "signature": "base64-encoded-ed25519-signature" + "signature": "e0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32df" } ``` @@ -75,7 +75,7 @@ message ResourceAttestation { "content_hash": "doubleverify-v1:x9y8z7...", "hash_method": "doubleverify-v1" }, - "signature": "base64-encoded-ed25519-signature" + "signature": "e0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32df" } ``` @@ -167,9 +167,12 @@ Only `estimated_quantity` and `content_hash` are auto-disputable -- they are obj ``` canonical_bytes = utf8_encode(jcs_output) signature = ed25519_sign(canonical_bytes, private_key) -attestation.signature = base64_encode(signature) +attestation.signature = hex_encode(signature) // 128 characters ``` +The signature is hex, like every other detached signature in this contract. The field rule is +`^[0-9A-Fa-f]{128}$`, so either case is accepted on the wire and a base64 value is refused. + ## Multiple Attestations An offer may carry multiple attestations from different parties: diff --git a/website/src/content/docs/protocol/ext-academic.mdx b/website/src/content/docs/protocol/ext-academic.mdx index 4505a059..3b2d31a2 100644 --- a/website/src/content/docs/protocol/ext-academic.mdx +++ b/website/src/content/docs/protocol/ext-academic.mdx @@ -336,7 +336,7 @@ An agent queries for a Nature article. No institutional subscription is availabl "word_count": 9400, "language": "en" }, - "signature": "base64-ed25519-signature..." + "signature": "14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e14974d4e" } ], "ext": { diff --git a/website/src/content/docs/protocol/ext-c2pa.mdx b/website/src/content/docs/protocol/ext-c2pa.mdx index b1f3d312..ea9013dd 100644 --- a/website/src/content/docs/protocol/ext-c2pa.mdx +++ b/website/src/content/docs/protocol/ext-c2pa.mdx @@ -145,7 +145,7 @@ When a C2PA verification vendor validates a manifest, it publishes results as a "content_hash": "sha256:a1b2c3d4e5f6...", "hash_method": "sha256" }, - "signature": "base64-encoded-ed25519-signature" + "signature": "e0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32df" } ``` @@ -199,7 +199,7 @@ C2PA and RAMP use different PKI: |--------|------|------| | Key format | X.509 certificates | Ed25519 JWKS | | Trust model | Centrally curated Trust Lists (CA hierarchy) | Decentralized (provider ↔ exchange manifests) | -| Signature format | COSE Sign1 (RFC 9052) | JWS Compact (RFC 7515) | +| Signature format | COSE Sign1 (RFC 9052) | Detached Ed25519 over RFC 8785 JCS, hex-encoded | | Timestamping | RFC 3161 TSA (cryptographic proof) | Self-declared `attested_at` | The verification vendor bridges these worlds: @@ -208,7 +208,7 @@ The verification vendor bridges these worlds: C2PA World RAMP World ───────────────── ────────────────── X.509 cert chain Ed25519 JWKS -COSE Sign1 signature ──► JWS EdDSA signature +COSE Sign1 signature ──► Detached Ed25519 (hex) C2PA Trust List Vendor RAMP accepted_verifiers JUMBF manifest bridges ResourceAttestation.claims RFC 3161 TSA attested_at timestamp @@ -251,7 +251,7 @@ Both C2PA and RAMP support Ed25519/EdDSA. A verification vendor that holds both | Algorithm | C2PA Support | RAMP Support | |-----------|-------------|-------------| -| Ed25519 | Yes (EdDSA via COSE) | Yes (EdDSA via JWS) | +| Ed25519 | Yes (EdDSA via COSE) | Yes (detached EdDSA, hex) | | ECDSA P-256 | Yes | No | | RSA | Yes | No | | SHA-256 | Yes (hard binding) | Yes (content_hash) | diff --git a/website/src/content/docs/protocol/ext-news.mdx b/website/src/content/docs/protocol/ext-news.mdx index 0f2c4b48..55ed3895 100644 --- a/website/src/content/docs/protocol/ext-news.mdx +++ b/website/src/content/docs/protocol/ext-news.mdx @@ -161,7 +161,7 @@ These claim names extend the core attestation vocabulary for news content. A pro "news.source_organization": "The New York Times", "news.correction_of": "https://www.nytimes.com/2026/03/18/business/trade-deal.html" }, - "signature": "base64-encoded-ed25519-signature" + "signature": "e0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32dfe0ee32df" } ``` @@ -383,7 +383,7 @@ An Exchange returns an offer for a New York Times article. The article has been "news.date_published": "2026-03-19T08:15:00Z", "news.source_organization": "The New York Times" }, - "signature": "ZXhhbXBsZS1zaWduYXR1cmU..." + "signature": "3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd3ee7a0dd" } ], "ext": { @@ -403,7 +403,7 @@ An Exchange returns an offer for a New York Times article. The article has been "news.copyright_notice": "Copyright 2026 The New York Times Company. All rights reserved.", "news.tdm_reservation": true }, - "signature": "ZXhjaGFuZ2Utc2lnbmF0dXJl...", + "signature": "1961f0901961f0901961f0901961f0901961f0901961f0901961f0901961f0901961f0901961f0901961f0901961f0901961f0901961f0901961f0901961f090", "signature_algorithm": "EdDSA" } ``` @@ -476,7 +476,7 @@ An Exchange returns an offer for an NPR podcast episode with transcript and mult } ] }, - "signature": "bnByLXBvZGNhc3Qtc2lnbmF0dXJl...", + "signature": "bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5bad2dde5", "signature_algorithm": "EdDSA" } ``` @@ -525,7 +525,7 @@ An agent previously purchased version 1 of an article. The publisher issues a co "news.version": 2, "news.correction_of": "https://apnews.com/article/election-results-2026?v=1" }, - "signature": "Y29ycmVjdGlvbi1zaWduYXR1cmU..." + "signature": "7011de427011de427011de427011de427011de427011de427011de427011de427011de427011de427011de427011de427011de427011de427011de427011de42" } ], "ext": { diff --git a/website/src/content/docs/protocol/role-composition.mdx b/website/src/content/docs/protocol/role-composition.mdx index 29b5d433..61fbf646 100644 --- a/website/src/content/docs/protocol/role-composition.mdx +++ b/website/src/content/docs/protocol/role-composition.mdx @@ -35,7 +35,7 @@ The `WellKnownManifest.role` field identifies which role a given subdomain holds Three properties keep the trust model intact under multi-role operation: 1. **Per-role key separation.** Each role publishes its own keys in its own WBA directory — the JOSE JWK Set (`WBAFile.keys`) served at `/.well-known/http-message-signatures-directory` for that role's domain, with each key identified by its RFC 7638 JWK Thumbprint (the RFC 9421 `keyid`). Keys are no longer carried in `WellKnownManifest`. An operator's Exchange key cannot sign messages that an Agent role would accept, and vice versa, because consumers look up keys by `(domain, role)`, not by operator. -2. **Cryptographic transaction boundaries.** Every `Offer` carries an `exchange_signature` produced by the Exchange (JWS / Ed25519). Cross-role HTTP exchanges (Agent → Broker, Broker → Exchange, Agent → Exchange) are authenticated at the transport layer via RFC 9421 HTTP Message Signatures applied **hop-by-hop** — each forwarding party adds its own labeled `Signature` header covering the request plus the prior hop's signature as the request passes through. The ordered stack of these labeled signatures in the HTTP headers *is* the forwarding chain; each hop's transport-layer signature proves its participation independently of the others, and the Exchange verifies the whole stack and counts hops against `max_hops` / `max_intermediary_hops`. The forwarding path is established at discovery and the Exchange correlates the commit back to the original `ResourceQuery` via the `X-Request-ID` HTTP header propagated across the round-trip (there is no `request_id` message field). A Broker run by the same operator as an Exchange therefore cannot fabricate, suppress, or rewrite a hop's contribution without breaking that hop's signature in the header stack — which any auditor with access to the Exchange's request logs can detect. +2. **Cryptographic transaction boundaries.** Every `Offer` carries a `signature` produced by the Exchange (detached hex Ed25519, not a JWS). Cross-role HTTP exchanges (Agent → Broker, Broker → Exchange, Agent → Exchange) are authenticated at the transport layer via RFC 9421 HTTP Message Signatures applied **hop-by-hop** — each forwarding party adds its own labeled `Signature` header covering the request plus the prior hop's signature as the request passes through. The ordered stack of these labeled signatures in the HTTP headers *is* the forwarding chain; each hop's transport-layer signature proves its participation independently of the others, and the Exchange verifies the whole stack and counts hops against `max_hops` / `max_intermediary_hops`. The forwarding path is established at discovery and the Exchange correlates the commit back to the original `ResourceQuery` via the `X-Request-ID` HTTP header propagated across the round-trip (no live request or response carries a `request_id` field in its body; the admin plane's forensic evidence row states the persisted value after the fact). A Broker run by the same operator as an Exchange therefore cannot fabricate, suppress, or rewrite a hop's contribution without breaking that hop's signature in the header stack — which any auditor with access to the Exchange's request logs can detect. 3. **Independent reconciliation records.** Settlement reconciliation uses three independent records (Exchange ledger, CDN access log, `UsageReport`) that must agree within the published tolerance — see [Transaction Flow → Reconciliation via Signed URLs](/protocol/transaction-flow/#reconciliation-via-signed-urls). Collusion between two co-operated roles still has to produce consistent records across all three, which is detectable by any auditor with access to the CDN logs and the dispute record. In other words: the protocol does not assume that distinct roles are run by distinct operators. It assumes that distinct roles produce distinct, independently verifiable signatures and records. Multi-role operators inherit that property by following the standard manifest discipline. diff --git a/website/src/content/docs/protocol/scenario-walkthrough.mdx b/website/src/content/docs/protocol/scenario-walkthrough.mdx index 5489eabc..c7dd5efb 100644 --- a/website/src/content/docs/protocol/scenario-walkthrough.mdx +++ b/website/src/content/docs/protocol/scenario-walkthrough.mdx @@ -122,7 +122,7 @@ SSP-Alpha's Resource Ingestion Pipeline runs in the background: 5. Third-party attestation (GumGum via CatalogService.PushResources): GumGum crawls the article, signs attestation claims with Ed25519: { verifier: "gumgum.com", claims: { estimated_quantity: 3300, word_count: 2500, - language: "en", iab_categories: ["IAB19-6"] }, signature: "..." } + language: "en", iab_categories: ["IAB19-6"] }, signature: "2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f" } Exchange validates: gumgum.com is in techcrunch.com's catalog_contributors, verifies signature against the key in gumgum.com's WBA directory (the JWK Set at gumgum.com/.well-known/http-message-signatures-directory), @@ -147,7 +147,7 @@ SSP-Alpha's Resource Ingestion Pipeline runs in the background: attested_at: "2026-03-14T09:00:00Z", uri: "https://techcrunch.com/premium/ai-regulation-2026.html", claims: { estimated_quantity: 3300, word_count: 2500, language: "en" }, - signature: "base64-ed25519-gumgum..." } + signature: "acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42" } ], terms: [ { semantics: TERM_SEMANTICS_ENUMERATED, @@ -290,7 +290,7 @@ The SDK sends `DiscoverResources` to SSP-Alpha — `POST https://exchange.ssp-al attestations: [ { verifier: "gumgum.com", keyid: "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", claims: { estimated_quantity: 3300, word_count: 2500, language: "en" }, - signature: "base64-ed25519-gumgum..." } + signature: "acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42" } ] terms: [ { semantics: TERM_SEMANTICS_ENUMERATED, @@ -302,7 +302,7 @@ The SDK sends `DiscoverResources` to SSP-Alpha — `POST https://exchange.ssp-al ] } ] delivery_method: DELIVERY_METHOD_INSTRUCTIONS - signature: "" + signature: "5914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d6" signature_algorithm: "EdDSA" Offer B (subscription): @@ -321,7 +321,7 @@ The SDK sends `DiscoverResources` to SSP-Alpha — `POST https://exchange.ssp-al delivery_method: DELIVERY_METHOD_INSTRUCTIONS reporting: { required: true, window: "86400s", required_fields: ["transaction_id", "function", "consumed_quantity"] } - signature: "" + signature: "5914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d65914f0d6" signature_algorithm: "EdDSA" ``` @@ -358,7 +358,7 @@ The SDK sends `DiscoverResources` to SSP-Alpha — `POST https://exchange.ssp-al "attested_at": "2026-03-14T09:00:00Z", "uri": "https://techcrunch.com/premium/ai-regulation-2026.html", "claims": { "estimated_quantity": 3300, "word_count": 2500, "language": "en" }, - "signature": "base64-ed25519-gumgum..." + "signature": "acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42" } ], "terms": [ @@ -373,7 +373,7 @@ The SDK sends `DiscoverResources` to SSP-Alpha — `POST https://exchange.ssp-al ] } ], - "signature": "base64-ed25519-sig-A...", + "signature": "a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6", "signature_algorithm": "EdDSA" }, { @@ -407,7 +407,7 @@ The SDK sends `DiscoverResources` to SSP-Alpha — `POST https://exchange.ssp-al "attested_at": "2026-03-14T09:00:00Z", "uri": "https://techcrunch.com/premium/ai-regulation-2026.html", "claims": { "estimated_quantity": 3300, "word_count": 2500, "language": "en" }, - "signature": "base64-ed25519-gumgum..." + "signature": "acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42acb24b42" } ], "terms": [ @@ -423,7 +423,7 @@ The SDK sends `DiscoverResources` to SSP-Alpha — `POST https://exchange.ssp-al "scopes": ["subscription:SUB-ANTHROPIC-HEARST-2026"] } ], - "signature": "base64-ed25519-sig-B...", + "signature": "cd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811a", "signature_algorithm": "EdDSA" } ] @@ -510,8 +510,8 @@ SSP-Alpha returns `offer_groups` (batch mode -- multiple URIs in query): "exchange": "exchange.ssp-alpha.com", "pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.05", "currency": "USD", "unit": "accesses", "unit_cost": "0.00001515", "estimated_quantity": 3300 }, "identity": { "canonical_url": "https://techcrunch.com/premium/ai-regulation-2026.html", "iptc_guid": "urn:newsml:techcrunch:20260315:ai-reg-001", "content_hash": "a1b2c3d4e5f6...", "hash_method": "simhash-v1" }, - "attestations": [{ "verifier": "gumgum.com", "claims": { "estimated_quantity": 3300, "word_count": 2500 }, "signature": "..." }], - "signature": "base64-ed25519-sig-A...", + "attestations": [{ "verifier": "gumgum.com", "claims": { "estimated_quantity": 3300, "word_count": 2500 }, "signature": "2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f" }], + "signature": "a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6a1a475b6", "signature_algorithm": "EdDSA" }, { @@ -520,9 +520,9 @@ SSP-Alpha returns `offer_groups` (batch mode -- multiple URIs in query): "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0", "currency": "USD", "unit_cost": "0", "estimated_quantity": 3300 }, "subscription_id": "SUB-ANTHROPIC-HEARST-2026", "identity": { "canonical_url": "https://techcrunch.com/premium/ai-regulation-2026.html", "iptc_guid": "urn:newsml:techcrunch:20260315:ai-reg-001", "content_hash": "a1b2c3d4e5f6...", "hash_method": "simhash-v1" }, - "attestations": [{ "verifier": "gumgum.com", "claims": { "estimated_quantity": 3300, "word_count": 2500 }, "signature": "..." }], + "attestations": [{ "verifier": "gumgum.com", "claims": { "estimated_quantity": 3300, "word_count": 2500 }, "signature": "2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f" }], "reporting": { "required": true, "window": "86400s", "required_fields": ["transaction_id", "function", "consumed_quantity"] }, - "signature": "base64-ed25519-sig-B...", + "signature": "cd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811a", "signature_algorithm": "EdDSA" } ] @@ -554,8 +554,8 @@ Note: SSP-Alpha returns an empty `offers` array for the `theverge.com` URI with "content_hash": "a1b2c3d4e5f7...", "hash_method": "simhash-v1" }, - "attestations": [{ "verifier": "gumgum.com", "claims": { "estimated_quantity": 3100, "word_count": 2350 }, "signature": "..." }], - "signature": "base64-ed25519-sig-C...", + "attestations": [{ "verifier": "gumgum.com", "claims": { "estimated_quantity": 3100, "word_count": 2350 }, "signature": "2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f" }], + "signature": "056786f6056786f6056786f6056786f6056786f6056786f6056786f6056786f6056786f6056786f6056786f6056786f6056786f6056786f6056786f6056786f6", "signature_algorithm": "EdDSA" } ] @@ -651,11 +651,11 @@ SDK or Broker commits to the subscription offer. "hash_method": "simhash-v1", "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-sig-B...", + "signature": "b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21", "signature_algorithm": "EdDSA" }, "agent_acceptance": { - "signature": "base64-ed25519-acceptance-sig...", + "signature": "acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11", "signature_algorithm": "EdDSA" } } @@ -701,11 +701,11 @@ SDK or Broker commits to the subscription offer. "hash_method": "simhash-v1", "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-sig-B...", + "signature": "b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21b0000b21", "signature_algorithm": "EdDSA" }, "agent_acceptance": { - "signature": "base64-ed25519-acceptance-sig...", + "signature": "acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11", "signature_algorithm": "EdDSA" } } @@ -727,7 +727,7 @@ The request body is identical to the direct case; the forwarding chain lives in - Ed25519 verify each hop's signature -> pass 3. Check idempotency: tx-claude-001 not seen before -> proceed 4. Verify offer signature: - - Ed25519 verify exchange_signature on offer -> pass + - Ed25519 verify signature on offer -> pass - Reconstruct offer data from signed token (stateless, no offer storage) 5. subscription_id present on offer -> SKIP billing.Authorize (already paid under subscription) 6. Quota check: @@ -746,7 +746,7 @@ The request body is identical to the direct case; the forwarding chain lives in subscription_id: "SUB-ANTHROPIC-HEARST-2026", amount: "0", agent_identity_hash: "e3b0c442...", - offer_snapshot_json: "", + offer_snapshot_json: "", reporting_required: true, reporting_deadline: "2026-03-16T15:00:00Z" } @@ -900,7 +900,7 @@ Three independent records that must agree: - txn-alpha-001: subscription SUB-ANTHROPIC-HEARST-2026, amount=$0 - subscription_unit_value: $0.05 (value of the access for accounting) - offer_snapshot: article "AI Regulation...", 3300 est tokens - - offer_snapshot includes exchange_signature (Ed25519, non-repudiable) + - offer_snapshot includes signature (Ed25519, non-repudiable) - reporting_deadline: 2026-03-16T15:00:00Z 3. Usage report (Anthropic filed): @@ -942,7 +942,7 @@ Response includes signed Offer snapshots for every transaction: "exchange": "exchange.ssp-alpha.com", "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0", "currency": "USD" }, "subscription_id": "SUB-ANTHROPIC-HEARST-2026", - "signature": "base64-ed25519-sig-B...", + "signature": "cd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811acd97811a", "signature_algorithm": "EdDSA" }, "cost": { "amount": "0", "currency": "USD" }, @@ -956,7 +956,7 @@ Response includes signed Offer snapshots for every transaction: **What Hearst can verify**: -1. **Offer authenticity**: Hearst verifies the `exchange_signature` on each `offer_snapshot` using SSP-Alpha's published Ed25519 public key (fetch SSP-Alpha's WBA directory at `exchange.ssp-alpha.com/.well-known/http-message-signatures-directory` and read its `keys`). This proves the Exchange actually issued this Offer -- it cannot deny having offered these terms. +1. **Offer authenticity**: Hearst verifies the `signature` on each `offer_snapshot` using SSP-Alpha's published Ed25519 public key (fetch SSP-Alpha's WBA directory at `exchange.ssp-alpha.com/.well-known/http-message-signatures-directory` and read its `keys`). This proves the Exchange actually issued this Offer -- it cannot deny having offered these terms. 2. **RSL price ceiling compliance**: Hearst's RSL declares a maximum rate of $0.08/crawl. The subscription's `subscription_unit_value` of $0.05 is below this ceiling. If any per-request Offer exceeded $0.08, Hearst would detect it by comparing `offer_snapshot.pricing.rate` against their RSL terms. diff --git a/website/src/content/docs/protocol/transaction-flow.mdx b/website/src/content/docs/protocol/transaction-flow.mdx index 42d15d2e..e70e99de 100644 --- a/website/src/content/docs/protocol/transaction-flow.mdx +++ b/website/src/content/docs/protocol/transaction-flow.mdx @@ -120,7 +120,7 @@ Key fields: "size": "sample" } ], - "signature": "a1b2c3d4e5f6...", + "signature": "aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7aaf974b7", "signature_algorithm": "EdDSA" } ] @@ -134,7 +134,7 @@ Each `Offer` includes: - `LicenseTerm` entries on `terms[]`, each carrying `restrictions` (`repeated Restriction`) derived from the provider's RSL terms - `ReportingObligation` defining post-usage reporting requirements - `Preview` URLs for pre-transaction evaluation (see [Resource Previews](#resource-previews)) -- An Ed25519 `exchange_signature` that proves the Exchange issued this offer +- An Ed25519 `signature` that proves the Exchange issued this offer ### Batch Multi-URL Query @@ -152,7 +152,7 @@ When requesting multiple URIs, the response uses `offer_groups` instead of `offe "offer_id": "offer-tc-4921", "exchange": "exchange.ssp-example.com", "pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.05", "currency": "USD", "unit_cost": "0.00001515", "unit": "accesses" }, - "signature": "a1b2c3...", + "signature": "2b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a4", "signature_algorithm": "EdDSA" } ] @@ -164,7 +164,7 @@ When requesting multiple URIs, the response uses `offer_groups` instead of `offe "offer_id": "offer-tc-4922", "exchange": "exchange.ssp-example.com", "pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.05", "currency": "USD", "unit_cost": "0.00001200", "unit": "accesses" }, - "signature": "d4e5f6...", + "signature": "b56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0fb56fde0f", "signature_algorithm": "EdDSA" } ] @@ -190,7 +190,7 @@ When the requester has a subscription, the Exchange includes both per-request an "offer_id": "offer-tc-4921", "exchange": "exchange.ssp-example.com", "pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.05", "currency": "USD", "unit_cost": "0.00001515", "unit": "accesses" }, - "signature": "a1b2c3...", + "signature": "2b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a42b7432a4", "signature_algorithm": "EdDSA" }, { @@ -200,7 +200,7 @@ When the requester has a subscription, the Exchange includes both per-request an "subscription_id": "SUB-12345", "terms": [ { "semantics": "TERM_SEMANTICS_ENUMERATED", "scopes": ["subscription:SUB-12345"] } ], "reporting": { "required": true, "window": "86400s", "required_fields": ["transaction_id", "function", "consumed_quantity"] }, - "signature": "g7h8i9...", + "signature": "8a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a991342", "signature_algorithm": "EdDSA" } ] @@ -256,7 +256,7 @@ When the requester holds a subscription, the Exchange attaches `SubscriptionQuot "unit": "spend_cents" } ], - "signature": "g7h8i9...", + "signature": "8a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a9913428a991342", "signature_algorithm": "EdDSA" } ] @@ -303,7 +303,7 @@ Content-Type: application/json }, "delivery_method": "DELIVERY_METHOD_INSTRUCTIONS", "expires_at": "2026-03-14T02:30:00Z", - "signature": "a1b2c3d4e5f6...", + "signature": "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", "signature_algorithm": "EdDSA" } } @@ -377,7 +377,7 @@ For batch transactions, use the `items` array: "exchange": "exchange.ssp-example.com", "pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.05", "currency": "USD", "unit_cost": "0.00001515", "unit": "accesses" }, "expires_at": "2026-03-14T02:30:00Z", - "signature": "a1b2c3...", + "signature": "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4", "signature_algorithm": "EdDSA" } }, @@ -387,7 +387,7 @@ For batch transactions, use the `items` array: "exchange": "exchange.ssp-example.com", "pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.05", "currency": "USD", "unit_cost": "0.00001200", "unit": "accesses" }, "expires_at": "2026-03-14T02:30:00Z", - "signature": "d4e5f6...", + "signature": "d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7", "signature_algorithm": "EdDSA" } } diff --git a/website/src/content/docs/protocol/walkthrough-academic.mdx b/website/src/content/docs/protocol/walkthrough-academic.mdx index 04e1a9b3..4363acc3 100644 --- a/website/src/content/docs/protocol/walkthrough-academic.mdx +++ b/website/src/content/docs/protocol/walkthrough-academic.mdx @@ -254,7 +254,7 @@ The Exchange resolves all 50 DOIs against its catalog, checks the delegation JWT "academic.license": "CC-BY-4.0", "academic.subject_areas": ["cs.CV", "cs.AI"] }, - "signature": "base64-ed25519-arxiv-self-attestation..." + "signature": "5f27b9585f27b9585f27b9585f27b9585f27b9585f27b9585f27b9585f27b9585f27b9585f27b9585f27b9585f27b9585f27b9585f27b9585f27b9585f27b958" } ], "terms": [ @@ -269,7 +269,7 @@ The Exchange resolves all 50 DOIs against its catalog, checks the delegation JWT ] } ], - "signature": "base64-ed25519-arxiv-offer-sig...", + "signature": "2030fbb02030fbb02030fbb02030fbb02030fbb02030fbb02030fbb02030fbb02030fbb02030fbb02030fbb02030fbb02030fbb02030fbb02030fbb02030fbb0", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-ARXIV-2406-11838", @@ -328,7 +328,7 @@ The Exchange resolves all 50 DOIs against its catalog, checks the delegation JWT "academic.retraction_status": "active", "academic.peer_review_status": "peer_reviewed" }, - "signature": "base64-ed25519-elsevier-self-attestation..." + "signature": "c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75c01f7b75" } ], "terms": [ @@ -354,7 +354,7 @@ The Exchange resolves all 50 DOIs against its catalog, checks the delegation JWT "unit": "accesses" } ], - "signature": "base64-ed25519-elsevier-sub-offer-sig...", + "signature": "5b63e4855b63e4855b63e4855b63e4855b63e4855b63e4855b63e4855b63e4855b63e4855b63e4855b63e4855b63e4855b63e4855b63e4855b63e4855b63e485", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-ELSEVIER-MEDIA-103250", @@ -413,7 +413,7 @@ The Exchange resolves all 50 DOIs against its catalog, checks the delegation JWT "academic.retraction_status": "active", "academic.peer_review_status": "peer_reviewed" }, - "signature": "base64-ed25519-springer-self-attestation..." + "signature": "8038d7768038d7768038d7768038d7768038d7768038d7768038d7768038d7768038d7768038d7768038d7768038d7768038d7768038d7768038d7768038d776" } ], "terms": [ @@ -428,7 +428,7 @@ The Exchange resolves all 50 DOIs against its catalog, checks the delegation JWT ] } ], - "signature": "base64-ed25519-springer-paid-offer-sig...", + "signature": "3f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf043f9dcf04", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-SPRINGER-NM-02401", @@ -489,7 +489,7 @@ The Exchange resolves all 50 DOIs against its catalog, checks the delegation JWT "academic.retraction_reason": "data_fabrication", "academic.peer_review_status": "peer_reviewed" }, - "signature": "base64-ed25519-springer-retraction-attestation..." + "signature": "b4413924b4413924b4413924b4413924b4413924b4413924b4413924b4413924b4413924b4413924b4413924b4413924b4413924b4413924b4413924b4413924" } ], "terms": [ @@ -504,7 +504,7 @@ The Exchange resolves all 50 DOIs against its catalog, checks the delegation JWT ] } ], - "signature": "base64-ed25519-springer-retracted-offer-sig...", + "signature": "80f51b7080f51b7080f51b7080f51b7080f51b7080f51b7080f51b7080f51b7080f51b7080f51b7080f51b7080f51b7080f51b7080f51b7080f51b7080f51b70", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-SPRINGER-NM-02467", @@ -661,7 +661,7 @@ The Broker commits to all 49 selected offers in a single batch `TransactionReque "hash_method": "sha256", "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-arxiv-offer-sig...", + "signature": "a11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1da11f0c1d", "signature_algorithm": "EdDSA" } }, @@ -688,7 +688,7 @@ The Broker commits to all 49 selected offers in a single batch `TransactionReque "hash_method": "sha256", "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-elsevier-sub-offer-sig...", + "signature": "e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132e15e4132", "signature_algorithm": "EdDSA" } } @@ -878,7 +878,7 @@ Response: **Authentication at every step:** - **Agent to Exchange**: signed with an RFC 9421 HTTP Message Signature (alg=ed25519) in the HTTP headers - **Institutional delegation**: delegation JWT signed by `stanford.edu`'s Ed25519 key, holder-of-key bound via `cnf.jkt` (offline-verifiable) -- **Exchange to Agent**: `exchange_signature` on each Offer (Ed25519, stateless verification) +- **Exchange to Agent**: `signature` on each Offer (Ed25519, stateless verification) - **Signed URLs**: HMAC-SHA256 (Exchange-CDN shared secret, agent identity bound) - **Content integrity**: SHA-256 hash verification on all 49 delivered PDFs (`RESOURCE_MUTABILITY_STATIC`) diff --git a/website/src/content/docs/protocol/walkthrough-credit-report.mdx b/website/src/content/docs/protocol/walkthrough-credit-report.mdx index c16b13e4..77cc7e48 100644 --- a/website/src/content/docs/protocol/walkthrough-credit-report.mdx +++ b/website/src/content/docs/protocol/walkthrough-credit-report.mdx @@ -174,7 +174,7 @@ The Exchange returns an `OfferGroup` with **three tiered offers** for the same r "credit.data_blocks": ["paydex", "summary"], "credit.score_model": "DNB-PAYDEX-v4.2" }, - "signature": "base64-ed25519-dnb-self-attestation-basic..." + "signature": "228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6228af4f6" } ], "terms": [ @@ -189,7 +189,7 @@ The Exchange returns an `OfferGroup` with **three tiered offers** for the same r ] } ], - "signature": "base64-ed25519-dnb-offer-basic-sig...", + "signature": "7cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb97cd0feb9", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-DNB-123456789-BASIC", @@ -245,7 +245,7 @@ The Exchange returns an `OfferGroup` with **three tiered offers** for the same r "credit.commercial_score": 580, "credit.financial_stress_score": 1420 }, - "signature": "base64-ed25519-dnb-self-attestation-std..." + "signature": "5f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e55f41f1e5" } ], "terms": [ @@ -260,7 +260,7 @@ The Exchange returns an `OfferGroup` with **three tiered offers** for the same r ] } ], - "signature": "base64-ed25519-dnb-offer-std-sig...", + "signature": "9edd17539edd17539edd17539edd17539edd17539edd17539edd17539edd17539edd17539edd17539edd17539edd17539edd17539edd17539edd17539edd1753", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-DNB-123456789-STD", @@ -318,7 +318,7 @@ The Exchange returns an `OfferGroup` with **three tiered offers** for the same r "credit.has_financials": true, "credit.financial_statement_date": "2025-12-31" }, - "signature": "base64-ed25519-dnb-self-attestation-comp..." + "signature": "823a5151823a5151823a5151823a5151823a5151823a5151823a5151823a5151823a5151823a5151823a5151823a5151823a5151823a5151823a5151823a5151" } ], "terms": [ @@ -333,7 +333,7 @@ The Exchange returns an `OfferGroup` with **three tiered offers** for the same r ] } ], - "signature": "base64-ed25519-dnb-offer-comp-sig...", + "signature": "77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d77c8123d", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-DNB-123456789-COMP", @@ -396,7 +396,7 @@ The `rate_limit` on the response signals that D&B enforces per-caller rate limit ## Step 4 — ExecuteTransaction -The agent selects the **standard tier** ($61.99) — sufficient depth for due diligence without the premium cost of the comprehensive report. The agent sends the offer's `exchange_signature` back to the Exchange for stateless verification: +The agent selects the **standard tier** ($61.99) — sufficient depth for due diligence without the premium cost of the comprehensive report. The agent sends the offer's `signature` back to the Exchange for stateless verification: `POST https://exchange.dnb.com/ramp/v1/ramp.v1.ExchangeService/ExecuteTransaction` @@ -428,11 +428,11 @@ The agent selects the **standard tier** ($61.99) — sufficient depth for due di "hash_method": "sha256", "resource_mutability": "RESOURCE_MUTABILITY_DYNAMIC" }, - "signature": "base64-ed25519-dnb-offer-std-sig...", + "signature": "d17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3ed17b0f3e", "signature_algorithm": "EdDSA" }, "agent_acceptance": { - "signature": "base64-ed25519-acceptance-sig...", + "signature": "acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11", "signature_algorithm": "EdDSA" } } diff --git a/website/src/content/docs/protocol/walkthrough-due-diligence.mdx b/website/src/content/docs/protocol/walkthrough-due-diligence.mdx index 95766d5c..2ffdbba9 100644 --- a/website/src/content/docs/protocol/walkthrough-due-diligence.mdx +++ b/website/src/content/docs/protocol/walkthrough-due-diligence.mdx @@ -238,7 +238,7 @@ Each Exchange returns offers in its domain-specific pricing model. The Broker ag "paydex_score": 78, "report_sections": ["financials", "payment_history", "legal_filings", "ownership"] }, - "signature": "base64-ed25519-dnb-self-attestation..." + "signature": "9d04a7859d04a7859d04a7859d04a7859d04a7859d04a7859d04a7859d04a7859d04a7859d04a7859d04a7859d04a7859d04a7859d04a7859d04a7859d04a785" } ], "terms": [ @@ -253,7 +253,7 @@ Each Exchange returns offers in its domain-specific pricing model. The Broker ag ] } ], - "signature": "base64-ed25519-credit-offer-sig...", + "signature": "b8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65eceb8e65ece", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-DNB-ACME-FULL", @@ -311,7 +311,7 @@ Each Exchange returns offers in its domain-specific pricing model. The Broker ag "case_status": "closed", "document_type": "docket" }, - "signature": "base64-ed25519-pacer-attestation..." + "signature": "7898adb27898adb27898adb27898adb27898adb27898adb27898adb27898adb27898adb27898adb27898adb27898adb27898adb27898adb27898adb27898adb2" } ], "terms": [ @@ -326,7 +326,7 @@ Each Exchange returns offers in its domain-specific pricing model. The Broker ag ] } ], - "signature": "base64-ed25519-legal-offer-sig-001...", + "signature": "6560a8036560a8036560a8036560a8036560a8036560a8036560a8036560a8036560a8036560a8036560a8036560a8036560a8036560a8036560a8036560a803", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-PACER-24CV01234", @@ -354,7 +354,7 @@ Each Exchange returns offers in its domain-specific pricing model. The Broker ag "identity": { "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-legal-offer-sig-002...", + "signature": "2fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc872fd5fc87", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-PACER-25CV05678", @@ -382,7 +382,7 @@ Each Exchange returns offers in its domain-specific pricing model. The Broker ag "identity": { "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-legal-offer-sig-003...", + "signature": "e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6e9724ed6", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-PACER-23CV09012", @@ -438,7 +438,7 @@ Each Exchange returns offers in its domain-specific pricing model. The Broker ag ] } ], - "signature": "base64-ed25519-sec-offer-sig-10k-2025...", + "signature": "e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820e9a27820", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-SEC-ACME-10K-2025", @@ -450,15 +450,15 @@ Each Exchange returns offers in its domain-specific pricing model. The Broker ag }, { "uri": "https://sec.gov/Archives/edgar/data/0001234567/10-K-2024.htm", - "offers": [{ "offer_id": "offer-sec-10k-2024", "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0", "currency": "USD" }, "signature": "...", "signature_algorithm": "EdDSA" }] + "offers": [{ "offer_id": "offer-sec-10k-2024", "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0", "currency": "USD" }, "signature": "2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f", "signature_algorithm": "EdDSA" }] }, { "uri": "https://sec.gov/Archives/edgar/data/0001234567/10-Q-2025-Q3.htm", - "offers": [{ "offer_id": "offer-sec-10q-q3", "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0", "currency": "USD" }, "signature": "...", "signature_algorithm": "EdDSA" }] + "offers": [{ "offer_id": "offer-sec-10q-q3", "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0", "currency": "USD" }, "signature": "2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f", "signature_algorithm": "EdDSA" }] }, { "uri": "https://sec.gov/Archives/edgar/data/0001234567/10-Q-2025-Q2.htm", - "offers": [{ "offer_id": "offer-sec-10q-q2", "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0", "currency": "USD" }, "signature": "...", "signature_algorithm": "EdDSA" }] + "offers": [{ "offer_id": "offer-sec-10q-q2", "pricing": { "model": "PRICING_MODEL_FREE", "rate": "0", "currency": "USD" }, "signature": "2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f2f43b42f", "signature_algorithm": "EdDSA" }] } ] } @@ -545,7 +545,7 @@ The Broker sends batch `TransactionRequest` to each Exchange. One request per Ex "canonical_url": "https://creditdata.com/reports/duns/123456789", "resource_mutability": "RESOURCE_MUTABILITY_DYNAMIC" }, - "signature": "base64-ed25519-credit-offer-sig...", + "signature": "c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5c8ed17a5", "signature_algorithm": "EdDSA" } } @@ -590,7 +590,7 @@ The Broker sends batch `TransactionRequest` to each Exchange. One request per Ex "hash_method": "sha256", "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-legal-offer-sig-001...", + "signature": "1e6a10011e6a10011e6a10011e6a10011e6a10011e6a10011e6a10011e6a10011e6a10011e6a10011e6a10011e6a10011e6a10011e6a10011e6a10011e6a1001", "signature_algorithm": "EdDSA" } }, @@ -610,7 +610,7 @@ The Broker sends batch `TransactionRequest` to each Exchange. One request per Ex "identity": { "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-legal-offer-sig-002...", + "signature": "1e6a10021e6a10021e6a10021e6a10021e6a10021e6a10021e6a10021e6a10021e6a10021e6a10021e6a10021e6a10021e6a10021e6a10021e6a10021e6a1002", "signature_algorithm": "EdDSA" } }, @@ -630,7 +630,7 @@ The Broker sends batch `TransactionRequest` to each Exchange. One request per Ex "identity": { "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-legal-offer-sig-003...", + "signature": "1e6a10031e6a10031e6a10031e6a10031e6a10031e6a10031e6a10031e6a10031e6a10031e6a10031e6a10031e6a10031e6a10031e6a10031e6a10031e6a1003", "signature_algorithm": "EdDSA" } } @@ -865,7 +865,7 @@ RAMP replaces the **data acquisition** layer of due diligence, not the analysis. **Authentication at every step:** - **Agent to Broker**: RFC 9421 HTTP Message Signature (alg=ed25519) in HTTP headers - **Broker to Exchange**: the Broker adds its own labeled RFC 9421 HTTP Message Signature (alg=ed25519) in the HTTP headers, covering the forwarded request plus the agent's prior signature; the ordered set of labeled header signatures is the forwarding chain -- **Exchange to Agent**: `exchange_signature` on every Offer (Ed25519, stateless verification) +- **Exchange to Agent**: `signature` on every Offer (Ed25519, stateless verification) - **Signed URLs**: HMAC-SHA256 (Exchange-CDN shared secret, agent identity bound) ## Next Steps diff --git a/website/src/content/docs/protocol/walkthrough-eu-regulation.mdx b/website/src/content/docs/protocol/walkthrough-eu-regulation.mdx index 5a5ef42e..8998643e 100644 --- a/website/src/content/docs/protocol/walkthrough-eu-regulation.mdx +++ b/website/src/content/docs/protocol/walkthrough-eu-regulation.mdx @@ -162,7 +162,7 @@ The Broker receives one response from each Exchange. Here are both offers side b "legal.legislation.consolidation_status": "original", "legal.legislation.in_force": true }, - "signature": "base64-ed25519-opoce-attestation..." + "signature": "30068b1130068b1130068b1130068b1130068b1130068b1130068b1130068b1130068b1130068b1130068b1130068b1130068b1130068b1130068b1130068b11" } ], "terms": [ @@ -198,7 +198,7 @@ The Broker receives one response from each Exchange. Here are both offers side b "ro","sk","sl","sv" ] }, - "signature": "base64-ed25519-eurlex-offer-sig...", + "signature": "975f8405975f8405975f8405975f8405975f8405975f8405975f8405975f8405975f8405975f8405975f8405975f8405975f8405975f8405975f8405975f8405", "signature_algorithm": "EdDSA" } ] @@ -261,7 +261,7 @@ The Broker receives one response from each Exchange. Here are both offers side b "legal.legislation.cross_references": 47, "legal.legislation.in_force": true }, - "signature": "base64-ed25519-wk-self-attestation..." + "signature": "73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb73da0dbb" } ], "terms": [ @@ -298,7 +298,7 @@ The Broker receives one response from each Exchange. Here are both offers side b "legal.legislation.date_consolidation": "2026-01-15", "legal.legislation.languages_available": ["en", "de", "fr", "nl"] }, - "signature": "base64-ed25519-wk-offer-sig...", + "signature": "bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703bd4a9703", "signature_algorithm": "EdDSA" } ] @@ -364,11 +364,11 @@ Assume the agent chooses EUR-Lex (free). The `ExecuteTransaction` follows the st "hash_method": "sha256", "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-eurlex-offer-sig...", + "signature": "e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12e01e6a12", "signature_algorithm": "EdDSA" }, "agent_acceptance": { - "signature": "base64-ed25519-acceptance-sig...", + "signature": "acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11", "signature_algorithm": "EdDSA" } } diff --git a/website/src/content/docs/protocol/walkthrough-medical-imaging.mdx b/website/src/content/docs/protocol/walkthrough-medical-imaging.mdx index 477bead4..5fb1b55c 100644 --- a/website/src/content/docs/protocol/walkthrough-medical-imaging.mdx +++ b/website/src/content/docs/protocol/walkthrough-medical-imaging.mdx @@ -152,7 +152,7 @@ The Exchange looks up the study in TCIA's catalog, verifies the agent's identity "medimg.burned_in_text_removed": true, "medimg.defacing_applied": true }, - "signature": "base64-ed25519-tcia-self-attestation..." + "signature": "06007ed206007ed206007ed206007ed206007ed206007ed206007ed206007ed206007ed206007ed206007ed206007ed206007ed206007ed206007ed206007ed2" } ], "terms": [ @@ -167,7 +167,7 @@ The Exchange looks up the study in TCIA's catalog, verifies the agent's identity ] } ], - "signature": "base64-ed25519-medimg-offer-sig...", + "signature": "9cb330479cb330479cb330479cb330479cb330479cb330479cb330479cb330479cb330479cb330479cb330479cb330479cb330479cb330479cb330479cb33047", "signature_algorithm": "EdDSA", "ext": { "comp.package_id": "PKG-TCIA-TCGA-GBM-0152", @@ -241,11 +241,11 @@ The agent commits to the offer. Because `medimg.dua_required` was flagged as cri "hash_method": "sha256-merkle", "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-medimg-offer-sig...", + "signature": "9ed10a679ed10a679ed10a679ed10a679ed10a679ed10a679ed10a679ed10a679ed10a679ed10a679ed10a679ed10a679ed10a679ed10a679ed10a679ed10a67", "signature_algorithm": "EdDSA" }, "agent_acceptance": { - "signature": "base64-ed25519-acceptance-sig...", + "signature": "acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11", "signature_algorithm": "EdDSA" } } diff --git a/website/src/content/docs/protocol/walkthrough-v1.mdx b/website/src/content/docs/protocol/walkthrough-v1.mdx index 660006fc..a9b39354 100644 --- a/website/src/content/docs/protocol/walkthrough-v1.mdx +++ b/website/src/content/docs/protocol/walkthrough-v1.mdx @@ -143,7 +143,7 @@ The Exchange looks up the article in its catalog and returns an offer. It does n "word_count": 2400, "language": "en" }, - "signature": "base64-ed25519-tc-self-attestation..." + "signature": "ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4ef9e25e4" } ], "terms": [ @@ -158,7 +158,7 @@ The Exchange looks up the article in its catalog and returns an offer. It does n ] } ], - "signature": "base64-ed25519-offer-sig...", + "signature": "8d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac68d1d0ac6", "signature_algorithm": "EdDSA" } ] @@ -208,11 +208,11 @@ The agent commits to the offer by sending the full signed `offer` back, reflecte "hash_method": "sha256", "resource_mutability": "RESOURCE_MUTABILITY_STATIC" }, - "signature": "base64-ed25519-offer-sig...", + "signature": "0ffe51610ffe51610ffe51610ffe51610ffe51610ffe51610ffe51610ffe51610ffe51610ffe51610ffe51610ffe51610ffe51610ffe51610ffe51610ffe5161", "signature_algorithm": "EdDSA" }, "agent_acceptance": { - "signature": "base64-ed25519-acceptance-sig...", + "signature": "acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11acce7a11", "signature_algorithm": "EdDSA" } } @@ -475,7 +475,7 @@ The Exchange verifies the JWT chain, confirms `earnings:*` covers the requested "word_count": 14200, "language": "en" }, - "signature": "base64-ed25519-bb-self-attestation..." + "signature": "81f59bc681f59bc681f59bc681f59bc681f59bc681f59bc681f59bc681f59bc681f59bc681f59bc681f59bc681f59bc681f59bc681f59bc681f59bc681f59bc6" } ], "terms": [ @@ -500,7 +500,7 @@ The Exchange verifies the JWT chain, confirms `earnings:*` covers the requested "resets_at": "2026-04-01T00:00:00Z" } ], - "signature": "base64-ed25519-bb-offer-sig...", + "signature": "19d1803319d1803319d1803319d1803319d1803319d1803319d1803319d1803319d1803319d1803319d1803319d1803319d1803319d1803319d1803319d18033", "signature_algorithm": "EdDSA" } ] @@ -619,7 +619,7 @@ result, err := client.Fetch(ctx, url) **Authentication at every step:** - **Agent → Exchange**: RFC 9421 HTTP Message Signature (Ed25519 over the HTTP request — method, target URI, and body content digest — in the `Signature` / `Signature-Input` headers) - **Delegation**: delegation JWT signed by principal's Ed25519 key, holder-of-key bound via `cnf.jkt` (offline-verifiable) -- **Exchange → Agent**: `exchange_signature` on Offer (Ed25519, stateless verification) +- **Exchange → Agent**: `signature` on Offer (Ed25519, stateless verification) - **Signed URLs**: HMAC-SHA256 (Exchange-CDN shared secret, agent identity bound) ## Next Steps diff --git a/website/src/content/docs/reference/changelog.mdx b/website/src/content/docs/reference/changelog.mdx index 7d750124..43d12750 100644 --- a/website/src/content/docs/reference/changelog.mdx +++ b/website/src/content/docs/reference/changelog.mdx @@ -3,8 +3,379 @@ title: "Changelog" description: "RAMP protocol changelog" --- +{/* GENERATED FILE — do not edit. Source: proto/CHANGELOG.md. + Edit that, then run scripts/gen-changelog-page.py. ci-local.sh gates the drift. */} + ## Unreleased +**The offline recipe compares every signed member, not just `offer_sig` (contract fix, no wire +change).** Binding the acceptance to the offer stopped splicing: a genuine acceptance from a +different offer no longer passes. It did not stop reuse. Two acceptances by one agent against ONE +offer share `offer_sig` and differ only in the idempotency key, so an Exchange holding a single +genuine acceptance could write two evidence rows for two different executes against one offer, and +both passed. That is fabrication by the row's writer rather than splicing by an outsider, and it is +the failure `AgentAcceptancePayload.idempotency_key` exists to prevent. + +The recipe now compares all four members of the signed payload against their stored copies. Three +names coincide; the fourth does not -- the payload member is `idempotency_key` and the row stores it +as `request_idempotency_key`. The row comment that listed the payload's four fields used the row's +name for the fourth, which would send an implementer looking for a JSON member that does not exist; +it now states the mapping. The conformance test carries both cases: a spliced acceptance caught on +`offer_sig`, and a reused acceptance caught on the idempotency key. + +**The admin plane no longer claims a per-tenant ACL is possible (documentation fix, no wire +change).** Four sites -- the service comment, the RPC comment, the `tenant_id` field comment and the +hand-maintained admin proto reference page -- said the `(tenant_id, transaction_id)` pair selector +is what lets a deployment put a per-tenant ACL in front of `GetTransactionEvidence`. The threat +model says the opposite twice, and it is right: `ramp.admin.v1` carries no request signing and no +per-operator identity, so there is no caller to attach an ACL to. All four now say what the pair +selector actually buys -- it narrows what a leaked transaction id is worth -- and that the network +allowlist is the only gate. This matters because the same threat model records that +`GetTransactionEvidence` widened that allowlist's blast radius from per-tenant config writes to a +cross-tenant read of every tenant's signed offers; an operator sizing it while believing a second +control sits behind it would size it too loosely. + +**The no-shared-secret claim is scoped to delivery URLs (documentation fix, no wire change).** The +file header said "No shared secret in either scheme", which is true of the two signed-URL schemes +and contradicted by `cdn_type` on `DomainVerificationConfirmation`, where `"hmac"` is still an +accepted value with no validation rule. The header now says neither *delivery-URL* scheme uses a +shared secret and points at the registration plane that still admits an HMAC key format. Whether +that plane should keep admitting it is a live question, tracked with the wider HMAC sweep. + +**Four parity exclusions stated reasons that were false (documentation fix, no wire change).** Three +justified themselves with "TS/Py have no server face"; both `ramp_sdk.server_verify` and +`core/verify-request.ts` exist and open by calling themselves exactly that. Those three also carried +a retirement trigger -- "if a Python or TS server face ever lands" -- that had already fired and so +could never fire again. The real reason is narrower and is now stated: the py/ts server faces carry +no request-id seam. The fourth said py/ts "mint request-ids inline"; neither SDK mints anything -- +both export the `RequestIDHeader` constant and no non-test code sets that header, so every RPC from +a py/ts client arrives with no correlation id. Two older entries repeating that claim are corrected +with it. The underlying behavioural gap is now tracked as its own work rather than documented as an +intentional difference in API shape. + +**The offline re-verification recipe binds the acceptance to the offer (contract fix, no wire +change).** The recipe on `TransactionEvidence` stated two independent signature checks and +introduced the second with "the agent accepted this exact offer". Nothing in the procedure +established "this exact". A genuine offer from one transaction and a genuine acceptance from +another, by the same agent, both verify against real keys and both pass the authenticity step the +same comment describes -- and the spliced row asserts an agreement that never happened. + +The recipe now has a third step: read `offer_sig` back out of +`agent_acceptance_canonical_bytes` and require it to equal the row's `offer_sig`, compared +case-insensitively. The acceptance payload has always carried `offer_sig` as field 1 for exactly +this purpose; the recipe simply never read it. The Exchange MUST perform the same comparison before +persisting a row. `conformance/evidence_offline_verify_test.go` now executes all three steps and +carries a splice case: two genuine halves joined, both signatures verifying, caught only by the +binding check. + +**Who may file a usage report is now stated (contract fix, no wire change).** The dedupe namespace +for `UsageReport` and `DisputeRequest` is `(transaction_id, key)`, which deliberately leaves the +verified signer out so that a Broker relaying an agent's report unchanged collapses into one report +rather than two. Dropping the signer also removed a protection that was never restated: when the +namespace included the authenticated caller, an unauthorized filer could only pollute their own +slot. The slot is now shared. + +The rule is therefore explicit: only the agent the transaction was bound to at execute time may file +against it, or a Broker relaying that agent's report unchanged. Any other filing MUST be rejected +rather than deduped, since an accepted filing from an unbound party would occupy the slot the bound +agent's report needs. An unauthorized filing reports as +`USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND` on purpose -- a distinct "not authorized" +value would confirm to an unbound party that the transaction exists, turning the rejection into an +oracle for probing transaction ids. No enum value was added. + +**The proto no longer describes signed URLs two ways (documentation fix, no wire change).** The +retrieval-URL block said signed URLs use HMAC-SHA256 with an Exchange-to-CDN shared secret, and +offered a fallback to "HMAC + short TTL + TLS", while the same block's identity-binding paragraph +had been rewritten to say "confirm the URL signature". RAMP has two signing schemes and both are +asymmetric: Ed25519 over a canonical message, and a CloudFront RSA canned policy. There is no shared +secret in either. Both lines now say so, and the fallback names the scheme that actually exists -- +a CDN that verifies the URL itself before any function code runs, and therefore cannot check proof +of possession. + +**The C2PA page no longer calls the attestation signature a JWS (documentation fix, no wire +change).** Pinning `ResourceAttestation.signature` to hex left three lines on +`protocol/ext-c2pa.mdx` naming the old format in the RAMP column of a C2PA-versus-RAMP comparison. +The audience for that page is a verification vendor -- a third party who never negotiated with the +publisher, which is the reader the hex settlement exists to protect -- and one building from the +table would emit a value the schema now rejects. Four SDK comments calling `EdDSA` "the JWS alg" +are corrected the same way: the name is the JOSE algorithm identifier, the signature is detached +hex. + +**Two drift gates now cover the detached-signature rule.** +`ramp.v1.ResourceAttestation.signature` carried the hex rule and a comment saying "the same rule", +which is not the phrase `conformance/samerule_test.go` reads, so it was the one copy of five tied to +nothing. It now declares `Same rule as ramp.v1.Offer.signature`. Separately, the equality gate can +only prove the five copies stay EQUAL -- move them all in step and a changed shape passes. Measured: +widening the class to `^[0-9A-Za-z]{128}$` passed every gate and regenerated the corpus +byte-identical. `TestHexSignaturePatternAdmits` now pins what the rule admits: either case accepted, +127 and 129 characters and non-hex characters and the empty string refused. + +**`ResourceAttestation.signature` states its encoding, and enforces it (BREAKING: rule addition on a +live field).** The field carried no rule and its comment described the signed BYTES precisely -- an +Ed25519 signature over the RFC 8785 JCS form of `{verifier, keyid, attested_at, uri, claims}` -- +while never saying how the signature ITSELF is written. A vendor had to guess, and the published +examples guessed base64, which no part of the contract supports. + +It is now hex, 128 characters, either case, with `pattern = "^[0-9A-Fa-f]{128}$"` -- the same rule +and the same convention as `Offer.signature` and `AgentAcceptance.signature`. Every detached +signature in this contract is now written the same way. + +An attestation is the worst place to leave an encoding unstated, which is why this is settled rather +than documented: the signing party is a third party who never negotiated with the reader, so two +vendors guessing differently produce attestations neither side can verify and nothing on the wire +explains why. No SDK produces or verifies an attestation signature today, so nothing conformant is +refused. + +The rule also makes the field mandatory in practice, since the empty string does not match. That +restates what the message already means -- an attestation without a signature is an unverifiable +assertion by an unproven author, which the field comment already calls Level 0 (no attestation +present) rather than an attestation with a field missing. + +**Every published signature example is hex (documentation fix, no wire change).** 71 example values +across 14 pages showed signatures as base64 placeholders (`base64-ed25519-...`), truncated tokens +(`a1b2c3...`), or bare ellipses. They were left behind by the JWS-to-hex settlement and were wrong +for `Offer.signature` and `AgentAcceptance.signature` from that moment; the attestation ones were +merely unpinned until the rule above. All are now full 128-character hex, with one value per +distinct placeholder so a signature that appears in several places in one walkthrough stays the same +value throughout. + +**`broker` is bounded printable ASCII (rule addition on a field added in this revision).** The +field is a server-written value that a ledger renders, so an unbounded string would have re-opened on +a new field exactly the surface `RequestCorrelation.request_id`'s printable-ASCII bound closes: +control characters, terminal escapes and newlines reaching a rendered forensic row. It now carries +`max_len: 255` and `^$|^[!-~]+$`. + +The rule bounds the SHAPE and deliberately does not pin the FORMAT. A thumbprint pattern would +invalidate a row for a transaction that legitimately executed under a server that records provenance +some other way -- the `requester_id` reasoning. The alternation admits the empty string on purpose, +because `''` is one of the field's three states; a bare `^[!-~]+$` would delete it and leave absence +meaning both "not recorded" and "arrived direct". + +**`broker` moves from `TransactionState` to `TransactionEvidence`, and gains explicit presence +(field move on a message that has not shipped).** `TransactionState.broker` was a plain `string` +describing a transaction-log column that no implementation has. Two things were wrong with that. + +The message is a projection of transaction-log columns, and a field with nothing behind it breaks +the property that makes the projection meaningful. Broker routing is not operational state anyway: +it is an execute-time observation about the connection the request arrived on, covered by neither +signature — the same category as `request_correlation`, which already sits on `TransactionEvidence`. +So it moves there, and `TransactionState` goes back to being every-field-backed-by-a-column. + +The field is now `optional`, which turns two states into three. Absent means the Exchange does not +record routing; `''` means it does record it and the acceptance arrived direct; a value means it +arrived through that hop. Without explicit presence the field defaults to `''`, so an Exchange with +nothing to say would have stated "arrived direct" for every row — a forensic plane asserting a +transport fact it never observed. + +The value is defined as implementation-defined provenance for the outermost hop, not a resolvable +identity. A reference Exchange serves the verified RFC 7638 key thumbprint of the hop that presented +the request, and deliberately does not resolve it to a directory host: the relay hop is not +re-identified against a registry, and the recipient's own relay-permission setting is the gate. A +reader may compare the value for equality against a thumbprint it already holds, but must not expect +a hostname and must not read it as an identity the Exchange vouched for. + +**Agent-plane signature fields now enforce the hex shape they describe (BREAKING: rule addition on +live fields).** `ramp.v1.Offer.signature` carried no rule at all and +`ramp.v1.AgentAcceptance.signature` carried only `min_len: 1`, while both comments described a +detached Ed25519 signature in hex. The read plane already enforced exactly that on its stored +copies (`TransactionEvidence.offer_sig`, `.agent_acceptance_signature`), so the forensic copy of a +signature was validated and the live one was not: a malformed signature was accepted on the agent +plane, failed verification later, and only failed VALIDATION once it reached an evidence row it +could never legitimately reach. Both fields now carry +`pattern = "^[0-9A-Fa-f]{128}$"` — 64 bytes of Ed25519 signature, hex-encoded, either case +accepted because hex decoding accepts both. On `AgentAcceptance.signature` the pattern REPLACES +`min_len: 1`, which it subsumes. Breaking in the descriptor, but no conformant caller is refused: +a value outside this shape cannot hex-decode into 64 bytes, so it could never have verified — the +rejection simply moves from the verify step to the validation step, where the error names the +problem. All three SDKs already emit lowercase hex. The `Same rule as` drift directives on the two +admin fields were re-anchored UPSTREAM to the ramp.v1 fields, which they could not point at while +those fields had no rule; the gate now compares the two planes against each other. +`Offer.signature` also becomes mandatory in practice, since the empty string does not match the +pattern — which is what the message already meant, an offer whose terms, pricing and expiry are +not signed being no offer. + +**`transaction_id` is enforced non-empty on `UsageReport` and `DisputeRequest` (BREAKING: rule +addition on live fields).** Both comments said the field MUST be non-empty and both were bare +`string transaction_id = 3;`, so an empty value passed. The rule is not a shape preference: for +these two RPCs the named transaction IS the dedupe namespace for `idempotency_key`, so a message +that names no transaction has no namespace to dedupe within, and the namespace invariant stated on +`TransactionRequest.idempotency_key` — one caller's key never collides with another caller's +cached result — has nothing to hold it up. Both fields now carry `min_len: 1`, with no upper +bound, because the Exchange assigns the id and nothing upstream constrains its length. The prose +MUST and the schema now say the same thing. + +**Operator plane: forensic evidence read — `GetTransactionEvidence` (additive).** +`AdminService` gains its first read: +`GetTransactionEvidence(GetTransactionEvidenceRequest) → GetTransactionEvidenceResponse` +returns the append-once evidence row the Exchange persists for every successfully executed +transaction — the full signed offer (`offer_json` plus the verbatim RFC 8785 JCS +`offer_canonical_bytes`), both Ed25519 proofs (`offer_sig`, `agent_acceptance_signature`) with +the acceptance's four signed inputs, both verifying public keys — plus the transaction-log and +reporting-obligation state a ledger renderer needs. New messages: `TransactionEvidence`, +`TransactionState`, `ReportingObligationState`, `RequestCorrelation`, the request/response +envelopes; new enum `ObligationState`, which is exactly the storage model's persisted vocabulary +(`PENDING`/`FULFILLED`/`EXPIRED`/`WAIVED`/`BLOCKED`). Selection is by the +`(tenant_id, transaction_id)` PAIR — transaction ids legitimately circulate to counterparty +agents, so an id alone must not act as a bearer capability for the forensic row; a tenant +mismatch is `NOT_FOUND`, byte-identical to an unknown id, so existence under another tenant is +not revealed. The row re-verifies OFFLINE, and the contract states the trust boundary +explicitly: offline verification proves the row is internally consistent, while authenticity +requires comparing the embedded keys against independently obtained copies. The Exchange anchor +is signed — `Offer.exchange` inside `offer_canonical_bytes`; the agent side has none, so +`agent_directory_url` is provenance and the agent key must be anchored independently. +The delivery join is hash-only +(`TransactionState.signed_url_hash` against the edge log) — the full signed URL, a live bearer +capability, never appears on this plane, so a signed-URL *signature* match assertion is +deliberately unproducible from this contract. The correlation id the Exchange persisted rides in +`RequestCorrelation` (bounded printable ASCII, with a `minted` provenance flag); the agent plane +still carries no correlation field in any message body. `TransactionState.signed_url_expiry` and +`TransactionState.signed_url_hash` are both optional, because not every delivery method mints a +signed URL: a `DELIVERY_METHOD_DIRECT` transaction returns the resource inline or from the +Exchange's own endpoint, so it has no URL, no expiry and nothing to hash. Requiring them would +leave a successfully executed direct transaction with no legal value to send for two mandatory +fields. Absence is the stated fact that no signed URL existed; a value that IS present must still +be a full 32-byte digest. + +**`transaction_id` entropy: the guarantee is narrowed to what it actually is (wording fix; no +wire change).** `ramp.v1.TransactionResultItem.transaction_id` said RAMP places no entropy +requirement on a transaction id because the evidence read selects by a pair, and concluded that +"nothing rests on this field's format being unguessable". The first half is right and the +conclusion was too strong. What the pair selector buys is that a transaction id ALONE is never a +bearer capability for the forensic row — which matters, because counterparty agents legitimately +hold the ids of their own transactions. It does not make enumeration infeasible: tenant ids are +human brand slugs, and a tenant's slug is handed to every agent holding one of its offers, +because it prefixes the `offer_id` inside the signed offer. A caller who can reach the admin +plane and has done business with a tenant can pair a known tenant with guessed ids. Enumeration +is bounded by the network-layer reachability restriction on `ramp.admin.v1`, which is therefore +the load-bearing control rather than a deployment convenience. Both planes and the threat model's +enumeration entry now say this the same way. + +The cross-package dependency is also gated. The agent-plane claim rested on the shape of +`ramp.admin.v1.GetTransactionEvidenceRequest`, and nothing failed if that shape changed — the +only other mention was inside the generated corpus, which would simply regenerate smaller. A +conformance guard now fails when the selector stops being a pair, so weakening it can no longer +leave a published agent-plane promise quietly false. + +**Evidence row: the offline trust boundary now names a SIGNED anchor, and `agent_directory_url` +is bounded (rule addition on a message that has not shipped).** The trust-boundary recipe told a +verifier to check the embedded keys against independently obtained copies, then pointed at +`agent_directory_url` for the agent side — a field covered by neither signature and written by +the same party as the rest of the row. A fabricated row satisfied the entire documented procedure +using one host its author controlled. The recipe now separates the two sides. The Exchange anchor +is read OUT of `offer_canonical_bytes`: `ramp.v1.Offer.exchange` names the issuing host and sits +under `offer_sig`, so changing it invalidates the signature the check exists to confirm. The +agent side has no signed anchor, and the contract now says so — `agent_directory_url` is +provenance, never authority, and the agent key must be anchored independently, which is equally +true when the field is `''`. It is also added to the list of unsigned self-assertions under +SCOPE OF THE GUARANTEE. + +The field additionally gains `max_len: 512` and a pattern accepting `''` or an https URL whose +host uses the same recipient-host grammar as `Offer.exchange`, with an optional port and an +ASCII-printable path. The rule bounds the damage from tooling that follows the value anyway; it +does not make following it safe, and the comment states what it does not catch — an IPv4-literal +host still matches, because the recipient-host grammar admits all-numeric labels. A conformance +test pins the accepted and refused set so the comment and the rule cannot drift apart. + +The row's replay exposure is now stated rather than left to inference. `offer_json` + `offer_sig` +are a complete Exchange-signed offer, and `ramp.v1.Offer` binds no requester and no tenant, so a +row holder can accept the same offer under their own identity until it expires; `expires_at`, +`Offer.exchange` and this plane's reachability restriction bound that without closing it, and +closing it needs a requester audience inside the signed offer, which belongs upstream in +`ramp.v1`. The acceptance is the opposite case: the row carries a complete resubmittable +acceptance, but resubmitting it lands in the same dedupe namespace and returns the original +result, and the row holds no private key with which to mint a different one. + +**The agent identity for a transaction is the ACCEPTANCE key (wording fix; no wire change).** +The schema named two different keys as the source of the same embedded value. `AgentAcceptance` +said `agent_identity_hash` is the RFC 7638 thumbprint of the acceptance key; the file header and +`TransactionResponse.agent_identity_hash` said the request-signing key. They are the same key +only on a direct hop. A Broker may author a re-packaged transaction as sender, and on that leg +the RFC 9421 signer is the broker while the in-body acceptance is the only agent-authored +signature in the request — so the transport signer cannot be the anchor. `AgentAcceptance` now +carries the normative definition and every other site cites it: the identity is the acceptance +key where an acceptance is present, and the verified request signer otherwise, which is safe +only because an acceptance-less request cannot have been relayed. + +Two rules that shipping code already enforces are now written down. The ONE-KEY RULE: an agent +MUST accept an offer and fetch the delivered resource with the same key, because the URL is bound +to the acceptance-key thumbprint and an enforcing delivery endpoint checks the fetching key +against it — accepting with one key and fetching with another produces a transaction that +succeeds and a retrieval that is refused. It binds only where proof-of-possession is enforced (a +bearer-only CDN keeps the bearer posture), and a custodial registry holding the one key satisfies +it with nothing extra to do. And because `TransactionResponse.agent_identity_hash` is a single +per-request value, every acceptance in one `TransactionRequest` MUST be signed by the same key. + +**Idempotency dedupe scope: one invariant, three named mechanisms (wording fix; no wire +change).** The same sentence — "uniqueness is scoped to the verified RFC 9421 signer" — was +copy-pasted onto `TransactionRequest`, `UsageReport` and `DisputeRequest`, three RPCs that do not +authenticate the same way. Under a broker-repackaged execute it namespaces every agent behind one +broker together, which is the collision the sentence exists to forbid. The invariant is now +stated once on `TransactionRequest.idempotency_key` — a key chosen by one caller MUST NEVER +collide with another caller's cached result — and each RPC names the namespace that makes it +true: `ExecuteTransaction` scopes to the acceptance identity; `ReportUsage` and `FileDispute` +carry no acceptance payload and scope to the transaction the message names, which is bound to +exactly one agent by its acceptance at execute time. Both of those RPCs therefore state that +`transaction_id` MUST be non-empty. That is prose here; the schema rule enforcing it is filed +separately. + +**`Offer.signature` is a detached hex Ed25519 signature, not a JWS (wording fix; no wire +change).** The schema described one field two ways. `Offer.signature`, +`Offer.signature_algorithm` and the file-header summaries called it a JWS with `alg=EdDSA`, while +`AgentAcceptance` described the same convention as "a hex-encoded detached Ed25519 signature +(NOT a JWS)". Hex is the reading every verifier implements, and the operator plane depends on +it: `TransactionEvidence.offer_sig` is pattern-enforced as 128 hex characters and the offline +verification recipe re-verifies those bytes directly, so under the JWS reading every evidence row +would fail its own validation. The JWS wording is now gone from all four sites. The value of +`signature_algorithm` stays `"EdDSA"` — the JOSE algorithm identifier is borrowed, the envelope +is not. Nothing on the wire changes: a client emitting a JWS here was already producing a value +no Exchange accepts. + +**Generated clients: `TransactionRequest.items` is now required in the Pydantic/Zod export +(breaking for the generated clients; no wire change).** The Go server has always rejected an +omitted `items` (`repeated.min_items = 1`); the generated clients accepted the omission and +diverged. The required-fields inference now covers `repeated.min_items ⇒ required`, closing that +gap for a pre-existing agent-plane type. + +**Generated clients: exact-length bytes fields are enforced, and both base64 alphabets are +accepted (no wire change).** A `bytes.len = N` rule (the evidence row's Ed25519 keys and +sha256 hash) now renders in the Pydantic/Zod export as the exact encoded forms of N bytes — the +loose character window protoschema emits would also admit an N+1-byte value — and the pattern +accepts standard and url-safe base64 alike, because Go `protojson` accepts either on decode; a +client rejecting base64url (e.g. a JWK `x` value pasted verbatim) would refuse input the server +accepts. It accepts them as two ALTERNATIVES — one alphabet per value — because that is what the +decoder does: `protojson` switches to the url-safe alphabet as soon as the string contains `-` or +`_`, then decodes strictly, so a value mixing `+` with `_` is refused, and the generated pattern +refuses it too. Padding is derived from the payload length mod 4 rather than left as a free tail, +so `"AA="`, `"AAA=="` and `"AAAAA"` — none of them a legal encoded length — are rejected exactly +as the server rejects them. The signing-algorithm labels are pinned `string.const = "EdDSA"` +rather than `min_len: 1`, so a generated client also rejects a claimed `"none"`. `bytes.min_len = 1` (the +canonical-bytes fields) is translated the same way: the generated pattern now requires the +encoded payload characters of at least one real byte before the padding tail, so the +two-character string `"=="` — pure padding, zero bytes, which Go `protojson` refuses to +decode — no longer passes the clients, and the pipeline fails closed on any bytes rule shape +it cannot translate. + +The conformance tooling grew with the surface: the corpus generator understands `string.const` +and fails closed on any rule shape it cannot classify, and the restated-rule drift gate now +derives its scope from the descriptor itself — every rule-identical field group must carry a +`Same rule as` directive or an explicit coincidence exemption — instead of an opt-in comment +marker plus a hand-maintained list. The base64 wire forms the two generated clients must decide +identically now live in one shared vector file (`conformance/testdata/bytes_wire_forms.json`) +that a conformance test pins against Go `protojson` + protovalidate, so each row is written once, +cannot drift between the Pydantic and Zod suites, and states the server's real verdict rather +than a belief about it. The evidence-read messages contribute 94 of the new corpus cases; the +committed corpus goes from 549 at the branch point to 703, a net +154 made of 179 added and 25 +removed across 31 messages, because tightened rules elsewhere in this revision replace cases +rather than only adding them. + +Nineteen of those additions close a gap in the generator rather than in the contract. Where a +field rejects its zero enum with `not_in: [0]` and has no explicit presence, protojson drops the +value, so the emitted case pins "omission is rejected". The explicit `*_UNSPECIFIED` string is a +different parse path in a generated client — the name is absent from the emitted enum, so the +client must refuse it — and it had no case at all. The enum edge now emits the same +omitted/explicit pair that the `string.min_len`, `bytes.min_len` and `repeated.min_items` edges +already emitted, which is where the shape was copied from. + **Every addressed request names its recipient: `exchange` becomes required (breaking, pre-1.0).** `ResourceQuery` (field 10), `DisputeRequest` (field 10), `RegisterRequest` (field 3), `GetAccountStatusRequest` (field 2), `DomainVerificationRequest` (field 4), @@ -235,7 +606,7 @@ offending `registration_data` members alongside the reason — variadic in Go, a trailing argument in Python and TS, so the six reasons that carry no per-member detail keep their three-argument call. Without this a service refusing a non-conforming registration had to build the `ErrorDetail` by hand or mutate the builder's result, defeating the rule these -helpers exist for: one place per language where the ADR-019 envelope is constructed. This is +helpers exist for: one place per language where the ErrorDetail envelope is constructed. This is the only `*Detail` builder that reaches past the reason enum — the schema refusal is useless without naming what failed, whereas the sibling detail lists (`TransactionDenial.restriction_mismatches`, `CatalogRejection.rejected_paths`) stay @@ -307,7 +678,8 @@ contract's first repeated message field carrying its own `repeated.max_items`, a generator previously produced only scalar list items. **The `ver` envelope field states its contract, and the version string gets one owner -(no wire change).** All 29 `ver` fields — 25 in `ramp.proto`, 4 in `admin.proto` — now name +(no wire change).** All `ver` fields — 29 at the time of this change (25 in `ramp.proto`, 4 in +`admin.proto`); the evidence-read envelopes later added 2 more with the same wording — now name the expected value `"1.0"` and the receive-side rule. Before this, 27 of them said only "Protocol version" or "RAMP protocol version", and `DiscoveryResponse.ver` carried no comment at all — 28 fields from which an integrator could not learn what to stamp. Only @@ -346,25 +718,45 @@ when omitted the Exchange defaults to `STATIC` at Offer build; an explicit `RESOURCE_MUTABILITY_UNSPECIFIED` is rejected (`not_in:[0]`, matching the Offer-side twin). Offer-side `ResourceIdentity.resource_mutability` is unchanged. -**Go SDK: network-fetching resolvers move `sdk/go/helpers` → `sdk/go/resolvers` -(source move, no wire change).** The IO-bearing key/endpoint resolvers — well-known -JWKS (`NewWellKnownKeyResolver`), revocation-aware WBA directory (`NewWBAKeyResolver`), -the `ramp.json` endpoint resolver (`WellKnownEndpointResolver` / -`NewWellKnownEndpointResolver` / `WellKnownOptions` / `ErrNoEndpoint`), and the -SSRF-guarded fetch client — now live in a new L2 I/O package, one tier above the pure, -IO-free `sdk/go/helpers`, so no network dial enters the trust core. Import them from -`github.com/RAMP-Protocol/protocol/sdk/go/resolvers`. No alias shim is shipped — it is -a hard rename; consumers import the resolvers from `sdk/go/resolvers`. The pure -`KeyResolver` interface and `NewStaticKeyResolver` stay in `helpers`. - -**SDK (all 3 languages): new public faces (additive, no wire change).** Document-order -active-key selection (`ActiveEd25519Key` / `…WithExpiry` and revocation-aware -`…Screened` variants; `active_ed25519_key*` in Python, `activeEd25519Key*` in TS), a -`CachedOfferKeyResolver`, an injectable Ed25519 verify primitive on the -TS signed-URL verify (`Ed25519Verifier`), and cross-language `ErrorDetail` readers -(Go `AttachErrorDetail` / `AttachDetail`; `parse_error_detail` / `error_detail_from` -in Python; `parseErrorDetail` / `errorDetailFrom` in TS). The SSRF-guarded transport -is a single env-driven client governed by two flags (`SKIP_SSRF`, `ALLOW_INSECURE`). +**SDK parity matrix is now generated, not hand-maintained (no wire change).** The +three overlapping, drift-prone parity docs (`docs/sdk-parity-matrix.md`, +`sdk-api-parity-map.md`, `sdk-parity-audit.md`) collapse to a single generated +artifact, `docs/sdk-parity-matrix.md`, rendered by `scripts/gen-parity-matrix.py` from +the two ground-truth sources CI already enforces against the code: the API surface from +`sdk/parity/symbol-map.json` (gated by `test_api_surface_parity.py`) and the +conformance-vector replay table from the committed corpora (gated by +`test_corpus_replay_completeness.py`). A regenerate-and-diff drift gate runs both in +`scripts/ci-local.sh` and as `sdk/python/tests/test_parity_matrix_generated.py` +(`sdk-types-ci.yml`), so the matrix can no longer drift from the real surface. The two +superseded audit docs are deleted. + +**Go SDK: the network-fetching resolvers move `sdk/go/helpers` → `sdk/go/resolvers` +(source move, no wire change).** The IO-bearing key/endpoint resolvers — the +well-known JWKS resolver (`NewWellKnownKeyResolver`), the revocation-aware WBA +directory resolver (`NewWBAKeyResolver`), the `ramp.json` endpoint resolver +(`WellKnownEndpointResolver` / `NewWellKnownEndpointResolver` / `WellKnownOptions` / +`ErrNoEndpoint`), and the SSRF-guarded fetch client — now live in the new L2 I/O +package `sdk/go/resolvers`, one tier above the pure, IO-free `sdk/go/helpers`. This +keeps every network dial out of the trust core (enforced by an io-leaf guard). +Migration: import these from `github.com/RAMP-Protocol/protocol/sdk/go/resolvers` +instead of `.../sdk/go/helpers`. **No alias shim is provided** — the move is a hard +rename and the downstream app already compiles against the moved layout; consumers +import the resolvers from `sdk/go/resolvers`. The pure `KeyResolver` interface and +the static `NewStaticKeyResolver` stay in `helpers`. + +**SDK (all 3 languages): new public faces this cycle (additive, no wire change).** +Document-order active-key selection — `ActiveEd25519Key` / +`ActiveEd25519KeyWithExpiry` and their revocation-aware `…Screened` variants +(`active_ed25519_key*` in Python, `activeEd25519Key*` in TS) — plus a +`CachedOfferKeyResolver`, an injectable Ed25519 verify primitive on +the TS signed-URL verify (`Ed25519Verifier`), and cross-language `ErrorDetail` +readers: Go `AttachErrorDetail` / `AttachDetail` on the server binding, and +`parse_error_detail` / `error_detail_from` (Python) and `parseErrorDetail` / +`errorDetailFrom` (TS) decoders, all pinned to the shared `error-detail-vectors.json` +oracle. The SSRF-guarded transport is now a single env-driven client +(`NewGuardedClientFromEnv` / `guarded_client` / `guardedFetchFromEnv`) governed by +two flags (`SKIP_SSRF`, `ALLOW_INSECURE`). See `docs/sdk-parity-matrix.md` for the +per-language surface. **Go SDK: `helpers.CanonicalOfferBytes` exported (additive, no wire change).** The offer-canonical-bytes accessor — RFC 8785 JCS over canonical proto-JSON with @@ -475,34 +867,55 @@ Exchange). Direct-to-Exchange callers who need to attach one use handle, and the Exchange will not read it as one. **Agent account registration + status RPCs (additive).** `ExchangeService` -gains `Register` and `GetAccountStatus` — the agent-account front door. -Registration creates the agent's account with the Exchange and mints -`billing_ref`, the opaque, long-lived, per-Exchange account handle; the -caller's identity is derived from the verified request signature, never from -the body, and the operator-defined business payload rides in a flexible +gains `Register(RegisterRequest) → RegisterResponse` and +`GetAccountStatus(GetAccountStatusRequest) → GetAccountStatusResponse` — the +agent-account front door the Web Bot Auth Registry epic needs. Registration +creates the agent's account with the Exchange and mints `billing_ref`, the +opaque, long-lived, per-Exchange account handle; the caller's identity is +derived from the verified request signature, never from the body, and the +operator-defined business payload rides in a flexible `RegisterRequest.registration_data` (`google.protobuf.Struct`) that the Exchange passes through uninspected. A repeat `Register` for the same agent returns the same `billing_ref` (idempotent by design — no `idempotency_key`). `GetAccountStatus` is the read-only "is my account active" check; its request deliberately carries no identifying field. Refused registrations use the -pre-existing `ErrorDetail.registration_failure` / -`RegistrationFailureReason` path. Pre-v1 additive change. +pre-existing `ErrorDetail.registration_failure` / `RegistrationFailureReason` +path, which until now had no RPC front door. Pre-v1 additive change. **Operator plane: new `ramp.admin.v1` package with `AdminService` (additive).** -Two full-replace, idempotent setters for Exchange operators: `SetTenantFeeRate` -and `SetReportingPolicy`. Each request and response is a thin `{ver, }` -envelope wrapping a required nested payload — `TenantFeeRate` (fee rate in basis -points, `0 <= fee_rate_bps < 10000`, plus an optional operator note) and +At introduction, two full-replace, idempotent setters for Exchange operators (the forensic +evidence read joined later in this cycle — see the entry above) — +`SetTenantFeeRate(SetTenantFeeRateRequest) → SetTenantFeeRateResponse` and +`SetReportingPolicy(SetReportingPolicyRequest) → SetReportingPolicyResponse`. +Each request and response is a thin `{ver, }` envelope carrying a +required nested payload message: `TenantFeeRate` (fee rate in basis points, +`0 <= fee_rate_bps < 10000`, plus an optional operator note) and `ReportingPolicy` (required report fields, quantity tolerance `0`–`1`, reporting -window ≤ 1 year). Field-level protovalidate constraints live on the payload -messages — shared by request and response, so each rule is stated once and the -echoed read-back cannot drift from the write — and flow into the generated -Pydantic/Zod types; responses echo the state as persisted. Deliberately a -separate service/package from `ExchangeService` — the operator plane is not part -of the agent contract, carries no `idempotency_key` and no `ext`/`ext_critical` -maps, and is expected to be network-isolated by deployments. See -[Proto: Admin v1](/reference/proto-admin/) for the full reference. `SetOfferPrice` -and `SetDeliveryWitnessMode` are deferred to follow-up work. +window ≤ 1 year). The field-level protovalidate constraints live on the two +payload messages — shared by request and response, so each rule is stated once +and the echoed read-back cannot drift from the write — and flow into the +generated Pydantic/Zod types export and the validation corpus; responses echo +the state as persisted. Deliberately a separate service/package from +`ExchangeService` — the operator plane is not part of the agent contract, +carries no `idempotency_key` (full-replace setters on an unsigned internal +plane have nothing to dedupe) and no `ext`/`ext_critical` maps, and is expected +to be network-isolated by deployments. `SetOfferPrice` and +`SetDeliveryWitnessMode` are deferred to follow-up work. The conformance +tooling (corpus generator, required-fields export, reachability and +doc-coverage guards, Zod/Pydantic types pipeline) now walks both contract +packages, and the corpus generator gained int32/double boundary mutants for the +payload messages' numeric rules. + +**Biscuits removed; entitlement mechanism kept for JWT (breaking).** The Biscuit +token format leaves the protocol — JWT is the sole entitlement/capability token +format. The entitlement MECHANISM is unchanged and format-neutral: a capability +token rides a covered header (renamed `X-RAMP-Entitlement-Biscuit` → +`X-Entitlement-Token`) whose signature-coverage the verifier enforces without +ever parsing the token, so it holds identically for JWT. Removed only the +biscuit-specific bits: the `token_format` value `"biscuit-v3"` (JWT stays the +default), and `DENIAL_REASON_ENTITLEMENT_STALE_ATTENUATION` (18) — attenuation is +a biscuit concept. The generic entitlement `DenialReason` family (12–17) stays. +Pre-v1 breaking change; `buf breaking` reports the deltas as expected. **Protocol standardization — unified error/response contract + a Connect RPC for every role (breaking).** Three threads land together: @@ -530,13 +943,13 @@ extended `DenialReason` with values 12–18 and added `OFFER_ABSENCE_REASON_BUDGET_EXCEEDED`. Accepted pre-v1 breaking change; `buf breaking` reports the deltas as expected. -Also removed the vestigial single-offer `offer_id` correlation scalar from -`TransactionRequest` (field 3). It was left stranded by the items-only migration: -never authoritative — the Exchange keys binding, billing, and audit off each -item's signature-verified offer identity inside the signed `Offer`, never this -scalar — and read by nothing. A single-offer transaction is the degenerate -one-element `items` list. Deleted outright with no reserved (pre-v1); `buf -breaking` reports the delta as expected. +Also: removed the vestigial `TransactionRequest.offer_id` (field 3). It was a +single-offer-era correlation scalar left stranded by the items-only migration — +never authoritative (the Exchange keys binding, billing, and audit off each +item's signature-verified `offer.offer_id`, never this scalar) and read by +nothing. A single-offer transaction is the degenerate one-element `items` list; +offer identity lives inside the signed Offer. Deleted outright with no reserved +(pre-v1); `buf breaking` reports the delta as expected. **Money as an exact decimal string + field validation as standard constraints (breaking).** @@ -562,140 +975,168 @@ and — because JCS-canonicalized signing is over the JSON field names — snake canonical form the signature bytes are computed over. Accepted pre-v1 breaking change. **Discovery/offer response model (breaking).** The Agent-to-Broker discovery -messages are renamed and the response re-modeled to carry offers rather than a -single transaction result: - -- **Renamed** `RAMPRequest` → `DiscoveryRequest` and `RAMPResponse` → - `DiscoveryResponse` — the Agent-to-Broker pair (Steps 1 and 6), the same pair - carried by `BrokerService.Resolve`. -- **Re-modeled** `DiscoveryResponse` as discovery-only: removed the - per-transaction fields (now carried solely by `TransactionResponse`) and added - `repeated OfferGroup offer_groups`, one group per requested URI. A group with no - offers carries its `absence_reason`. -- **Added** `Offer.exchange` (field 8): the issuing Exchange's canonical domain - and the execute target, inside the signed Offer bytes so a relaying Broker - cannot redirect execution without invalidating the signature. +messages are renamed and the response is re-modeled to carry offers rather than +a single transaction result: + +- **Renamed** `RAMPRequest` to `DiscoveryRequest` and `RAMPResponse` to + `DiscoveryResponse` — the Agent-to-Broker request/response pair (Steps 1 and 6), + the same pair carried by `BrokerService.Resolve`. +- **Re-modeled** `DiscoveryResponse` as discovery-only. Removed the + per-transaction fields (`transaction_id`, `billing_id`, `exchange`, + `resource_title`, `cost`, `delivery_method`, `reporting_obligation`, + `expires_at`, `broker_fee`, `retrieval_endpoint`, `agent_identity_hash`) — + these are carried solely by `TransactionResponse` — and added + `repeated OfferGroup offer_groups`, one group per requested URI, as the sole + offer representation. A group with no offers carries its `absence_reason`. +- **Added** `Offer.exchange` (field 8): the canonical domain of the issuing + Exchange and the target for the execute call. It sits inside the signed Offer + bytes, so a relaying Broker cannot redirect execution to a different Exchange + without invalidating the signature. + +This is an accepted breaking change pre-v1 freeze; `buf breaking` reports the +deltas as expected. **WBA identity split — keys move to the WBA directory (breaking).** Identity keys -are split out of `ramp.json` (`WellKnownManifest`) into a pure WBA key directory -served at `{domain}/.well-known/http-message-signatures-directory`: +are split out of `ramp.json` (`WellKnownManifest`) and into a pure WBA key +directory served at `{domain}/.well-known/http-message-signatures-directory`: -- **Added** `WBAFile` (the directory body) carrying the role's JWKs and an - optional `revocation_url`; removed the manifest's inline key fields. +- **Added** `WBAFile` (the WBA directory body) carrying the role's + attestation/identity JWKs (with their `not_before`/`not_after` bounds per + RFC 7517 §5) and an optional `revocation_url`; removed `WellKnownManifest`'s + `public_keys` and `invalidation_url`. - **Keyed by thumbprint, no `kid`.** The RFC 9421 `keyid` is the key's RFC 7638 - JWK Thumbprint, computed locally; the attestation `keyid` now holds the - verifier key's thumbprint, resolved against `WBAFile.keys`. -- **Added** `KeyRevocationList` (served at `WBAFile.revocation_url`) — the - complete set of revoked thumbprints, polled on a 300s cadence. + JWK Thumbprint, computed locally; carrying a separate `kid` is gone. The + attestation `keyid` field now holds the verifier key's thumbprint, resolved + against the verifier's `WBAFile.keys`. +- **Added** `KeyRevocationList`, the snapshot body served at + `WBAFile.revocation_url` — the complete set of revoked key thumbprints + (RFC 7638, base64url-no-pad), polled on a 300s cadence — and the + `RETRIEVAL_AUTH_FAILURE_REASON_KEYID_MISMATCH` / `_THUMBPRINT_MISMATCH` + failure reasons. + +This is an accepted breaking change pre-v1 freeze; `buf breaking` reports the +deltas as expected. **CoMP re-baseline to canonical V1 (breaking).** `proto/comp/v1/comp.proto` is re-aligned to be a 1:1 mirror of IAB Tech Lab Content Monetization Protocols **CoMP V1** (finalized 2026-04-28, -[`CoMP-1.0.md`](https://github.com/IABTechLab/CoMP/blob/880238e0100b3d0d67d5afd7357a18fc21a97be5/CoMP-1.0.md)). -The prior snapshot mirrored a pre-final draft. This breaks the `comp.v1` -generated types (accepted pre-v1 of the CoMP profile; `buf breaking` reports the -deltas as expected). Changes: +[`CoMP-1.0.md`](https://github.com/IABTechLab/CoMP/blob/880238e0100b3d0d67d5afd7357a18fc21a97be5/CoMP-1.0.md)). Our +prior snapshot mirrored a pre-final draft. Changes: - **Removed** the `License` message, the `LicenseUse` enum, and `Package.license` — canonical V1 has no separate `License` object. -- **Folded licensing into `Scope`**: added `ause` (`AllowedUse`), `pricetype` - (`PriceType`), `pricetier`, `unitprice`, `cur` (default `"USD"`), `country` - (ISO-3166 numeric), and `licensedur` (days). +- **Folded licensing into `Scope`**: added `ause` (new `AllowedUse` enum), + `pricetype` (new `PriceType` enum), `pricetier`, `unitprice`, `cur` + (default `"USD"`), `country` (`repeated int32`, ISO-3166 numeric), and + `licensedur` (days). - **Added** `Package.reporturl` (usage-reporting URL). -- **Added** per-media taxonomy `cattax` (default 9), `cat`, and `language` to - `Text`, `Video`, `Image`, and `Audio`. -- **Removed** the RAMP-invented non-CoMP fields: `Text.authority`/`originality`, - `Image.alt`/`caption`, `Video`/`Image`/`Audio.c2pa`, and `Retrieval.ratelmt`. -- **Added** `RETRIEVAL_AUTH_OTHER = 4`. +- **Added** per-media taxonomy fields `cattax` (default 9), `cat` + (`repeated int32`), and `language` (`repeated int32`, ISO-639-1) to `Text`, + `Video`, `Image`, and `Audio`. +- **Removed** the RAMP-invented fields that were not part of canonical CoMP: + `Text.authority`, `Text.originality`, `Image.alt`, `Image.caption`, + `Video/Image/Audio.c2pa`, and `Retrieval.ratelmt`. +- **Added** `RETRIEVAL_AUTH_OTHER = 4` to the retrieval auth enum. The request-side model (`AISystem`/`AISystemUse`, `Function`, `SubFunction`, -`AuthMethod`, `ScopeType`, `ContentType`) is unchanged. +`AuthMethod`, `ScopeType`, `ContentType`) is unchanged. This is an accepted +breaking change pre-v1 freeze of the CoMP profile; `buf breaking` reports the +deltas as expected. ## v1.0.0 — Initial release First public release of the RAMP Protocol (Resource Access Metering Protocol): -the wire format, the generated Go and TypeScript SDKs, and this specification -site. One protocol for discovering, pricing, transacting, delivering, and -verifying access to any digital resource by an AI agent. The reasoning behind the -major design decisions is recorded in [`docs/design-history.md`](https://github.com/RAMP-Protocol/protocol/blob/main/docs/design-history.md). - -### Core protocol - -- **ExchangeService** — `DiscoverResources`, `ExecuteTransaction`, `ReportUsage`, `DisputeTransaction`, and domain-verification RPCs. Agents and Brokers are interchangeable clients. -- **ResourceQuery / ResourceResponse** — query an Exchange for available resource offers. -- **DiscoveryRequest** — Agent → Broker entry point, with natural-language `query` and structured `search_filters` for Broker-side discovery. -- **Requester** — universal actor identity (AGENT, HUMAN_TOOL, SERVICE, DELEGATED, RESEARCH). -- **Delegation** — holder-bound JWT (RFC 7800 `cnf`/`jkt` + RFC 9421 proof-of-possession; chain of `cnf`-linked JWTs). `token_format` is `"jwt"`. Scoped, time-limited, spend-capped, narrowable offline. -- **Scope-based access control** — the Exchange filters its catalog by the requester's scopes; subscriptions are scopes. -- **SubscriptionQuotaInfo** — proactive, multi-dimensional quota signaling on `Offer` and `TransactionResponse`. -- **ResourceMutability** — STATIC (hash stable), DYNAMIC (hash drifts), LIVE (streaming, no content at offer time). -- **Data freshness** — `Offer.data_as_of` + `RequestConstraints.max_data_age` for staleness filtering. -- **Unit-agnostic metering** — `unit_cost` + `estimated_quantity` + `unit`: tokens, pages, seconds, records, bytes, sq_km, and domain-specific units. -- **ResourceAttestation** — Ed25519-signed claim envelope for resource integrity. Three levels: none, self-attested, third-party verified. -- **Dispute resolution** — three-tier (automated `<1s`, rule-based `<24h`, human escalation). Evidence chain: Transaction → UsageReport → Dispute. -- **Domain verification** — ACME HTTP-01-style provider onboarding. -- **CatalogService** — `PushResources`, `RemoveResources`, `RefreshCatalog`. -- **ext_critical** — critical-extension signaling (COSE `crit` pattern, RFC 9052): a consumer MUST understand listed keys or reject the message. -- **Resource previews** — lightweight `Preview` assets on `Offer` for pre-transaction evaluation (URLs only, zero Exchange memory impact). - -### Multi-hop - -- **Signature-stack forwarding chain** — schain-inspired forwarding for the Agent → Broker → … → Exchange path carried as a stack of RFC 9421 HTTP Message Signatures in HTTP headers. Each forwarding party adds one labeled signature covering the request plus the prior hop's signature; the ordered set of signatures is the chain. (Replaces the in-message `IntermediaryHop` / `ResourceQuery.intermediaries` and the removed `broker_signature`.) -- **Chain-depth caps** — `RequestConstraints.max_hops` (agent-side) and `WellKnownManifest.max_intermediary_hops` (Exchange-published). -- **Direct response path** — the terminal Exchange returns directly to the originating agent; intermediaries are forward-path only. - -### Discovery & keys - -- **WellKnownManifest** — a single canonical document served at `/.well-known/ramp.json` by every participant, role-tagged via the `Role` enum (AGENT, EXCHANGE, BROKER, PUBLISHER). Carries inline keys, optional `invalidation_url`, publisher authorization (`exchanges[]`, `catalog_contributors[]`), and exchange capability fields (pricing, delivery, auth methods, OIDC issuer, GNAP endpoint, base currency, supported profiles). -- **JsonWebKey** — inline RFC 7517 JWKs (Ed25519: `kty="OKP"`, `crv="Ed25519"`, `alg="EdDSA"`) with explicit `not_before` / `not_after` RFC3339 bounds. Key-validity window is half-open `[not_before, not_after)`. -- **KeyInvalidationList** — snapshot-semantic kid revocation list served at `invalidation_url` for emergency revocation. -- **Caching contract** — `/.well-known/ramp.json` MAY be cached (minutes–hours); the `invalidation_url` body SHOULD be short/`no-store`. -- **Domainless requesters** — accommodated via a registry-hosted `WellKnownManifest` (the agent sets `Requester.domain` to the registry host). - -### Authentication - -- **Auth-agnostic** — the Exchange advertises supported methods: GNAP (RFC 9635), OAuth + DPoP, OAuth Bearer, mTLS. -- **JWS for content signatures** — `Offer` and attestation signatures use JWS Compact Serialization (`alg=EdDSA`). -- **RFC 9421 for request signatures** — HTTP Message Signatures authenticate agents and each intermediary hop. -- **Retrieval-URL identity binding** — the Exchange MAY bind a signed `retrieval_endpoint` to the agent via `agent_identity_hash` (RFC 7638 JWK Thumbprint, DPoP-style per RFC 9449), verifiable fully offline by a capable edge function. - -### Content provenance (C2PA) - -- `ResourceIdentity` C2PA fields (`c2pa_manifest`, `c2pa_status`, `soft_binding`, `soft_binding_method`) and the `C2PAStatus` enum (TRUSTED, VALID, INVALID, ABSENT). -- The `ramp-c2pa-v1` extension profile bridges C2PA X.509/COSE trust into RAMP Ed25519 attestations. - -### Pricing models - -`PRICING_MODEL` is the charging structure only: FREE, PER_UNIT, FLAT (plus UNSPECIFIED=0, rejected at ingest). The metering basis ("per what") moved to the `Pricing.unit` vocabulary; revenue-share was removed (settlement is off-protocol); attribution/contribution are `Obligation`s. - -### Denial reasons - -BILLING_REF_INACTIVE, INSUFFICIENT_BALANCE, RATE_LIMITED, CONTENT_UNAVAILABLE, RESTRICTION_NOT_SATISFIED, REPORTING_OVERDUE, OFFER_EXPIRED, SIGNATURE_INVALID, QUOTA_EXCEEDED, DELEGATION_INVALID, SCOPE_INSUFFICIENT. - -### Extension profiles - -Domain-specific metadata carried in `ext` fields: - -- **ramp-news-v1** — articles, podcasts, broadcasting (IPTC NewsML-G2, Podcasting 2.0) -- **ramp-academic-v1** — scholarly articles, preprints (CrossRef, OpenAlex, COUNTER 5.1) -- **ramp-legal-v1** — legislation, case law, patents (ELI, ECLI, Akoma Ntoso) -- **ramp-comp-v1** — IAB CoMP V1 metadata (Package, Scope with folded licensing, Retrieval, per-media taxonomy) as optional ext fields - -### Licensing terms - -- **`LicenseTerm`** — universal licensing unit. A resource carries zero or more terms; each term is a complete, self-contained access arrangement (restrictions + quotas + obligations + pricing). Same shape at ingestion (`ResourceEntry.terms`) and emission (`Offer.terms`). -- **`License`** — identifies the governing license document (`uri`, `id`, `name`, `immutable`, `uri_digest`). `uri_digest` pins the document hash and is required whenever a `License` carries a `uri` (any semantics, mutable or not). -- **`Restriction`** — constrains one axis: `FUNCTION` (what), `GEOGRAPHY` (where), `USER_TYPE` (who). Tokens are proto-native (`(ramp.v1.vocab_enum)` on the `RestrictionKind` values). restrictions are **binding by default** (`advisory=true` downgrades an unverifiable restriction to non-blocking). -- **`Quota`** — usage cap that gates term validity, not billing. Metrics are proto-native (`(ramp.v1.vocab)` on `Quota.metric`): `accesses`, `tokens`, `display-words`, `impressions`, `units-manufactured`, and more. -- **`Obligation`** — post-use behavioral requirement. Replaces the retired `PRICING_MODEL_ATTRIBUTION` / `PRICING_MODEL_CONTRIBUTION` (attribution and contribution are obligations, not payment models). Kinds: `ATTRIBUTION`, `CONTRIBUTION`, `SHARE_ALIKE`, `NETWORK_COPYLEFT`, `NOTICE`, `OTHER`. `Obligation.scope_license` is a `License` (not a bare string), so a `SHARE_ALIKE` target inherits the `uri ⇒ uri_digest` tamper-evidence rule. -- **`TermSemantics`** — `ENUMERATED` (machine fields are the complete, authoritative term; enforced downstream at reconciliation; Pricing required) vs `REFERENCE_ONLY` (the document at `License.uri` is the authoritative, complete source; machine restrictions/quotas/obligations are optional but, when present, must be accurate and are enforced; Pricing still required). -- **`PricingMetering`** — `ONLINE` (default), `NONE` (one-time sale, no ongoing tracking), `OFFLINE_SELF_REPORTED` (agent self-reports; Exchange audits). Added as `Pricing.metering` (field 9). -- `Offer.terms` (field 19) — repeated LicenseTerm from the publisher catalog. `Offer.restrictions` (the flat `AccessRestrictions` field) removed. -- `ResourceEntry.terms` (field 13) — publisher-declared terms pushed via `CatalogService.PushResources`. -- `PushResourcesResponse.warnings` (field 3) — non-fatal ingestion warnings (e.g., unrecognized vocab token). -- `PRICING_MODEL_ATTRIBUTION` (6) and `PRICING_MODEL_CONTRIBUTION` (7) **removed** from `PricingModel`. Migrate to `Obligation.kind = ATTRIBUTION / CONTRIBUTION` in a `LicenseTerm`. -- **Wire-enforced validation** — licensing presence and coherence rules are now expressed as protovalidate CEL embedded in the descriptors (not prose-only): pricing required on every term, `REFERENCE_ONLY ⇒ license.uri`, `uri ⇒ uri_digest`, the required discriminator enums reject `UNSPECIFIED` (`LicenseTerm.semantics`, `Pricing.model`, `Restriction.kind`, `Obligation.kind`, `Obligation.trigger`, `Quota.window`), one restriction per kind, permitted/prohibited disjoint, and token-format rules. A `conformance/` test suite evaluates the CEL against valid/invalid instances and validates the doc examples, wired into CI; a guard derives the discriminator set from the proto (each such discriminator field rejects the enum's zero via its `not_in:[0]` rule) and fails if any field of one is left unenforced, so the set can't silently drift. Enforced and tested in the Go SDK today; the TypeScript SDK is generated (protovalidate-es not yet wired) and Python is a tracked follow-up. - -### Wire format - -Protocol Buffers. Package `ramp.v1`. Dual transport via Connect (HTTP/JSON + binary protobuf from the same handler). +the wire format (protobuf under `proto/`), the generated Go and TypeScript SDKs +(under `gen/`), and the specification site (under `website/`). RAMP extends IAB +Tech Lab CoMP v1.0 and RSL 1.0 with resource discovery, transaction execution, +post-usage reporting, dispute resolution, and provider domain verification — +enough for an autonomous agent to negotiate licensed access to a publisher's +resources through an Exchange and produce a cryptographically auditable record of +the transaction. + +Highlights: a single `ExchangeService` (`DiscoverResources`, +`ExecuteTransaction`, `ReportUsage`, `DisputeTransaction`, and domain +verification) with Brokers and agents as interchangeable clients; unit-agnostic +metering; Ed25519 at every trust boundary, with RFC 9421 HTTP Message Signatures +for request and hop authentication and JWS (RFC 7515) for offer and attestation +signatures; a unified `/.well-known/ramp.json` (`WellKnownManifest`) served by +every role with inline RFC 7517 JWKs and explicit key-validity bounds; +cryptographic content attestations with a structured dispute chain; multi-hop +intermediary chains with agent- and exchange-published depth caps; and extension +profiles for domain-specific behavior (news, academic, legal, C2PA, CoMP, pharma, +medical imaging). + +**Universal Licensing Core.** A resource carries `repeated LicenseTerm terms` +— the same shape at ingestion (`ResourceEntry.terms`) and emission +(`Offer.terms`) — replacing the hard-coded default pricing and the removed +`AccessRestrictions` / `Offer.restrictions`. A `LicenseTerm` bundles `License` +(`uri`, `id`, `name`, `immutable`), `TermSemantics` (`ENUMERATED` vs +`REFERENCE_ONLY`), `Restriction`s (function / geography / user-type axes), +`Quota`s, `Obligation`s (`scope_license`, `detail`), `Pricing` (required on +every term), Biscuit `scopes`, and `part_label`. `PricingModel` is the closed +charging structure (`FREE`, `PER_UNIT`, `FLAT`); the metering basis moved to +the open `Pricing.unit` vocabulary; `Pricing.metering` was added and +`revshare` / `REVENUE_SHARE` removed (settlement is off-protocol). Every +required enum carries `_UNSPECIFIED = 0` and is rejected if unset +(`PricingMetering` is the deliberate exception — `ONLINE = 0` is its real +default). The Offer JWS signs the entire canonical Offer, so `terms` and +`pricing` are tamper-evident. + +**Proto-native vocabulary.** Every open vocabulary axis is defined in the proto +and tooled by buf — no side-car JSON registry. The `(ramp.v1.vocab)` field +option (`FieldOptions` extension 50001) carries the registered bare tokens on +`Pricing.unit` and `Quota.metric`; the `(ramp.v1.vocab_enum)` enum-value option +(`EnumValueOptions` extension 50002, both in `ramp/v1/vocab.proto`) carries the +function / geography / user-type tokens on the `RESTRICTION_KIND_FUNCTION` / +`RESTRICTION_KIND_GEOGRAPHY` / `RESTRICTION_KIND_USER_TYPE` enum values. The +`protoc-gen-rampvocab` buf plugin reads both options structurally and emits +typed Go constants, `All`, and `IsRegistered` per axis under `gen/go/vocab/` +(`pricingunits`, `quotametrics`, `functiontokens`, `geographytokens`, +`usertypes`). Geography registers only the non-ISO specials (`*`, `EU`, `EEA`); +ISO 3166-1 alpha-2 codes are structural. `protovalidate` carries the structural +field CELs (`Pricing.unit`, `Quota.metric`) and message-level CEL on `Pricing` +(`PER_UNIT ⇒ unit`, `FREE ⇒ rate 0`). Adding a token edits the option list only +— no message-shape change. + +**Billing reference, not entitlement.** `Requester.license_id` was renamed +`billing_ref` and recast as an opaque handle into the operator's billing system. +It is not an authorization token: identity is the RFC 9421 request signature and +entitlement is scopes plus `Delegation`, so access is never gated on +`billing_ref`. + +**DenialReason consolidation.** `INVALID_LICENSE` and `EXPIRED_LICENSE` collapse +into a single `DENIAL_REASON_BILLING_REF_INACTIVE`, and `DELEGATION_EXPIRED` +broadens to `DENIAL_REASON_DELEGATION_INVALID` (expiry is one of several ways a +token fails to authorize). The enum is contiguous, with no reused numbers. + +**Delegation-claims profile.** The delegation token stays opaque on the wire; +`token_format` only selects the verifier. RAMP defines a small registered +claim/fact vocabulary mapping the same named concepts across JWT registered +claims and Biscuit facts, so scope / expiry / spend caps mean the same thing to +every verifier regardless of format. All vocabulary entries are optional except +the mandatory subject/holder binding: the key that signs the RFC 9421 request +MUST equal the token's holder key, which is what makes a leaked token not +bearer-usable. Issuer-specific facts use a `vendor:` namespace; `ramp_`-prefixed +names are reserved. Binding constraints are fail-closed (binding by default) +unless explicitly marked advisory. + +**JWT-default delegation (holder-of-key).** `token_format` defaults to `"jwt"`: +the delegation token is a holder-bound JWT, with the grant tied to a key via the +RFC 7800 `cnf` claim (`cnf.jkt` = RFC 7638 thumbprint) and possession proven by +the RFC 9421 request signature. Delegation is a chain of `cnf`-linked JWTs +(each child signed by the key its parent named, scope ⊆ parent), verified offline +under the issuer's key alone. `"biscuit-v3"` remains a permitted **optional** +alternative for deployments wanting deep multi-hop in-place attenuation. This +makes JWT — already ubiquitous — the one delegation technology implementers must +support; Biscuit is opt-in. + +**Scope matching.** One normative algorithm applies protocol-wide: scopes are +`":"`-separated segments; a grant covers a requirement only if each granted +segment equals the required segment or is `"*"`, with a terminal `"*"` matching +all remaining segments. There is no implicit prefix match and a grant narrower +than the requirement does not cover it. The same rule applies to +requester/`Delegation` scopes and to `LicenseTerm.scopes`; the Biscuit Datalog +authorizer is a conformant implementation that MUST produce identical results. + +The reasoning behind the major design decisions is recorded in +[`docs/design-history.md`](https://github.com/RAMP-Protocol/protocol/blob/main/docs/design-history.md). diff --git a/website/src/content/docs/reference/proto-admin.mdx b/website/src/content/docs/reference/proto-admin.mdx index 16f23035..c5b2cd08 100644 --- a/website/src/content/docs/reference/proto-admin.mdx +++ b/website/src/content/docs/reference/proto-admin.mdx @@ -1,15 +1,15 @@ --- title: "Proto: Admin v1" -description: "ramp.admin.v1 protobuf reference -- the operator-plane AdminService: tenant fee-rate and reporting-policy overrides" +description: "ramp.admin.v1 protobuf reference -- the operator-plane AdminService: tenant fee-rate and reporting-policy overrides, plus the per-transaction evidence read" --- Source: [`proto/ramp/admin/v1/admin.proto`](https://github.com/RAMP-Protocol/protocol/blob/main/proto/ramp/admin/v1/admin.proto) :::note[Operator plane, not agent wire] -`ramp.admin.v1` is the Exchange **operator/config plane** — deliberately a separate package and service from `ExchangeService`, so the agent-facing contract is untouched. Deployments MUST NOT expose `AdminService` on the public agent-facing listener: reachability is restricted at the network layer (an internal listener plus a source allowlist), and there is no per-operator identity inside the service in v1. +`ramp.admin.v1` is the Exchange **operator plane — configuration and forensics** — deliberately a separate package and service from `ExchangeService`, so the agent-facing contract is untouched. Deployments MUST NOT expose `AdminService` on the public agent-facing listener: reachability is restricted at the network layer (an internal listener plus a source allowlist), and there is no per-operator identity inside the service in v1. -Both RPCs are **full-replace overwrites** and therefore idempotent — they carry no `idempotency_key` (the `ramp.v1` idempotency convention dedupes per verified RFC 9421 signer, and the admin plane has no request signing in v1) and no `ext`/`ext_critical` extension maps. Each request and response is a thin `{ver, payload}` envelope (`ver` is field 1, the RAMP protocol version — value `"1.0"`, stamped by the sender from the SDK's `ProtocolVersion` constant and advisory on receive, as on the agent-facing plane; the *reason* differs, since this plane rests on network-layer reachability rather than on a request signature) wrapping a required payload message — `TenantFeeRate` or `ReportingPolicy` — that is **shared by the request and its response**, so every field rule is stated once and the echoed read-back cannot drift from the write. Responses echo the payload **as persisted**, giving operator tooling a read-back confirmation. +No RPC here carries an `idempotency_key` (the `ramp.v1` idempotency convention dedupes per verified RFC 9421 signer, and the admin plane has no request signing in v1) or `ext`/`ext_critical` extension maps — the two setters are **full-replace overwrites** and the evidence read is side-effect-free, so every RPC is naturally idempotent. Each request and response is a thin `{ver, payload}` envelope (`ver` is field 1, the RAMP protocol version — value `"1.0"`, stamped by the sender from the SDK's `ProtocolVersion` constant and advisory on receive, as on the agent-facing plane; the *reason* differs, since this plane rests on network-layer reachability rather than on a request signature). Each setter wraps a required payload message — `TenantFeeRate` or `ReportingPolicy` — that is **shared by the request and its response**, so every field rule is stated once and the echoed read-back cannot drift from the write; responses echo the payload **as persisted**, giving operator tooling a read-back confirmation. The evidence read does not share this shape: its request carries only the `(tenant_id, transaction_id)` selector, and its response wraps read-only payloads that exist on no write path. Every constraint is a **field-level** protovalidate rule, so it flows into the generated Pydantic/Zod types; this package defines no cross-field (message-level CEL) rules. ::: @@ -18,7 +18,7 @@ Every constraint is a **field-level** protovalidate rule, so it flows into the g ### AdminService -Exchange operator overrides: the tenant fee rate and the tenant reporting policy. `SetOfferPrice` and `SetDeliveryWitnessMode` are planned extensions of this service and are tracked separately. +Exchange operator overrides — the tenant fee rate and the tenant reporting policy — plus one forensic read, `GetTransactionEvidence`, which returns the append-once evidence row the Exchange persists for every executed transaction. `SetOfferPrice` and `SetDeliveryWitnessMode` are planned extensions of this service and are tracked separately. ::proto-service{name=AdminService} @@ -61,3 +61,64 @@ A thin `{ver, policy}` envelope carrying the required `ReportingPolicy` to apply Echoes the `ReportingPolicy` as persisted. ::proto-message{name=SetReportingPolicyResponse} + +## Messages -- Transaction Evidence + +### TransactionEvidence + +One append-once evidence row, exactly as the Exchange persisted it for a successfully executed transaction. The row is written only after both Ed25519 signatures verified -- a denied execute writes nothing -- so the row's existence is itself the success statement. It re-verifies **offline**: `ed25519.Verify(exchange_signing_public_key, offer_canonical_bytes, hex-decoded offer_sig)` proves the Exchange signed this exact offer, and `ed25519.Verify(agent_public_key, agent_acceptance_canonical_bytes, hex-decoded agent_acceptance_signature)` proves the agent accepted it. Both verifying public keys ride along (not key ids) so re-verification survives key rotation; the `*_canonical_bytes` are the verbatim JCS bytes each signature was computed over, stored as-signed and never re-derived. + +**Trust boundary.** Offline re-verification proves the row is *internally consistent* -- each signature verifies against the key stored in the same row, so anyone able to write a row could mint one that passes. To prove *authenticity*, a verifier must compare the embedded keys against copies obtained independently — and only one of the two sides has an anchor inside a signature. + +- **Exchange side, anchored in the signed bytes.** `offer_canonical_bytes` carries the offer's `exchange` field (`ramp.v1.Offer.exchange`, the bare host of the issuing Exchange) and `offer_sig` covers it. Read that host *out of* the canonical bytes, fetch that Exchange's published JWKS (see [Authentication](/protocol/authentication/)), and check `exchange_signing_public_key` against it. A fabricated row cannot redirect this step: changing `exchange` invalidates the signature the check exists to confirm. +- **Agent side, no signed anchor.** `agent_directory_url` is covered by neither signature and is written by the same party as the rest of the row, so a fabricated row satisfies any procedure built on it using a host its author controls. Treat it as provenance — where this Exchange states it pinned the key — never as the authority. The agent anchor must be obtained independently: from the counterparty the audit is being run for, or from the agent's own directory located through an identity you already trust. That holds equally when the field is `''`. + +The in-row keys are convenience copies that keep old rows verifiable after rotation; they are not the root of trust. After matching a key against its authority, a verifier should also check that key's RFC 7638 thumbprint against a revocation list, because a key can be rotated out *because* it was revoked, and a revoked key must not count as authentic. + +There is no single list covering both keys. `WBAFile.revocation_url` is one URL per **directory**, so the `KeyRevocationList` served there can only enumerate that directory's own revoked keys. Each key is checked against its own side's list, reached the same way its anchor was: `exchange_signing_public_key` against the issuing Exchange's list, reached from the `exchange` host inside `offer_canonical_bytes`; `agent_public_key` against the agent's list, reached from the independent directory that supplied the agent anchor — never from `agent_directory_url`, which is provenance and not authority. + +The signatures cover what was **agreed**, not what was **delivered** -- `transaction_id`, `request_correlation` and `created_at` are the Exchange's own assertions about the delivery. The row deliberately carries no signed retrieval URL: the full URL is a live bearer capability until expiry, so the delivery join is hash-only via `TransactionState.signed_url_hash` -- which also means a ledger rendered from this contract can never show a signed-URL *signature* match assertion; hash equality against the transaction log's `signed_url_hash` column is the delivery assertion this plane supports. Both sides hold the same SHA-256 digest as 32 **raw bytes**, so a query joining the two stores compares byte to byte with nothing to normalize. Encodings enter only where a store is rendered as text -- protojson base64s this field, and a log export picks its own spelling, which the [transaction-log contract](/components/transaction-log/event-types/#signed-url-logging) owns and this one does not pin. So it is exports, not stores, that a join has to reconcile. + +::proto-message{name=TransactionEvidence} + +### RequestCorrelation + +The recorded correlation for one evidence row, with its provenance (`minted`: the id is server-derived, versus propagated verbatim from a caller-supplied `X-Request-ID` -- the two are byte-indistinguishable in the id alone). One message rather than two sibling fields so presence expresses the id-and-provenance pairing structurally, without message-level CEL. Absent from `TransactionEvidence` when the Exchange recorded no correlation. + +The contract fixes an **invariant**, not a mechanism: a stored `request_id` always conforms, because the check runs on the write path. A server that meets a nonconforming header by rejecting it and recording a server-derived id in its place conforms, and so does one that records no correlation at all. That is why `minted` means server-derived rather than "the header was absent" -- the property it exists for is whether a caller could influence the characters. + +The pair is read back from the evidence store's `request_id` and `request_id_minted` columns, which carry the same present-together-or-absent-together rule -- see [the evidence store](/components/exchange/storage-model/). The transaction log holds no correlation column, which is why `RequestCorrelation` sits on `TransactionEvidence` and not on `TransactionState`. + +::proto-message{name=RequestCorrelation} + +### TransactionState + +The thin transaction-log facts a ledger renderer needs next to the evidence row: the logged per-item idempotency key (derived as `request_idempotency_key + ":" + offer_id`, so never byte-equal to the bare request key -- join log exports on the derived form), the signed-URL expiry, and the sha256 of the signed retrieval URL. Every field here projects a transaction-log column, which is the property that makes the message meaningful -- a field with no column behind it would state something the log never recorded. Broker routing used to sit here and does not: it is an execute-time transport observation, not operational state, so it moved to `TransactionEvidence` beside the other facts covered by neither signature. There is deliberately no status field -- evidence only ever describes a successful execute, so existence is the status. + +::proto-message{name=TransactionState} + +### ReportingObligationState + +The server-side lifecycle record of the transaction's reporting obligation: the `ObligationState`, the reported consumed quantity (mirroring the wire's `Usage.consumed_quantity` `int32`, absent until a report has been accepted), when the report is due, when a report was accepted, and when the obligation was minted. The three timestamp fields carry the store's own column names in snake_case (`window_end`, `fulfilled_at`, `created_at`), so this record joins against the [storage model](/components/exchange/storage-model/) by name with no translation step. Named apart from `ramp.v1.ReportingObligation`, the agent-facing requirements contract this state was minted from. + +::proto-message{name=ReportingObligationState} + +### GetTransactionEvidenceRequest + +A thin `{ver, transaction_id, tenant_id}` envelope. Selection is by the `(tenant_id, transaction_id)` pair: transaction ids legitimately circulate outside the deployment (every counterparty agent holds the ids of its own transactions), so the id alone must not act as a bearer capability for the forensic row. Naming the tenant narrows what a leaked id is worth — that is all it does. It is not an access control, and this plane has none: `ramp.admin.v1` carries no request signing and no per-operator identity, so there is no caller to attach a per-tenant rule to. The pair also does not make enumeration infeasible, because tenant ids are human brand slugs that any agent holding one of that tenant's offers already knows. Enumeration is bounded by reachability — this service must not be exposed on the public agent-facing listener. + +::proto-message{name=GetTransactionEvidenceRequest} + +### GetTransactionEvidenceResponse + +The evidence row plus the transaction-log and reporting-obligation state needed to render it. `evidence` and `transaction_state` are required -- they exist 1:1 for every found transaction, and an unknown `transaction_id` or a `tenant_id` mismatch is `NOT_FOUND` (byte-identical in both cases, so existence under another tenant is not revealed), never an empty response. `obligation_state` is the transaction's reporting obligation record as persisted -- the store keeps one obligation per transaction, transitioning in place -- and is absent when the transaction minted none. + +::proto-message{name=GetTransactionEvidenceResponse} + +## Enums + +### ObligationState + +Lifecycle of a reporting obligation, exactly the vocabulary the Exchange persists (the storage model's `Pending`/`Fulfilled`/`Expired`/`Waived`/`Blocked` -- see the [Exchange storage model](/components/exchange/storage-model/)). Defined in this package rather than imported from `ramp.v1`, which states reporting *requirements*, never their server-side lifecycle. A *rejected* usage report does not transition the obligation: it stays `PENDING` until an accepted report, a waiver, or expiry. + +::proto-enum{name=ObligationState numbers} diff --git a/website/src/content/docs/reference/proto-ramp.mdx b/website/src/content/docs/reference/proto-ramp.mdx index fcd1d0d9..e92068b2 100644 --- a/website/src/content/docs/reference/proto-ramp.mdx +++ b/website/src/content/docs/reference/proto-ramp.mdx @@ -9,7 +9,7 @@ Source: [`proto/ramp/v1/ramp.proto`](https://github.com/RAMP-Protocol/protocol/b :::note[Envelope and correlation] Every RPC request/response carries `ver` (field 1, RAMP protocol version). Its value is `"1.0"`, and senders MUST stamp it — from the SDK's `ProtocolVersion` constant rather than a literal, so a protocol bump is a single edit. On receive `ver` is **advisory**: it is not an authenticity or authorization control and MUST NOT be used as one, a receiver is not required to check it, and one that does check it MAY reject an unrecognised **major** version but MUST NOT reject an unrecognised **minor** version. Version negotiation, where it is needed, happens out of band — an Exchange publishes `WellKnownManifest.protocol_versions_supported` and a Broker filters on it before sending — which is why the in-band field need not be a rejection gate. It carries no protovalidate rule. `WellKnownManifest.ver` is a separate namespace: it versions the `/.well-known/ramp.json` document schema, is stated MUST-equal, and is not coupled to the envelope value. The external-contract messages (domain verification, catalog push) also carry `ext` (field 15) and `ext_critical` (field 90, COSE `crit`, RFC 9052). -Request correlation is **not** carried in the proto body. It rides on the `X-Request-ID` HTTP header, and cross-system tracing uses W3C Trace Context (`traceparent` / `tracestate`). The former `request_id` fields have been removed from every message. +Request correlation is **not** carried in the proto body. It rides on the `X-Request-ID` HTTP header, and cross-system tracing uses W3C Trace Context (`traceparent` / `tracestate`). The former `request_id` fields have been removed from every `ramp.v1` message. One admin-plane carve-out exists by the persisted-identifier rule: the forensic evidence read (`ramp.admin.v1.RequestCorrelation`) states, after the fact, the correlation id the Exchange *persisted* for a transaction — no live request or response carries one in its body. ::: :::note[Wire format is snake_case proto-JSON] diff --git a/website/src/content/docs/security/threat-model.mdx b/website/src/content/docs/security/threat-model.mdx index a0fe4f2b..35e07f75 100644 --- a/website/src/content/docs/security/threat-model.mdx +++ b/website/src/content/docs/security/threat-model.mdx @@ -120,7 +120,7 @@ This single pattern accounts for most ad-tech fraud: domain spoofing (self-repor ### T22: Exchange impersonation **Attack**: Attacker sets up fake Exchange, poisons ramp.json via DNS hijack. -**Countermeasure (protocol)**: ramp.json served over HTTPS from provider's domain (TLS proves endorsement). Every Offer carries `exchange_signature` (Ed25519). Agent verifies against Exchange's public key. +**Countermeasure (protocol)**: ramp.json served over HTTPS from provider's domain (TLS proves endorsement). Every Offer carries a `signature` (detached hex Ed25519). Agent verifies against Exchange's public key. ### T23: Signed URL interception (MITM) **Attack**: Attacker intercepts TransactionResponse, steals signed URL. @@ -128,7 +128,7 @@ This single pattern accounts for most ad-tech fraud: domain spoofing (self-repor ### T24: Transaction replay **Attack**: Attacker captures valid TransactionRequest and replays it. -**Countermeasure (protocol)**: **Idempotency keys.** Every state-mutating call carries a required `idempotency_key` — `TransactionRequest`, `UsageReport`, and `DisputeRequest`. Servers dedupe per (verified RFC 9421 signer, key), so a replay returns the original result instead of re-charging or re-filing. Broker `Resolve` (`DiscoveryRequest`) is pure discovery — it executes no transaction, has no replay exposure, and carries no key. +**Countermeasure (protocol)**: **Idempotency keys.** Every state-mutating call carries a required `idempotency_key` — `TransactionRequest`, `UsageReport`, and `DisputeRequest` — and a replay returns the original result instead of re-charging or re-filing. The invariant is that a key chosen by one caller can never collide with another caller's cached result, so dedupe always happens inside a namespace, never globally. The namespace differs per RPC because the three do not authenticate the same way: `ExecuteTransaction` scopes to the acceptance identity (the agent key that signed `AgentAcceptancePayload`, never the transport sender, which may be a Broker relaying many agents); `ReportUsage` and `FileDispute` carry no acceptance payload and scope to the transaction they name, which its acceptance bound to exactly one agent at execute time. Broker `Resolve` (`DiscoveryRequest`) is pure discovery — it executes no transaction, has no replay exposure, and carries no key. ### T25: Billing_id forgery in usage reports **Attack**: Attacker fabricates billing_ids in usage reports. @@ -160,14 +160,48 @@ This single pattern accounts for most ad-tech fraud: domain spoofing (self-repor **Attack**: An unauthorized caller probes for a resource and reads the `absence_reason`. Authorization-flavored values (`SCOPE_INSUFFICIENT`, `NOT_AUTHORIZED`, `NOT_IN_CATALOG`, `CONTENT_BLOCKED`) distinguish "no such resource" from "it exists but you may not have it" — confirming existence and the reason for refusal. Broker `Resolve` (`DiscoveryResponse.absence_reason`) exposes the same oracle at the broker that `DiscoverResources` (`OfferGroup.absence_reason`) does at the Exchange, so hiding it on one surface but not the other still leaks. **Countermeasure (protocol)**: The reason is a hint, not a guarantee. Where existence itself must stay hidden, the Exchange/Broker **MAY omit** `absence_reason` (leave it unset) rather than reveal an authorization-flavored value — the same permissive hedge documented on `OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT`, and it applies equally to `OfferGroup.absence_reason` and `DiscoveryResponse.absence_reason`. `ErrorDetail.message`/`metadata` are held to the same rule (T-LIC-adjacent leakage): servers SHOULD NOT restore the withheld detail there as free text. +## 7. Admin-Plane Threats (Forensic Evidence Read) + +The admin plane (`ramp.admin.v1`) adds one high-value asset: the append-once evidence row — the full signed offer (pricing, licensing terms, quota), both Ed25519 proofs with their verbatim canonical bytes, and both verifying keys. The plane has no request signing and no per-operator identity in v1; reachability is restricted at the network layer. + +### T-ADM-1: Evidence-row enumeration / cross-tenant read +**Attack**: A caller with network reachability walks transaction ids (which legitimately circulate to counterparty agents) and reads other tenants' evidence rows — offer pricing, licensing terms, quota. +**Countermeasure**: `GetTransactionEvidence` selects by the `(tenant_id, transaction_id)` **pair**, so an id alone is not a bearer capability. That is the whole of the protocol-level control. The gate that actually bounds this threat is the network boundary — an internal listener plus a source allowlist, failing closed when the list is empty — and it is configured per deployment, not by the contract. +**Residual**: The pair selector does not by itself defeat enumeration, and it should not be read as if it did. The tenant half is not a secret — tenant ids are human brand slugs (`hearst-media`), and a tenant's slug is visible to every agent holding one of its offers, because it prefixes the `offer_id` inside the signed offer. A caller who reaches this plane and has done business with a tenant therefore already holds one valid tenant value and can walk ids against it. RAMP also places no entropy requirement on `transaction_id`, so those ids may legitimately be sequential. What actually bounds enumeration is the network boundary, and nothing else does. A per-tenant ACL is sometimes assumed to be the answer here and is not available: this plane carries no request signing and no per-operator identity, so there is no caller to attach an ACL to. That is a property of the plane's design, not a gap someone forgot to fill — until the plane gains a verified caller identity, the allowlist is the only control, and the pair selector merely narrows what a stolen id is worth. + +### T-ADM-2: NOT_FOUND existence oracle +**Attack**: A caller probes a transaction id under the wrong tenant and uses the error shape to confirm the transaction exists somewhere. +**Countermeasure**: A tenant mismatch is `NOT_FOUND`, byte-identical to an unknown id — existence under another tenant is not revealed. + +### T-ADM-3: Cross-plane correlation-id injection +**Attack**: An agent supplies a hostile `X-Request-ID` (control characters, oversized, log-format metacharacters) on the hot path; the Exchange persists it and the admin plane later replays it into a rendered ledger or log pipeline. +**Countermeasure**: `RequestCorrelation.request_id` is bounded printable ASCII (`^[!-~]+$`, max 255), and the check runs on the **write** path, so a hostile value never enters the store. The contract fixes the *invariant* — a stored id always conforms — and leaves the mechanism to the server: it may reject the bad header and record a server-derived id in its place, or record no correlation at all. Both close this attack, because neither persists the hostile characters. A server must also never populate the field from a nonconforming stored value — rows written before the write check existed may hold one — but that is a migration guard, not the primary defence. The `minted` flag separates server-derived ids from caller-influenceable ones so a renderer can treat the latter as untrusted. +**Residual**: The contract enforces the write path nowhere. It is server behaviour, and a field rule on a response message cannot reach back to it. If the write check is missing the failure is not a leaked value but a **denied read**: `request_id` is required inside a required message, so one nonconforming stored value invalidates the entire `GetTransactionEvidenceResponse` rather than degrading it — an agent that can get a hostile header persisted bricks its own transaction's forensic row. The reference Exchange closes this at its request middleware, with a charset stricter than the contract requires. The Go SDK's request-id middleware closes it too, at the contract's own charset: a received header is propagated only if it conforms, and a nonconforming one is replaced by a server-derived id rather than passed through, with the substitution reported as `minted`. A server that composes it therefore inherits the write-path check; a server that writes correlation ids by another route still has to make the check itself. + +### T-ADM-4: Fabricated evidence row +**Attack**: Anyone able to write to the store (or to man-in-the-middle the read) mints a row whose signatures verify against the keys embedded in the same row — internally consistent, but proving nothing. +**Countermeasure**: The contract states the trust boundary explicitly: authenticity requires comparing the embedded keys against independently obtained copies. The Exchange anchor is signed — `Offer.exchange` sits inside `offer_canonical_bytes`, under `offer_sig`, so a fabricated row cannot redirect the verifier to a host it controls without invalidating the signature being checked. The agent side has no signed anchor: `agent_directory_url` is covered by neither signature and is written by the row's author, so it is provenance only and the agent key must be anchored from the counterparty's own directory. The in-row keys are convenience copies for rotation-survival, not the root of trust. +**Residual**: This bounds who a *verifier* can be redirected to; it does not stop a store writer from minting a row that is internally consistent. Detection depends on the independent anchors above actually being used. + +Stated plainly, because it is the honest boundary of what this plane attests: independent proof that the agent actually published this key at that directory would need archived directory snapshots or an append-only key history, and neither exists. An attacker who controls an Exchange can write a row whose signatures verify under a key the agent never published; the row's directory URL is an assertion, not a proof. + +Two properties follow that read like defects and are not. An Exchange may pin the agent key locally after fetching it from the agent's directory, and verify against the pin rather than re-fetching per request — the directory is still the anchor, consulted at registration and rotation. And an old row's `agent_public_key` will not match the current registry after the agent rotates. That mismatch is the reason the field exists: the row attests which key signed these terms, and where and when this Exchange obtained it. A registry-match expectation would be false on purpose. + ## Prevention Taxonomy | Level | Threat IDs | What It Means | |---|---|---| -| **Preventable at protocol level** | T3, T4, T5, T6, T7, T8, T9, T12, T16, T19, T22, T23, T24, T25, T26, T-ATT-1, T-ATT-2, T-ATT-3, T-ATT-5, T-DEL-1, T-DEL-2, T-DEL-3, T-DEL-4, T-DEL-5, T-DEL-6 | Protocol changes make the attack structurally impossible | -| **Detectable via reconciliation** | T1, T2, T4, T5, T13, T14, T15, T17, T27, T-ATT-4 | Three-sided reconciliation catches it | +| **Preventable at protocol level** | T3, T4, T5, T6, T7, T8, T9, T12, T16, T19, T22, T23, T24, T25, T26, T-ATT-1, T-ATT-2, T-ATT-3, T-ATT-5, T-DEL-1, T-DEL-2, T-DEL-3, T-DEL-4, T-DEL-5, T-DEL-6, T-ADM-2, T-ADM-3 | Protocol changes make the attack structurally impossible | +| **Bounded only by a deployment control** | T-ADM-1 | The protocol narrows the attack but cannot stop it. A control outside the contract is what actually bounds it, and it has to be configured per deployment | +| **Detectable via reconciliation** | T1, T2, T4, T5, T13, T14, T15, T17, T27, T-ATT-4, T-ADM-4 | Three-sided reconciliation catches it | | **Legal/contractual only** | T10, T11, T13, T17, T18, T20, T21, T28, T29, T30 | Can't enforce technically | +**Why the admin-plane rows moved.** T-ADM-1 and T-ADM-4 were filed as structurally prevented, and neither is. T-ADM-1's own countermeasure text names the network boundary as the outer control, which is a deployment control by definition; the pair selector narrows a stolen id but does not stop a caller who reaches the plane. T-ADM-4's countermeasure is documenting a trust boundary and comparing keys out of band — that is detection, not prevention, and it depends on a verifier actually performing the comparison. + +**The row that needs configuring is not the one an operator expects.** `ramp.admin.v1` has no request signing and no per-operator identity, so there is nothing to attach a per-tenant ACL to — a per-tenant ACL is structurally unavailable under this plane's design, not merely unimplemented. The only gate is the network allowlist, which must fail closed on an empty list. + +That gate now covers a materially larger blast radius than it used to. The pre-existing admin RPCs are config writes scoped to one named tenant (`SetTenantFeeRate`, `SetReportingPolicy`). `GetTransactionEvidence` is a **cross-tenant read of the whole evidence plane**: anyone inside the allowlist can read every tenant's signed offers, pricing, licensing terms and quota. Same gate, different consequence — sizing the allowlist as if it still guarded fee-rate writes under-protects the read. + ### The Three Lines of Defense ``` @@ -186,7 +220,7 @@ Line 3: Legal/contractual enforcement Handles the black-box problem (what agent did internally) ``` -## 7. Attestation-Specific Threats +## 8. Attestation-Specific Threats ### T-ATT-1: Forged attestation injection @@ -216,7 +250,7 @@ Line 3: Legal/contractual enforcement **Attack**: Unauthorized party registers a domain similar to a legitimate verifier (e.g., `doub1everify.com`) and pushes attestations claiming to be a trusted vendor. **Countermeasure (protocol)**: `catalog_contributors` in `ramp.json` uses exact domain matching. The provider must explicitly list the contributor's domain. The Exchange fetches the WBA directory from the exact `verifier` domain in the attestation and verifies the signature against that domain's `keys` (the JWK Set at `/.well-known/http-message-signatures-directory`). -## 8. Delegation and Authorization Threats +## 9. Delegation and Authorization Threats ### T-DEL-1: Delegation token theft @@ -248,7 +282,7 @@ Line 3: Legal/contractual enforcement **Attack**: Agent obtains multiple delegation tokens for the same subscription (e.g., by requesting new delegations from different intermediaries) to exceed per-subscription quotas. **Countermeasure (protocol)**: **Exchange tracks quotas by `subscription_id`, not by delegation token.** All tokens referencing the same subscription share one counter. Multiple tokens for the same subscription do not multiply the quota. Rate limiting is applied at the subscription level regardless of how many delegation tokens reference it. -## 9. Licensing-Layer Threats +## 10. Licensing-Layer Threats ### T-LIC-1: SSRF / phishing via `License.uri` @@ -280,7 +314,7 @@ Line 3: Legal/contractual enforcement |---|---|---|---| | ads.txt | 2017 | ramp.json (authorized providers) | In proto | | sellers.json | 2019 | Exchange identity in ResourceResponse | In proto | -| SupplyChain Object | 2019 | exchange_signature on Offer | In proto | -| ads.cert | 2020 | exchange_signature + content_integrity_hash | In proto | +| SupplyChain Object | 2019 | `Offer.signature` | In proto | +| ads.cert | 2020 | `Offer.signature` + content_integrity_hash | In proto | | MRC Viewability Standard | 2014 | Minimum billable token count | Design principle | | Open Measurement SDK | 2018 | Three-sided reconciliation | In protocol flow |