From c9363cd1cb0a7ed9e648e2a3610fd911dca3d9e4 Mon Sep 17 00:00:00 2001 From: legendko Date: Wed, 2 Sep 2026 11:04:40 +0200 Subject: [PATCH 1/9] fix(sdk): check restriction disjointness on the tokens the fold produces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token cannot be both permitted and prohibited, and the contract says so as a CEL rule over the request exactly as received. That rule compares SPELLINGS where the meaning is a TOKEN. Ten restriction tokens have more than one accepted spelling — scrape is an alias of crawl, adapt and derivative both mean modify, personal means individual — and every axis also folds ASCII case, lower for FUNCTION and USER_TYPE, upper for GEOGRAPHY. So a term naming one spelling under permitted and another under prohibited passes the boundary check, because as written the two lists share nothing, and then folds into a stored term where the same token sits in both lists. Nothing looked at it again. The stored term rides on offers and the Exchange validates its own responses, so the failure surfaced far away and much later: every discovery request returning that resource answered with an internal error, the push having looked clean and the publisher having been told nothing. The ingest tier now asserts the same property over the canonical values, under its own rule id — one name per rule, which a conformance guard requires. The boundary rule is unchanged: it still catches the plain case, and it costs nothing. Three properties are deliberate. The check folds what it compares instead of trusting the term to have been normalised. Both callers do normalise — the Exchange in place, the entry face on a copy — but a rule written to close an ordering gap must not open one of its own, and the fold is a fixed point, so it costs a pass over tokens already folded and nothing else. It is also what lets the corpus express the fault at all, since the per-term list feeds terms as authored. It runs on every axis, including the one with no canonicalisation rule. There the fold returns the token unchanged and the comparison is plain equality, which is what it must be: the server binding defaults to validation off, and on such a mount the ingest tier is the only tier a pushed term meets. The finding names the canonical token and not the spellings that produced it. A message quoting the spellings differs depending on whether the caller folded first, and the message is wire-visible and pinned byte for byte across three SDKs. The path locates the permitted element; the prohibited one is the entry that folds to the same token. The per-alias vectors are derived from the generated vocabulary rather than listed, so an alias added to the proto brings its case with it. The entry corpus now records the plain overlap under both rules, which is what composing the two tiers means: the boundary reports what it sees, the ingest tier reports what the fold produced, and neither suppresses the other. Folding rewrites tokens and never kind, so the neighbouring one-restriction-per- kind rule cannot be created or destroyed by it. That was assumed by the split between the tiers and is now asserted. Cost, measured on this machine over a worst-case conformant term (8 restrictions, each 64 permitted and 64 prohibited tokens, none colliding, so the walk never returns early): ValidateLicenseTerm goes from 346us to 392us. The prohibited list is folded once into a set, so the added work is one fold per token; a pairwise walk would have folded four thousand times per restriction. --- .../helpers/gen_licenseterm_vectors_test.go | 120 +++- sdk/go/helpers/licenseterm.go | 91 ++- sdk/go/helpers/licenseterm_corpus_test.go | 3 +- sdk/go/helpers/licenseterm_test.go | 61 ++ .../helpers/testdata/licenseterm-vectors.json | 623 +++++++++++++++++- 5 files changed, 884 insertions(+), 14 deletions(-) diff --git a/sdk/go/helpers/gen_licenseterm_vectors_test.go b/sdk/go/helpers/gen_licenseterm_vectors_test.go index 76b4e93f..26a18544 100644 --- a/sdk/go/helpers/gen_licenseterm_vectors_test.go +++ b/sdk/go/helpers/gen_licenseterm_vectors_test.go @@ -17,8 +17,11 @@ package helpers import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" + "sort" + "strings" "testing" "google.golang.org/protobuf/encoding/protojson" @@ -26,6 +29,8 @@ import ( "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" + "github.com/RAMP-Protocol/protocol/gen/go/vocab/functiontokens" + "github.com/RAMP-Protocol/protocol/gen/go/vocab/usertypes" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -236,6 +241,23 @@ func buildLTFoldVectors() []ltFoldVector { return out } +// ltSortedAliases returns an axis's alias spellings in a fixed order, so the +// derived case list is stable across runs — Go randomises map iteration and the +// corpus is compared byte for byte. +func ltSortedAliases(aliases map[string]string) []string { + out := make([]string, 0, len(aliases)) + for alias := range aliases { + out = append(out, alias) + } + sort.Strings(out) + return out +} + +// ltCaseToken renders a token as a case-name segment. Only the hyphen needs +// folding: every registered spelling is otherwise lowercase alphanumerics and +// underscores, which the vocabulary generator enforces. +func ltCaseToken(token string) string { return strings.ReplaceAll(token, "-", "_") } + func ltRestriction(kind rampv1.RestrictionKind, permitted, prohibited []string) *rampv1.Restriction { return &rampv1.Restriction{Kind: kind, Permitted: permitted, Prohibited: prohibited} } @@ -368,10 +390,11 @@ func buildLTValidateVectors(t *testing.T) []ltValidateVector { licenseURI := func() *rampv1.License { return &rampv1.License{Uri: proto.String("https://example.com/license.txt"), UriDigest: proto.String("sha256:" + ltRepeatHex(64))} } - cases := []struct { + type validateCase struct { name string term *rampv1.LicenseTerm - }{ + } + cases := []validateCase{ {"reference_only_with_restrictions_accepted", &rampv1.LicenseTerm{ Semantics: rampv1.TermSemantics_TERM_SEMANTICS_REFERENCE_ONLY, License: licenseURI(), Pricing: ltPerUnitPricing("accesses"), Restrictions: []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{"ai-train"}, nil)}, @@ -456,7 +479,89 @@ func buildLTValidateVectors(t *testing.T) []ltValidateVector { x.Restrictions = []*rampv1.Restriction{{Permitted: []string{"flibbertigibbet"}}} })}, {"empty_term_accepted", &rampv1.LicenseTerm{}}, + + // The canonical-disjointness reject. The alias pairs are derived below; these + // are the shapes a table of aliases cannot express. + // + // Two ALIASES of one token, neither of them the registered spelling — the + // pairing the derived cases never produce, because they always pair an alias + // with its canonical form. + {"restriction_two_aliases_of_one_token_collide_rejected", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{"adapt"}, []string{"derivative"})} + })}, + // Case folding alone, with no alias in sight: the axis that folds DOWN, and + // the axis that folds UP and registers no aliases at all. + {"restriction_function_case_collides_rejected", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{"CRAWL"}, []string{"crawl"})} + })}, + {"restriction_geography_case_collides_rejected", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindGeography, []string{"us"}, []string{"US"})} + })}, + // An axis with no canonicalisation rule at all. The check still runs there: + // the fold returns the token unchanged and the comparison is plain equality, + // which is the only tier a term meets on a server that never asked for the + // wire tier. + {"restriction_other_axis_exact_duplicate_rejected", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindOther, []string{"custom-use"}, []string{"custom-use"})} + })}, + // The RFC 8259 trim is part of the fold, so it can collide two spellings too. + // Only a direct caller can reach this: the wire pattern forbids whitespace in + // a token, so an entry carrying it never gets as far as the ingest tier. + {"restriction_padded_token_collides_after_trim_rejected", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{" crawl "}, []string{"crawl"})} + })}, + // The other side of the rule: folding must not invent a collision. + {"restriction_disjoint_after_fold_accepted", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{"scrape"}, []string{"ai-train"})} + })}, + {"restriction_namespaced_tokens_disjoint_accepted", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindOther, []string{"acme:read"}, []string{"acme:write"})} + })}, + // Scan order, both across restrictions and within one. A term with more than + // one collision reports exactly one, and which one is not left to chance. + {"restriction_second_restriction_collides_rejected", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ + ltRestriction(ltKindFunction, []string{"ai-train"}, []string{"search"}), + ltRestriction(ltKindUserType, []string{"personal"}, []string{"individual"}), + } + })}, + {"restriction_collision_reports_first_permitted_site", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, + []string{"tdm", "scrape"}, []string{"crawl", "text-and-data-mining"})} + })}, + // Precedence against the checks that run before it. + {"quota_metric_checked_before_restriction_collision", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Quotas = []*rampv1.Quota{{Metric: "frobnications", Limit: 10, Window: rampv1.QuotaWindow_QUOTA_WINDOW_DAILY}} + x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{"scrape"}, []string{"crawl"})} + })}, + } + + // One case per REGISTERED ALIAS, derived from the generated vocabulary rather + // than listed, so an alias added to the proto brings its vector with it and the + // obligation to cover every alias pair cannot quietly rot. Each pairs the alias + // under permitted with its registered form under prohibited: two strings the + // wire tier reads as disjoint and the fold reads as one token. GEOGRAPHY is + // absent because it registers no aliases — its collisions are the case-folding + // ones above. + for _, axis := range []struct { + name string + kind rampv1.RestrictionKind + aliases map[string]string + }{ + {"function", ltKindFunction, functiontokens.Aliases}, + {"user_type", ltKindUserType, usertypes.Aliases}, + } { + for _, alias := range ltSortedAliases(axis.aliases) { + canonical := axis.aliases[alias] + cases = append(cases, validateCase{ + fmt.Sprintf("restriction_alias_%s_%s_collides_rejected", axis.name, ltCaseToken(alias)), + ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ltRestriction(axis.kind, []string{alias}, []string{canonical})} + }), + }) + } } + out := make([]ltValidateVector, 0, len(cases)) for _, c := range cases { warnings, err := ValidateLicenseTerm(c.term) // REAL face @@ -596,6 +701,14 @@ func buildLTEntryVectors(t *testing.T) []ltEntryVector { e.Path = "x" e.Terms[0] = ltEnumerated(ltPerUnitPricing("frobnications"), nil) })}, + // The ingest tier catching what the wire tier cleared. The entry is + // wire-conformant — two different strings — so the boundary rule reports + // nothing and the reject arrives with an entry-relative path. + {"term_reject_restriction_canonical_disjoint", entry(func(e *rampv1.ResourceEntry) { + e.Terms[0] = ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { + x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{"scrape"}, []string{"crawl"})} + }) + })}, {"no_terms_accepted", entry(func(e *rampv1.ResourceEntry) { e.Terms = nil })}, } celIDs := ltCrossFieldRuleIDs(t) @@ -611,7 +724,8 @@ func buildLTEntryVectors(t *testing.T) []ltEntryVector { CrossFieldRules: []string{}, TermRules: []ltFinding{}, Warnings: ltWarningsOf(verdict.Warnings)} for _, viol := range verdict.Violations { switch { - case viol.Rule == RulePricingUnitRegistered || viol.Rule == RuleQuotaMetricRegistered: + case viol.Rule == RulePricingUnitRegistered || viol.Rule == RuleQuotaMetricRegistered || + viol.Rule == RuleRestrictionCanonicalDisjoint: v.TermRules = append(v.TermRules, ltFindingOf(viol.Rule, viol.Path, viol.Token, viol.Message)) case celIDs[viol.Rule]: v.CrossFieldRules = append(v.CrossFieldRules, viol.Rule) diff --git a/sdk/go/helpers/licenseterm.go b/sdk/go/helpers/licenseterm.go index de8ee922..50a7b6cb 100644 --- a/sdk/go/helpers/licenseterm.go +++ b/sdk/go/helpers/licenseterm.go @@ -23,7 +23,8 @@ import ( // structural and cross-field rules, applied to the request as received. The // ingest tier runs over the CANONICALISED terms: restriction tokens are folded // and alias-resolved to their registered form, then a bare Pricing.unit or -// Quota.metric that is not a registered token is rejected, while an +// Quota.metric that is not a registered token is rejected, as is a restriction +// whose permitted and prohibited lists name one token once folded, while an // unregistered restriction token and an OBLIGATION_KIND_OTHER obligation // without detail are accepted with a warning that reaches // PushResourcesResponse.warnings. @@ -55,6 +56,13 @@ const ( // RuleQuotaMetricRegistered rejects a bare Quota.metric that is not a // registered quota token. RuleQuotaMetricRegistered = "quota.metric.registered" + // RuleRestrictionCanonicalDisjoint rejects a restriction whose permitted and + // prohibited lists name the same token once both are canonicalised. The wire + // tier's rule compares the tokens AS WRITTEN, so two accepted spellings of one + // token — an alias beside its registered form, or two spellings differing only + // in ASCII case — pass it and collide only after the fold. This is that + // property read on the values the fold produces. + RuleRestrictionCanonicalDisjoint = "restriction.canonical_disjoint" // RuleRestrictionTokenRegistered warns about a bare restriction token that is // not registered on its axis. The term is accepted: the restriction // vocabulary is open and forward-compatible, and under scope-only projection @@ -150,7 +158,9 @@ func KnownRestrictionToken(kind rampv1.RestrictionKind, token string) bool { // form in place, on every axis that carries a canonicalisation rule. It touches // nothing else — Pricing.unit and Quota.metric are exact registry values, scopes // are matched verbatim — and is nil-safe and idempotent. Run it before -// ValidateLicenseTerm, which assumes canonical tokens. +// ValidateLicenseTerm, whose checks read canonical tokens — all but the +// disjointness check, which folds what it compares and so reaches the same +// verdict on either form. func NormalizeLicenseTerm(term *rampv1.LicenseTerm) { for _, r := range term.GetRestrictions() { kind := r.GetKind() @@ -174,18 +184,29 @@ func NormalizeResourceEntry(entry *rampv1.ResourceEntry) { } } -// ValidateLicenseTerm runs the ingest-tier checks over one already-canonical -// term. It returns a *RuleViolation as the error for a hard reject — a bare -// Pricing.unit or Quota.metric that is not a registered token, pricing checked -// first and the first offending quota winning — and, when the term is accepted, -// the warnings it would carry: one per unregistered bare restriction token, in +// ValidateLicenseTerm runs the ingest-tier checks over one term. It returns a +// *RuleViolation as the error for a hard reject, in a fixed order: a bare +// Pricing.unit that is not a registered token, then the first offending quota +// metric, then the first restriction whose permitted and prohibited lists name +// one token once canonicalised. When the term is accepted it returns the +// warnings it would carry: one per unregistered bare restriction token, in // restriction order with permitted before prohibited, then one per // OBLIGATION_KIND_OTHER obligation without detail. Empty and vendor-namespaced // (containing ":") tokens are never membership-checked. // +// Every check but one reads the term as ALREADY CANONICAL, which is what +// NormalizeLicenseTerm produces. The exception is the disjointness check, which +// folds what it compares: it exists because a rule that compares spellings +// answers a question about tokens, so a rule written to close that gap must not +// in turn assume its own caller folded first. +// // The wire tier is not re-run here: token format, PER_UNIT⇒unit, FREE⇒rate 0, -// one restriction per kind, permitted∩prohibited and the presence rules are -// protovalidate's, and ValidateResourceEntry composes the two tiers. +// one restriction per kind and the presence rules are protovalidate's, and +// ValidateResourceEntry composes the two tiers. Disjointness is the one property +// BOTH tiers assert, over different values — permitted∩prohibited over the tokens +// as written, and the rule below over the tokens the fold produces — so a term +// the first clears can still fail the second, and a term that fails both is +// reported by both. func ValidateLicenseTerm(term *rampv1.LicenseTerm) ([]RuleWarning, error) { if unit := term.GetPricing().GetUnit(); bareUnregistered(unit, pricingunits.IsRegistered) { return nil, &RuleViolation{ @@ -205,6 +226,11 @@ func ValidateLicenseTerm(term *rampv1.LicenseTerm) ([]RuleWarning, error) { } } } + for i, r := range term.GetRestrictions() { + if v := canonicalDisjointViolation(i, r); v != nil { + return nil, v + } + } var warnings []RuleWarning for i, r := range term.GetRestrictions() { kind := r.GetKind() @@ -284,6 +310,53 @@ func ValidateResourceEntry(entry *rampv1.ResourceEntry) EntryVerdict { return verdict } +// canonicalDisjointViolation returns the violation for the first permitted token +// of r whose canonical form is also the canonical form of one of its prohibited +// tokens, or nil when the two lists name no token in common. The permitted list +// decides the order, so a restriction carrying several collisions always answers +// the same way. +// +// It canonicalises what it compares rather than trusting r to have been +// normalised, and it runs on EVERY axis. On an axis with no canonicalisation +// rule the fold returns the token unchanged, so the check degenerates to plain +// equality — which is what it must do on a server that never asked for the wire +// tier, where this is the only tier a pushed term meets. +// +// The finding names the CANONICAL token and not the spellings that produced it. +// Naming the spellings would make the message depend on whether the caller had +// folded first — the Exchange has, a direct caller has not — and the message is +// wire-visible and pinned byte-for-byte across the three SDKs. Path locates the +// permitted element; the prohibited one is the entry that folds to the same token. +// +// The prohibited list is folded once into a set, so the cost is one fold per +// token rather than one per pair: both lists are capped at 64, and a pairwise +// walk would fold four thousand times per restriction. +func canonicalDisjointViolation(i int, r *rampv1.Restriction) *RuleViolation { + prohibited := r.GetProhibited() + permitted := r.GetPermitted() + if len(prohibited) == 0 || len(permitted) == 0 { + return nil + } + kind := r.GetKind() + banned := make(map[string]struct{}, len(prohibited)) + for _, tok := range prohibited { + banned[CanonicalRestrictionToken(kind, tok)] = struct{}{} + } + for j, tok := range permitted { + canon := CanonicalRestrictionToken(kind, tok) + if _, ok := banned[canon]; !ok { + continue + } + return &RuleViolation{ + Rule: RuleRestrictionCanonicalDisjoint, + Path: fmt.Sprintf("restrictions[%d].permitted[%d]", i, j), + Token: canon, + Message: fmt.Sprintf("restriction token %q is both permitted and prohibited after canonicalisation", canon), + } + } + return nil +} + // restrictionTokenWarning returns the warning for one restriction token, or // false when the token needs none (empty, vendor-namespaced, or registered). func restrictionTokenWarning(kind rampv1.RestrictionKind, tok, path string) (RuleWarning, bool) { diff --git a/sdk/go/helpers/licenseterm_corpus_test.go b/sdk/go/helpers/licenseterm_corpus_test.go index a96a4d8a..1ea94f30 100644 --- a/sdk/go/helpers/licenseterm_corpus_test.go +++ b/sdk/go/helpers/licenseterm_corpus_test.go @@ -230,7 +230,8 @@ func TestLicenseTermCorpus_Entry(t *testing.T) { termRules := []corpusFinding{} for _, viol := range verdict.Violations { switch { - case viol.Rule == helpers.RulePricingUnitRegistered || viol.Rule == helpers.RuleQuotaMetricRegistered: + case viol.Rule == helpers.RulePricingUnitRegistered || viol.Rule == helpers.RuleQuotaMetricRegistered || + viol.Rule == helpers.RuleRestrictionCanonicalDisjoint: termRules = append(termRules, corpusFinding{Rule: viol.Rule, Path: viol.Path, Token: viol.Token, Message: viol.Message}) case celIDs[viol.Rule]: crossField = append(crossField, viol.Rule) diff --git a/sdk/go/helpers/licenseterm_test.go b/sdk/go/helpers/licenseterm_test.go index fde987d7..f14f32da 100644 --- a/sdk/go/helpers/licenseterm_test.go +++ b/sdk/go/helpers/licenseterm_test.go @@ -109,3 +109,64 @@ func TestValidateResourceEntry_ReportsBothTiersWithEntryPaths(t *testing.T) { t.Errorf("want both the wire-tier path violation and the ingest-tier term violation, got %+v", verdict.Violations) } } + +// The disjointness check is the one ingest-tier rule that does not read its input +// as already canonical, so the property worth pinning here is that folding first +// changes nothing: the Exchange normalises in place before it validates, a +// publisher's pre-check does not, and both must reach the same verdict. The value +// table for the rule lives in the shared corpus. +func TestValidateLicenseTerm_CanonicalDisjointIsIndifferentToFoldingFirst(t *testing.T) { + raw := func() *rampv1.LicenseTerm { + return &rampv1.LicenseTerm{Restrictions: []*rampv1.Restriction{{ + Kind: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, + Permitted: []string{"scrape"}, + Prohibited: []string{"crawl"}, + }}} + } + before := raw() + _, errBefore := helpers.ValidateLicenseTerm(before) + normalized := raw() + helpers.NormalizeLicenseTerm(normalized) + _, errAfter := helpers.ValidateLicenseTerm(normalized) + + var rv *helpers.RuleViolation + if !errors.As(errBefore, &rv) { + t.Fatalf("unfolded term: error is %T, want *helpers.RuleViolation", errBefore) + } + if rv.Rule != helpers.RuleRestrictionCanonicalDisjoint || rv.Token != "crawl" { + t.Errorf("violation = %+v", *rv) + } + if errAfter == nil { + t.Fatal("folded term: expected the same violation, got none") + } + if rv.Rule != helpers.RuleRestrictionCanonicalDisjoint { + t.Errorf("rule = %q", rv.Rule) + } + if errBefore.Error() == errAfter.Error() { + return + } + t.Errorf("the verdict depends on whether the caller folded first:\n raw: %s\n folded: %s", + errBefore.Error(), errAfter.Error()) +} + +// Folding rewrites restriction TOKENS and nothing else, so the sibling rule that +// counts restrictions per kind cannot be created or destroyed by it. That is +// assumed by the split between the tiers; assert it rather than believe it. +func TestNormalizeLicenseTerm_LeavesRestrictionKindAlone(t *testing.T) { + term := &rampv1.LicenseTerm{Restrictions: []*rampv1.Restriction{ + {Kind: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Permitted: []string{"SCRAPE"}}, + {Kind: rampv1.RestrictionKind_RESTRICTION_KIND_GEOGRAPHY, Permitted: []string{"us"}}, + {Kind: rampv1.RestrictionKind_RESTRICTION_KIND_USER_TYPE, Prohibited: []string{"Personal"}}, + {Kind: rampv1.RestrictionKind_RESTRICTION_KIND_OTHER, Permitted: []string{"Left-Alone"}}, + }} + want := make([]rampv1.RestrictionKind, len(term.Restrictions)) + for i, r := range term.Restrictions { + want[i] = r.GetKind() + } + helpers.NormalizeLicenseTerm(term) + for i, r := range term.Restrictions { + if r.GetKind() != want[i] { + t.Errorf("restrictions[%d].kind = %v, want %v", i, r.GetKind(), want[i]) + } + } +} diff --git a/sdk/go/helpers/testdata/licenseterm-vectors.json b/sdk/go/helpers/testdata/licenseterm-vectors.json index 8edb29a2..27b67e6e 100644 --- a/sdk/go/helpers/testdata/licenseterm-vectors.json +++ b/sdk/go/helpers/testdata/licenseterm-vectors.json @@ -285,7 +285,14 @@ "cross_field_rules": [ "restriction.permitted_prohibited_disjoint" ], - "term_rules": [], + "term_rules": [ + { + "rule": "restriction.canonical_disjoint", + "path": "terms[0].restrictions[0].permitted[0]", + "token": "ai-train", + "message": "restriction token \"ai-train\" is both permitted and prohibited after canonicalisation" + } + ], "warnings": [] }, { @@ -943,6 +950,45 @@ ], "warnings": [] }, + { + "name": "term_reject_restriction_canonical_disjoint", + "entry": { + "domain": "publisher.example", + "path": "/premium/article-42.html", + "terms": [ + { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "scrape" + ], + "prohibited": [ + "crawl" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + } + ] + }, + "ok": false, + "structural": false, + "cross_field_rules": [], + "term_rules": [ + { + "rule": "restriction.canonical_disjoint", + "path": "terms[0].restrictions[0].permitted[0]", + "token": "crawl", + "message": "restriction token \"crawl\" is both permitted and prohibited after canonicalisation" + } + ], + "warnings": [] + }, { "name": "no_terms_accepted", "entry": { @@ -2262,6 +2308,581 @@ "term": {}, "violation": null, "warnings": [] + }, + { + "name": "restriction_two_aliases_of_one_token_collide_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "adapt" + ], + "prohibited": [ + "derivative" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "modify", + "message": "restriction token \"modify\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_function_case_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "CRAWL" + ], + "prohibited": [ + "crawl" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "crawl", + "message": "restriction token \"crawl\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_geography_case_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_GEOGRAPHY", + "permitted": [ + "us" + ], + "prohibited": [ + "US" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "US", + "message": "restriction token \"US\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_other_axis_exact_duplicate_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_OTHER", + "permitted": [ + "custom-use" + ], + "prohibited": [ + "custom-use" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "custom-use", + "message": "restriction token \"custom-use\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_padded_token_collides_after_trim_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + " crawl " + ], + "prohibited": [ + "crawl" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "crawl", + "message": "restriction token \"crawl\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_disjoint_after_fold_accepted", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "scrape" + ], + "prohibited": [ + "ai-train" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": null, + "warnings": [ + { + "rule": "restriction.token.registered", + "path": "restrictions[0].permitted[0]", + "token": "scrape", + "message": "unregistered RESTRICTION_KIND_FUNCTION restriction token \"scrape\" (term accepted)" + } + ] + }, + { + "name": "restriction_namespaced_tokens_disjoint_accepted", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_OTHER", + "permitted": [ + "acme:read" + ], + "prohibited": [ + "acme:write" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": null, + "warnings": [] + }, + { + "name": "restriction_second_restriction_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "ai-train" + ], + "prohibited": [ + "search" + ] + }, + { + "kind": "RESTRICTION_KIND_USER_TYPE", + "permitted": [ + "personal" + ], + "prohibited": [ + "individual" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[1].permitted[0]", + "token": "individual", + "message": "restriction token \"individual\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_collision_reports_first_permitted_site", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "tdm", + "scrape" + ], + "prohibited": [ + "crawl", + "text-and-data-mining" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "text-and-data-mining", + "message": "restriction token \"text-and-data-mining\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "quota_metric_checked_before_restriction_collision", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "quotas": [ + { + "limit": "10", + "metric": "frobnications", + "window": "QUOTA_WINDOW_DAILY" + } + ], + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "scrape" + ], + "prohibited": [ + "crawl" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "quota.metric.registered", + "path": "quotas[0].metric", + "token": "frobnications", + "message": "quota metric \"frobnications\" is not a registered quota token" + }, + "warnings": [] + }, + { + "name": "restriction_alias_function_adapt_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "adapt" + ], + "prohibited": [ + "modify" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "modify", + "message": "restriction token \"modify\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_alias_function_copy_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "copy" + ], + "prohibited": [ + "reproduce" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "reproduce", + "message": "restriction token \"reproduce\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_alias_function_derivative_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "derivative" + ], + "prohibited": [ + "modify" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "modify", + "message": "restriction token \"modify\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_alias_function_generative_ai_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "generative-ai" + ], + "prohibited": [ + "ai-input" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "ai-input", + "message": "restriction token \"ai-input\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_alias_function_scrape_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "scrape" + ], + "prohibited": [ + "crawl" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "crawl", + "message": "restriction token \"crawl\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_alias_function_tdm_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "tdm" + ], + "prohibited": [ + "text-and-data-mining" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "text-and-data-mining", + "message": "restriction token \"text-and-data-mining\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_alias_function_train_ai_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "train-ai" + ], + "prohibited": [ + "ai-train" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "ai-train", + "message": "restriction token \"ai-train\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_alias_user_type_business_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_USER_TYPE", + "permitted": [ + "business" + ], + "prohibited": [ + "commercial_entity" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "commercial_entity", + "message": "restriction token \"commercial_entity\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_alias_user_type_enterprise_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_USER_TYPE", + "permitted": [ + "enterprise" + ], + "prohibited": [ + "commercial_entity" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "commercial_entity", + "message": "restriction token \"commercial_entity\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] + }, + { + "name": "restriction_alias_user_type_personal_collides_rejected", + "term": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_USER_TYPE", + "permitted": [ + "personal" + ], + "prohibited": [ + "individual" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + }, + "violation": { + "rule": "restriction.canonical_disjoint", + "path": "restrictions[0].permitted[0]", + "token": "individual", + "message": "restriction token \"individual\" is both permitted and prohibited after canonicalisation" + }, + "warnings": [] } ] } From 46dbea72b9ef847024610a7c6c0a93d80ba93080 Mon Sep 17 00:00:00 2001 From: legendko Date: Wed, 2 Sep 2026 11:07:17 +0200 Subject: [PATCH 2/9] feat(sdk): mirror the canonical-disjointness reject in TypeScript and Python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Go oracle gained the check; the two ports replay its corpus, so they carry it in the same position, with the same scan order and the same message bytes. Both already dispatch through their own canonicalRestrictionToken, so neither grows a second fold. The rule id joins the two hand-listed sets that decide which findings the entry corpus records as term rules rather than counting as structural. Both are edited here so the fix stands on its own; deriving them from the corpus is a separate change, made once every vector is passing. Python re-exports the constant from the package aggregator: the API-surface gate holds TypeScript-present-Python-absent at a hard zero, and the constant is public in both. The symbol map and the generated matrix take one row each — 131 symbols at parity, with the documented-divergence and exclusion counts unmoved, because this adds a face all three languages carry. --- docs/sdk-parity-matrix.md | 3 +- sdk/parity/symbol-map.json | 5 ++ sdk/python/ramp_sdk/__init__.py | 2 + sdk/python/ramp_sdk/licenseterm.py | 58 +++++++++++++++++-- sdk/python/tests/test_licenseterm_parity.py | 7 ++- sdk/ts/src/licenseterm.ts | 62 ++++++++++++++++++--- sdk/ts/tests/licenseterm.parity.test.ts | 7 ++- 7 files changed, 128 insertions(+), 16 deletions(-) diff --git a/docs/sdk-parity-matrix.md b/docs/sdk-parity-matrix.md index 4a6decd1..e80e66b8 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:** 130 symbols at cross-language parity · 16 documented divergences · 179 Go-idiomatic exclusions · 34 conformance corpora, each tri-replayed. +**At a glance:** 131 symbols at cross-language parity · 16 documented divergences · 179 Go-idiomatic exclusions · 34 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). @@ -84,6 +84,7 @@ Legend: a name = the public face in that language · `—` = intentionally none | `RuleObligationOtherRequiresDetail` | `RULE_OBLIGATION_OTHER_REQUIRES_DETAIL` | `RULE_OBLIGATION_OTHER_REQUIRES_DETAIL` | | `RulePricingUnitRegistered` | `RULE_PRICING_UNIT_REGISTERED` | `RULE_PRICING_UNIT_REGISTERED` | | `RuleQuotaMetricRegistered` | `RULE_QUOTA_METRIC_REGISTERED` | `RULE_QUOTA_METRIC_REGISTERED` | +| `RuleRestrictionCanonicalDisjoint` | `RULE_RESTRICTION_CANONICAL_DISJOINT` | `RULE_RESTRICTION_CANONICAL_DISJOINT` | | `RuleRestrictionTokenRegistered` | `RULE_RESTRICTION_TOKEN_REGISTERED` | `RULE_RESTRICTION_TOKEN_REGISTERED` | | `RuleViolation` | `RuleViolation` | `RuleViolation` | | `RuleWarning` | `RuleWarning` | `RuleWarning` | diff --git a/sdk/parity/symbol-map.json b/sdk/parity/symbol-map.json index 433edab2..dbdf271f 100644 --- a/sdk/parity/symbol-map.json +++ b/sdk/parity/symbol-map.json @@ -645,6 +645,11 @@ "python": "RULE_QUOTA_METRIC_REGISTERED", "ts": "RULE_QUOTA_METRIC_REGISTERED" }, + "helpers.RuleRestrictionCanonicalDisjoint": { + "allowlist_reason": null, + "python": "RULE_RESTRICTION_CANONICAL_DISJOINT", + "ts": "RULE_RESTRICTION_CANONICAL_DISJOINT" + }, "helpers.RuleRestrictionTokenRegistered": { "allowlist_reason": null, "python": "RULE_RESTRICTION_TOKEN_REGISTERED", diff --git a/sdk/python/ramp_sdk/__init__.py b/sdk/python/ramp_sdk/__init__.py index c4cef78a..418ae46c 100644 --- a/sdk/python/ramp_sdk/__init__.py +++ b/sdk/python/ramp_sdk/__init__.py @@ -95,6 +95,7 @@ RULE_OBLIGATION_OTHER_REQUIRES_DETAIL, RULE_PRICING_UNIT_REGISTERED, RULE_QUOTA_METRIC_REGISTERED, + RULE_RESTRICTION_CANONICAL_DISJOINT, RULE_RESTRICTION_TOKEN_REGISTERED, EntryVerdict, RuleViolation, @@ -204,6 +205,7 @@ "RULE_OBLIGATION_OTHER_REQUIRES_DETAIL", "RULE_PRICING_UNIT_REGISTERED", "RULE_QUOTA_METRIC_REGISTERED", + "RULE_RESTRICTION_CANONICAL_DISJOINT", "RULE_RESTRICTION_TOKEN_REGISTERED", "RegistrationDataVerdict", "RegistrationSchema", diff --git a/sdk/python/ramp_sdk/licenseterm.py b/sdk/python/ramp_sdk/licenseterm.py index cf2a9b7c..dda2f9f0 100644 --- a/sdk/python/ramp_sdk/licenseterm.py +++ b/sdk/python/ramp_sdk/licenseterm.py @@ -37,6 +37,12 @@ #: Rejects a bare Quota.metric that is not a registered quota token. RULE_QUOTA_METRIC_REGISTERED = "quota.metric.registered" #: Warns about a bare restriction token not registered on its axis; the term is accepted. +RULE_RESTRICTION_CANONICAL_DISJOINT = "restriction.canonical_disjoint" +"""Rejects a restriction whose permitted and prohibited lists name the same token once +both are canonicalised. The wire tier's rule compares the tokens AS WRITTEN, so two +accepted spellings of one token — an alias beside its registered form, or two spellings +differing only in ASCII case — pass it and collide only after the fold.""" + RULE_RESTRICTION_TOKEN_REGISTERED = "restriction.token.registered" #: Warns about an OBLIGATION_KIND_OTHER obligation carrying no detail. RULE_OBLIGATION_OTHER_REQUIRES_DETAIL = "obligation.other.requires_detail" @@ -232,14 +238,47 @@ def _restriction_token_warning(kind: str, tok: str, path: str) -> RuleWarning | ) +def _canonical_disjoint_violation(i: int, restriction: dict[str, Any]) -> RuleViolation | None: + """Return the violation for the first permitted token of ``restriction`` whose canonical + form is also the canonical form of one of its prohibited tokens, or ``None``. + + It canonicalises what it compares rather than trusting the term to have been + normalised, and it runs on every axis — where the fold is a no-op the check is plain + equality, which is what a server that never asked for the wire tier needs. The finding + names the canonical token, not the spellings that produced it, so the message does not + depend on whether the caller folded first. + """ + permitted = _as_list(restriction.get("permitted")) + prohibited = _as_list(restriction.get("prohibited")) + if not permitted or not prohibited: + return None + kind = _str(restriction.get("kind")) + banned = {canonical_restriction_token(kind, _str(tok)) for tok in prohibited} + for j, tok in enumerate(permitted): + canon = canonical_restriction_token(kind, _str(tok)) + if canon not in banned: + continue + return RuleViolation( + rule=RULE_RESTRICTION_CANONICAL_DISJOINT, + path=f"restrictions[{i}].permitted[{j}]", + token=canon, + message=f'restriction token "{canon}" is both permitted and prohibited after canonicalisation', + ) + return None + + def validate_license_term(term: dict[str, Any]) -> TermVerdict: - """Run the ingest-tier checks over one already-canonical term. + """Run the ingest-tier checks over one term, in a fixed order. + + A bare ``pricing.unit`` that is not registered, then the first offending + ``quotas[].metric``, then the first restriction whose permitted and prohibited + lists name one token once canonicalised. An accepted term carries one warning + per unregistered bare restriction token — restriction order, permitted before + prohibited — then one per ``OBLIGATION_KIND_OTHER`` obligation without detail. - A bare ``pricing.unit`` or ``quotas[].metric`` that is not registered is a - violation (pricing first, the first offending quota wins); an accepted term - carries one warning per unregistered bare restriction token — restriction - order, permitted before prohibited — then one per ``OBLIGATION_KIND_OTHER`` - obligation without detail. The wire tier is not re-run here. + Every check but the disjointness one reads the term as already canonical. The + wire tier is not re-run here; disjointness is the one property both tiers assert, + over different values, so a term the boundary clears can still fail here. """ pricing = _as_obj(term.get("pricing")) or {} unit = _str(pricing.get("unit")) @@ -263,6 +302,13 @@ def validate_license_term(term: dict[str, Any]) -> TermVerdict: message=f'quota metric "{metric}" is not a registered quota token', ) ) + for i, r in enumerate(_as_list(term.get("restrictions"))): + restriction = _as_obj(r) + if restriction is None: + continue + violation = _canonical_disjoint_violation(i, restriction) + if violation is not None: + return TermVerdict(violation=violation) warnings: list[RuleWarning] = [] for i, r in enumerate(_as_list(term.get("restrictions"))): restriction = _as_obj(r) diff --git a/sdk/python/tests/test_licenseterm_parity.py b/sdk/python/tests/test_licenseterm_parity.py index 641ad837..4f6ee7ee 100644 --- a/sdk/python/tests/test_licenseterm_parity.py +++ b/sdk/python/tests/test_licenseterm_parity.py @@ -24,6 +24,7 @@ from ramp_sdk.licenseterm import ( RULE_PRICING_UNIT_REGISTERED, RULE_QUOTA_METRIC_REGISTERED, + RULE_RESTRICTION_CANONICAL_DISJOINT, canonical_restriction_token, known_restriction_token, normalize_license_term, @@ -37,7 +38,11 @@ _KNOWN = _VECTORS["known"] _VALIDATE = _VECTORS["validate"] _ENTRY = _VECTORS["entry"] -_TERM_RULES = {RULE_PRICING_UNIT_REGISTERED, RULE_QUOTA_METRIC_REGISTERED} +_TERM_RULES = { + RULE_PRICING_UNIT_REGISTERED, + RULE_QUOTA_METRIC_REGISTERED, + RULE_RESTRICTION_CANONICAL_DISJOINT, +} # The registered cross-field rule ids, read from the generated cross-field corpus — # corpusgen emits one mutant per message-level CEL rule, so its ``rules`` are the diff --git a/sdk/ts/src/licenseterm.ts b/sdk/ts/src/licenseterm.ts index 7990f6ac..1b98d10e 100644 --- a/sdk/ts/src/licenseterm.ts +++ b/sdk/ts/src/licenseterm.ts @@ -34,6 +34,14 @@ import { crossFieldRuleIds } from "./crossfield.ts"; export const RULE_PRICING_UNIT_REGISTERED = "pricing.unit.registered"; /** Rejects a bare Quota.metric that is not a registered quota token. */ export const RULE_QUOTA_METRIC_REGISTERED = "quota.metric.registered"; +/** + * Rejects a restriction whose permitted and prohibited lists name the same token + * once both are canonicalised. The wire tier's rule compares the tokens AS + * WRITTEN, so two accepted spellings of one token — an alias beside its + * registered form, or two spellings differing only in ASCII case — pass it and + * collide only after the fold. + */ +export const RULE_RESTRICTION_CANONICAL_DISJOINT = "restriction.canonical_disjoint"; /** Warns about a bare restriction token not registered on its axis; the term is accepted. */ export const RULE_RESTRICTION_TOKEN_REGISTERED = "restriction.token.registered"; /** Warns about an OBLIGATION_KIND_OTHER obligation carrying no detail. */ @@ -235,12 +243,46 @@ function restrictionTokenWarning(kind: string, tok: string, path: string): RuleW } /** - * validateLicenseTerm runs the ingest-tier checks over one already-canonical - * term: a bare Pricing.unit or Quota.metric that is not registered is a - * violation (pricing first, first offending quota wins), and an accepted term - * carries one warning per unregistered bare restriction token — restriction - * order, permitted before prohibited — then one per OBLIGATION_KIND_OTHER - * obligation without detail. The wire tier is not re-run here. + * canonicalDisjointViolation returns the violation for the first permitted token + * of r whose canonical form is also the canonical form of one of its prohibited + * tokens, or undefined when the two lists name no token in common. It + * canonicalises what it compares rather than trusting the term to have been + * normalised, and it runs on every axis — where the fold is a no-op the check is + * plain equality, which is what a server that never asked for the wire tier + * needs. The finding names the canonical token, not the spellings that produced + * it, so the message does not depend on whether the caller folded first. + */ +function canonicalDisjointViolation(i: number, r: Obj): RuleViolation | undefined { + const permitted = asArr(r["permitted"]); + const prohibited = asArr(r["prohibited"]); + if (permitted.length === 0 || prohibited.length === 0) return undefined; + const kind = str(r["kind"]); + const banned = new Set(prohibited.map((t) => canonicalRestrictionToken(kind, str(t)))); + for (let j = 0; j < permitted.length; j++) { + const canon = canonicalRestrictionToken(kind, str(permitted[j])); + if (!banned.has(canon)) continue; + return { + rule: RULE_RESTRICTION_CANONICAL_DISJOINT, + path: `restrictions[${i}].permitted[${j}]`, + token: canon, + message: `restriction token "${canon}" is both permitted and prohibited after canonicalisation`, + }; + } + return undefined; +} + +/** + * validateLicenseTerm runs the ingest-tier checks over one term, in a fixed + * order: a bare Pricing.unit that is not registered, then the first offending + * quota metric, then the first restriction whose permitted and prohibited lists + * name one token once canonicalised. An accepted term carries one warning per + * unregistered bare restriction token — restriction order, permitted before + * prohibited — then one per OBLIGATION_KIND_OTHER obligation without detail. + * + * Every check but the disjointness one reads the term as already canonical. The + * wire tier is not re-run here; disjointness is the one property both tiers + * assert, over different values, so a term the boundary clears can still fail + * here. */ export function validateLicenseTerm(term: Obj): TermVerdict { const unit = str(asObj(term["pricing"])?.["unit"]); @@ -270,8 +312,14 @@ export function validateLicenseTerm(term: Obj): TermVerdict { }; } } - const warnings: RuleWarning[] = []; const restrictions = asArr(term["restrictions"]); + for (let i = 0; i < restrictions.length; i++) { + const r = asObj(restrictions[i]); + if (!r) continue; + const violation = canonicalDisjointViolation(i, r); + if (violation) return { violation, warnings: [] }; + } + const warnings: RuleWarning[] = []; for (let i = 0; i < restrictions.length; i++) { const r = asObj(restrictions[i]); if (!r) continue; diff --git a/sdk/ts/tests/licenseterm.parity.test.ts b/sdk/ts/tests/licenseterm.parity.test.ts index e9221170..2651689e 100644 --- a/sdk/ts/tests/licenseterm.parity.test.ts +++ b/sdk/ts/tests/licenseterm.parity.test.ts @@ -17,6 +17,7 @@ import vectorsFile from "../../go/helpers/testdata/licenseterm-vectors.json"; import { RULE_PRICING_UNIT_REGISTERED, RULE_QUOTA_METRIC_REGISTERED, + RULE_RESTRICTION_CANONICAL_DISJOINT, canonicalRestrictionToken, knownRestrictionToken, normalizeLicenseTerm, @@ -43,7 +44,11 @@ type Vectors = { }; const vectors = vectorsFile as Vectors; -const TERM_RULES = new Set([RULE_PRICING_UNIT_REGISTERED, RULE_QUOTA_METRIC_REGISTERED]); +const TERM_RULES = new Set([ + RULE_PRICING_UNIT_REGISTERED, + RULE_QUOTA_METRIC_REGISTERED, + RULE_RESTRICTION_CANONICAL_DISJOINT, +]); // The registered cross-field rule ids, read from the generated cross-field corpus — // corpusgen emits one mutant per message-level CEL rule, so its `rules` are the From dd3da33a5a327bab14d7c8ff631957f762bac5ea Mon Sep 17 00:00:00 2001 From: legendko Date: Wed, 2 Sep 2026 11:10:01 +0200 Subject: [PATCH 3/9] fix(proto): say that disjointness is checked twice, and guard the statement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract declared one disjointness rule and described one, so an implementor reading it built an Exchange that stores the term the SDK refuses. The rule is declared at the wire tier and compares the tokens as written; several tokens have more than one accepted spelling, so two spellings of one token clear it and become one token when the ingest tier folds them. That second evaluation existed nowhere in the prose. Both comments now say it. The rule's own comment says what it compares and why that is not the whole property, and names the ingest-tier id. The service comment, which is where a publisher reads what an Exchange will run, carries the reject in its ingest-tier list and states that a deployment which does not mount the wire tier still gets the second check. A behaviour stated nowhere in the contract is inferred from whatever implementation the reader has at hand, which makes an unstated verdict a defect in the contract rather than a gap in the docs — the reason this is a contract change and not a documentation one. The new guard holds the two together, the way the registration-schema and domain guards hold theirs. That the rule fires is read out of the committed vectors, as data, because this package is the tier below the SDKs and imports none of them. That the contract states it is read from the .proto SOURCE, because a comment does not survive into the descriptor and the comment is what an implementor reads. It matches clauses only these sentences can produce: the bare words would have passed on text that never states the rule, since "canonicalised" occurs in unrelated paragraphs and "disjoint" occurs in the wire rule's own id. A second test pins that the two rules keep two names and that the wire rule still exists, since the ingest-tier one reads different values and never replaced it. Only descriptor.binpb moves in gen/: the Go and TypeScript emitters carry no proto comments, and the descriptor is what the reference pages render from. --- .../licenseterm_canonical_disjoint_test.go | 185 ++++++++++++++++++ gen/descriptor.binpb | Bin 610158 -> 611262 bytes proto/ramp/v1/ramp.proto | 25 ++- 3 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 conformance/licenseterm_canonical_disjoint_test.go diff --git a/conformance/licenseterm_canonical_disjoint_test.go b/conformance/licenseterm_canonical_disjoint_test.go new file mode 100644 index 00000000..0805b8e0 --- /dev/null +++ b/conformance/licenseterm_canonical_disjoint_test.go @@ -0,0 +1,185 @@ +package conformance + +// Drift guard: the post-fold disjointness rule the SDKs enforce IS a rule the +// contract publishes. +// +// Disjointness is asserted twice, at two tiers, over two different readings of +// the same field. The wire tier's half is a protovalidate rule and needs no +// guard — it is IN the descriptor. The ingest tier's half is SDK code, and it +// cannot be a CEL rule for the reason the neighbouring ingest-tier checks give: +// the fold resolves aliases out of a generated vocabulary, and a CEL expression +// that re-listed that vocabulary would drift from it. +// +// So the rule exists twice — as SDK code, which is what the reference +// implementations run, and as prose in ramp.proto, which is what a third-party +// implementor reads. An implementor who read only the contract would otherwise +// build an Exchange that stores the term this rule exists to refuse, and every +// suite in every language would stay green. Nothing made the two agree; this +// file does. +// +// The halves work differently, as in regschema_rule_test.go. That the rule +// FIRES is read out of the committed vectors — conformance imports nothing from +// sdk/, so the ids arrive as data. That the contract STATES it is checked by +// reading the .proto SOURCE, because a comment does not survive into the +// descriptor and the comment is the thing an implementor actually reads. +// +// The phrases below are clauses only this rule's sentences can produce. A guard +// that matched a bare word would pass on text that never states the rule: the +// word "canonicalised" already occurs in several unrelated paragraphs of this +// file, and "disjoint" occurs in the wire rule's own id. + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "buf.build/go/protovalidate" + "google.golang.org/protobuf/reflect/protoreflect" +) + +const ( + rampProtoSource = "../proto/ramp/v1/ramp.proto" + + // The two ids. They are deliberately different strings: one name per rule, which + // licenseterm_rule_ids_test.go enforces across the whole ingest-tier id set. + canonicalDisjointRuleID = "restriction.canonical_disjoint" + wireDisjointRuleID = "restriction.permitted_prohibited_disjoint" +) + +// canonicalDisjointFires reports whether the committed license-term vectors record +// the ingest-tier rule at all. Read as data: this package is the guard tier BELOW +// the SDKs and imports none of them. +func canonicalDisjointFires(t *testing.T) bool { + t.Helper() + b, err := os.ReadFile(licenseTermVectors) + if err != nil { + t.Fatalf("read %s: %v", licenseTermVectors, err) + } + var doc struct { + Validate []struct { + Violation *struct { + Rule string `json:"rule"` + } `json:"violation"` + } `json:"validate"` + } + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatalf("decode %s: %v", licenseTermVectors, err) + } + for _, v := range doc.Validate { + if v.Violation != nil && v.Violation.Rule == canonicalDisjointRuleID { + return true + } + } + return false +} + +// messageBody returns the source text between a declaration's opening brace and the +// first closing brace in column 1 — the whole body of a top-level message or service, +// including the comments inside it. +func messageBody(t *testing.T, src, decl string) string { + t.Helper() + start := strings.Index(src, decl) + if start < 0 { + t.Fatalf("%s: %q not found — the guard is reading the wrong file or the declaration was renamed", rampProtoSource, decl) + } + rest := src[start:] + end := strings.Index(rest, "\n}\n") + if end < 0 { + t.Fatalf("%s: %q is never closed in column 1", rampProtoSource, decl) + } + return rest[:end] +} + +// docComment returns the run of // lines immediately above a declaration. +func docComment(t *testing.T, src, decl string) string { + t.Helper() + at := strings.Index(src, decl) + if at < 0 { + t.Fatalf("%s: %q not found", rampProtoSource, decl) + } + lines := strings.Split(src[:at], "\n") + // The declaration starts a line, so the split ends with the empty string before it. + // Dropping it is what makes the walk below start on the last comment line rather + // than stopping instantly on a non-comment. + if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines = lines[:len(lines)-1] + } + var out []string + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(line, "//") { + break + } + out = append([]string{line}, out...) + } + return strings.Join(out, "\n") +} + +func TestCanonicalDisjointRuleIsStatedByTheContract(t *testing.T) { + if !canonicalDisjointFires(t) { + t.Fatalf("no vector records %q — either the rule is gone or the corpus stopped covering it, "+ + "and this guard would be holding the contract to a rule nothing runs", canonicalDisjointRuleID) + } + + b, err := os.ReadFile(rampProtoSource) + if err != nil { + t.Fatalf("read %s: %v", rampProtoSource, err) + } + src := string(b) + + // Where the rule is declared, the contract must say that the declared rule reads + // the tokens as written and that the property is asserted again after the fold. + // Both halves are load-bearing: an implementor who takes the CEL rule for the + // whole rule builds the gap this exists to close. + restriction := messageBody(t, src, "message Restriction {") + for _, want := range []string{ + canonicalDisjointRuleID, + "compares the tokens AS WRITTEN", + "two spellings of ONE token pass this rule", + } { + if !strings.Contains(restriction, want) { + t.Errorf("message Restriction does not state %q — the SDK refuses a term the contract does not say it refuses", want) + } + } + + // And the service comment, which is where a publisher reads what the Exchange + // will run, must carry the rule in its ingest-tier list. + catalog := docComment(t, src, "service CatalogService {") + for _, want := range []string{ + canonicalDisjointRuleID, + "BOTH tiers assert", + } { + if !strings.Contains(catalog, want) { + t.Errorf("the CatalogService comment does not state %q — its two-tier description is incomplete", want) + } + } +} + +// The two rules must keep two names. The ingest-tier id set is held to that as a +// whole elsewhere; this pins the specific pair, because these two are the ones a +// reader is most likely to collapse into one. +func TestCanonicalDisjointDoesNotDisplaceTheWireRule(t *testing.T) { + if canonicalDisjointRuleID == wireDisjointRuleID { + t.Fatal("the ingest-tier and wire-tier ids are the same string — one name for two rules") + } + var found bool + EachMessage(func(md protoreflect.MessageDescriptor) { + if string(md.Name()) != "Restriction" { + return + } + mr, err := protovalidate.ResolveMessageRules(md) + if err != nil || mr == nil { + return + } + for _, r := range mr.GetCel() { + if r.GetId() == wireDisjointRuleID { + found = true + } + } + }) + if !found { + t.Errorf("Restriction no longer declares %q — the ingest-tier rule reads the canonicalised tokens "+ + "and was never a replacement for the boundary check over the tokens as received", wireDisjointRuleID) + } +} diff --git a/gen/descriptor.binpb b/gen/descriptor.binpb index 73682f3b6beeed893ca8f9e97a787dca76eab047..a7d7b7dee27a03113fa4119bc5a4e661148bccf1 100644 GIT binary patch delta 34673 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 T{-su9?1|LJfMs2~x$FHumhe1p delta 33619 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 dbN~63mtT9i?e5F4r{|9$xs6zk% diff --git a/proto/ramp/v1/ramp.proto b/proto/ramp/v1/ramp.proto index c99aa966..749be1ee 100644 --- a/proto/ramp/v1/ramp.proto +++ b/proto/ramp/v1/ramp.proto @@ -1222,6 +1222,17 @@ message License { // USER_TYPE — RAMP user/organization categories message Restriction { // A token cannot be both permitted and prohibited on the same axis. + // + // This rule compares the tokens AS WRITTEN, because it runs at the wire tier, + // over the request exactly as received. Several tokens have more than one + // accepted spelling — a registered alias beside its canonical form, and either + // one in any ASCII case — so two spellings of ONE token pass this rule and + // become the same token when the ingest tier folds them. The ingest tier + // therefore asserts the same property a second time, over the canonicalised + // tokens, under the SDK rule id restriction.canonical_disjoint; see + // CatalogService. Both refuse the term, and a term that fails both is reported + // by both — the second is not a fallback for the first, it reads different + // values. option (buf.validate.message).cel = { id: "restriction.permitted_prohibited_disjoint" message: "a token cannot appear in both permitted and prohibited" @@ -2188,9 +2199,17 @@ enum IngestionSource { // GEOGRAPHY; a non-ASCII byte is never folded) and alias-resolved to their // registered form — the aliases are authored beside the tokens, on the // RestrictionKind values — after which a bare (non-namespaced) Pricing.unit or -// Quota.metric that is not a registered token is rejected, while an -// unregistered restriction token and an OBLIGATION_KIND_OTHER obligation -// without detail are accepted and reported in PushResourcesResponse.warnings. +// Quota.metric that is not a registered token is rejected, as is a restriction +// whose permitted and prohibited lists name one token once folded +// (restriction.canonical_disjoint), while an unregistered restriction token and +// an OBLIGATION_KIND_OTHER obligation without detail are accepted and reported +// in PushResourcesResponse.warnings. +// +// Disjointness is the one property BOTH tiers assert, and deliberately so: the +// wire tier reads the tokens as written and the ingest tier reads what the fold +// produced, so a term the boundary clears can still be refused here. A +// deployment that does not mount the wire tier still gets the second. +// // The SDK ships both tiers as a publisher-side pre-check; the Exchange's own // run of them is the deciding one. // From 2b2cc8ab6cec77e03b77ba71121d6b944b991337 Mon Sep 17 00:00:00 2001 From: legendko Date: Wed, 2 Sep 2026 11:12:45 +0200 Subject: [PATCH 4/9] docs: the disjointness rule is evaluated at both tiers, and the pages say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every surface described a two-tier model in which a token cannot be both permitted and prohibited was settled at the boundary. It is not: the boundary rule compares the tokens as written, and the property has to be asserted again over what the fold produced, because a registered alias and its canonical form are two spellings of one token. The licensing-terms page had not mentioned disjointness at all while describing canonicalisation two sections later, which is the pairing that hides the gap; it now states both checks and what separates them. The reference page carried the wire rule as though it were the whole rule. The ingestion, onboarding and content-ingestion pages describe the publisher pre-check, so they name the refusal a feed written against the registry's alias spellings will actually hit. The request-flows page already explained that the wire tier is something a deployment turns on, which makes it the right place to say that the ingest check is what a mount without it still gets. The design-history entry records the general rule rather than this instance: a rule comparing two token-valued fields is only correct on canonical values, so an axis that gains canonicalisation obliges a re-read of every rule over it — move the rule to the tier that sees canonical values, or move canonicalisation ahead of the tier that carries the rule. It also records why the second half has its own id, why it folds internally, and why neither half suppresses the other, including the concrete ground: the server binding defaults to validation off, so on a mount that never asks for the wire tier the ingest check is the only one a pushed term meets. --- docs/design-history.md | 40 +++++++++++++++++++ proto/CHANGELOG.md | 24 +++++++++++ sdk/go/README.md | 8 +++- .../components/content-ingestion/sources.mdx | 2 +- .../components/exchange/request-flows.mdx | 2 +- .../getting-started/publisher-onboarding.mdx | 2 +- .../content/docs/protocol/jsonl-ingestion.mdx | 3 +- .../content/docs/protocol/licensing-terms.mdx | 4 +- .../src/content/docs/reference/changelog.mdx | 24 +++++++++++ .../src/content/docs/reference/proto-ramp.mdx | 4 +- 10 files changed, 104 insertions(+), 9 deletions(-) diff --git a/docs/design-history.md b/docs/design-history.md index de5d062a..8c1a47ef 100644 --- a/docs/design-history.md +++ b/docs/design-history.md @@ -1130,6 +1130,46 @@ what it does pin, at three different strengths. The two JSON ports walk every me reachable from an entry explicitly for the cross-field rules, because their composed schemas attach per message and a top-level parse would never reach `terms[1].pricing`. +The consequence of that order took a while to surface, and it is the reason the tier +boundary is not simply a matter of where a check is cheapest to run. A rule evaluated +before the fold is a rule about SPELLINGS. `restriction.permitted_prohibited_disjoint` +is `this.permitted.all(p, !(p in this.prohibited))` — a string comparison standing in +for a statement about tokens — and the vocabulary it reads has ten aliases and folds +ASCII case on every axis. So a term naming `scrape` under permitted and `crawl` under +prohibited passed the rule, because as written the two lists shared nothing, and the +fold then collapsed both into one token sitting in both lists. The wire tier had +cleared the very violation the ingest tier went on to create. + +What made it expensive was where it landed. The stored term rides on offers and an +Exchange validates its own responses, so the refusal arrived at a different party, on +a different RPC, an unbounded time later: discovery of that resource failed with an +internal error, while the push that caused it had been answered as accepted. A check +that runs on the wrong values does not merely miss things; it can certify the thing it +was written to prevent. + +The property is now asserted at both tiers, and that is the general rule this records: +a rule that compares two token-valued fields is only correct on canonical values, so +when an axis gains canonicalisation, every rule that reads it must be re-examined — +either the rule moves to the tier that sees canonical values, or canonicalisation moves +ahead of the tier that carries the rule. Here the rule moved, because the fold resolves +aliases out of a generated vocabulary and a CEL expression that re-listed that +vocabulary would drift from it, which is the same reason membership is not a descriptor +rule. + +Three details of the second half are deliberate. It has its own id +(`restriction.canonical_disjoint`) rather than reusing the first's, because one name for +two rules is what the ingest-tier id guard exists to prevent, and these two really are +different rules: they read different values and can disagree. It canonicalises what it +compares instead of trusting its caller to have folded first — both callers do fold, but +a rule written to close an ordering gap that opened its own would be the same mistake +one tier down, and the fold is a fixed point, so the cost is a pass over tokens already +canonical. And neither rule suppresses the other: a term that fails both is reported by +both, matching the tier the contract already describes as collecting every violation +rather than stopping at the first. Suppression was considered and rejected on a concrete +ground — the server binding defaults to validation off, so on a mount that never asks +for the wire tier the ingest check is the only one a pushed term meets, and a rule that +stayed silent because "the boundary already caught it" would catch nothing there. + ## A catalog client is its own constructor The usage-report decision recorded above settled a rule that generalises: a client's diff --git a/proto/CHANGELOG.md b/proto/CHANGELOG.md index db3b86c8..1bbc1f15 100644 --- a/proto/CHANGELOG.md +++ b/proto/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +**A term is now checked for permitted/prohibited disjointness a second time, over +the canonicalised tokens (`restriction.canonical_disjoint`; SDK behaviour change +plus a comment clarification, no wire change).** `restriction.permitted_prohibited_disjoint` +runs at the wire tier, over the request exactly as received, so it compares token +SPELLINGS. Ten restriction tokens have more than one accepted spelling — `scrape` +is a registered alias of `crawl`, `adapt` and `derivative` both mean `modify`, +`personal` means `individual` — and every axis also folds ASCII case. A term naming +one spelling under `permitted` and another under `prohibited` therefore passed the +boundary check and became, once the ingest tier folded it, a stored term with the +same token in both lists. + +Nothing looked at it again, and the failure surfaced elsewhere: the term rides on +offers, an Exchange validates its own responses, and so every discovery request +returning that resource answered with an internal error while the push had looked +clean. The ingest tier now asserts the same property over the canonical values, +under its own rule id, in `ValidateLicenseTerm` and its Python and TypeScript twins. +The boundary rule is unchanged. + +Disjointness is now the one property both tiers assert, deliberately: they read +different values, neither suppresses the other, and a deployment that does not mount +the wire tier still gets the second. The `Restriction` and `CatalogService` comments +say so, and a conformance guard holds the contract's statement to the rule the SDKs +run. + **`Offer.offer_id` is documented as an opaque unique identifier, not a resource key (comment clarification; no wire change).** The comment already said the id is assigned by the Exchange, but an implementation historically derived it from the diff --git a/sdk/go/README.md b/sdk/go/README.md index 243e13fd..c1cddc18 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -48,8 +48,12 @@ vr, err = helpers.VerifyRequestResolved(ctx, req, body, resolver, helpers.Verify **License-term pre-check** — the two tiers an Exchange applies to a pushed entry, runnable by a publisher before signing: the wire rules over the entry as given, then canonicalisation (RFC 8259 trim, ASCII-only fold, alias resolution through the generated -vocabulary) and registry membership over a copy of its terms. Warning messages are the -exact `PushResourcesResponse.warnings` strings; rule ids share the CEL-id namespace: +vocabulary), then registry membership and canonical disjointness over a copy of its terms. +The second of those is why the tier order matters: `permitted: ["scrape"]` with +`prohibited: ["crawl"]` clears the boundary rule, which compares the tokens as written, and +names one token once folded. Warning messages are the exact +`PushResourcesResponse.warnings` strings; rule ids share the CEL-id namespace +(`pricing.unit.registered`, `restriction.canonical_disjoint`, …): ```go verdict := helpers.ValidateResourceEntry(entry) // never modifies entry diff --git a/website/src/content/docs/components/content-ingestion/sources.mdx b/website/src/content/docs/components/content-ingestion/sources.mdx index aa0d0fd6..cc89d82c 100644 --- a/website/src/content/docs/components/content-ingestion/sources.mdx +++ b/website/src/content/docs/components/content-ingestion/sources.mdx @@ -298,7 +298,7 @@ async with CatalogClient(ClientConfig(base_url=catalog_endpoint, signer=contribu }) ``` -`ver` is stamped when the caller leaves it empty; no idempotency key is minted, because the catalog messages carry none — a push is an upsert. A request whose `exchange` is empty or not a bare domain — the shape the wire rule admits, which is narrower than a usable host — is refused before it is signed. The pre-check is advice about what the Exchange will say; the Exchange re-runs both tiers on every push and its verdict decides. The JSONL feed, a sitemap, an RSL file, a crawl or a CMS plugin are inputs that converge on the same `ResourceEntry`; the SDK owns the shape, its validation and the push, never the source. +`ver` is stamped when the caller leaves it empty; no idempotency key is minted, because the catalog messages carry none — a push is an upsert. A request whose `exchange` is empty or not a bare domain — the shape the wire rule admits, which is narrower than a usable host — is refused before it is signed. The pre-check is advice about what the Exchange will say; the Exchange re-runs both tiers on every push and its verdict decides — including the ingest tier's check that a term is still disjoint after its tokens are canonicalised, which is the one refusal a feed written against the registry's alias spellings is most likely to hit. The JSONL feed, a sitemap, an RSL file, a crawl or a CMS plugin are inputs that converge on the same `ResourceEntry`; the SDK owns the shape, its validation and the push, never the source. ### Third-Party Content Intelligence and Verification Vendors (v1.0) diff --git a/website/src/content/docs/components/exchange/request-flows.mdx b/website/src/content/docs/components/exchange/request-flows.mdx index 5c9adf12..b1f1a2b0 100644 --- a/website/src/content/docs/components/exchange/request-flows.mdx +++ b/website/src/content/docs/components/exchange/request-flows.mdx @@ -778,7 +778,7 @@ func (h *ExchangeHandler) ReportUsage( ## Attestation Verification at Catalog Push -Before the handler below runs, the wire tier has already refused a malformed push: the `ResourceEntry` envelope rules (bare-host `domain`, absolute `path`, bounded fields, at most 32 terms, and at most 256 entries in the submission) and the `LicenseTerm` rules (including at most 8 restrictions, and at most 64 quotas or obligations, per term) are protovalidate rules applied at the boundary. That tier is something a deployment turns on: an Exchange built on the Go SDK mounts its handler with `connectserver.WithValidation(connect.ValidationStrict)`, which is what composes protovalidate onto it — the option is not the default, and the reference implementation passes it on every mount. The handler then canonicalises the terms and applies registry membership — the ingest tier — before the attestation checks. Both tiers ship in the SDK as a publisher pre-check, so a conforming feed rarely reaches this flow with a refusable entry. +Before the handler below runs, the wire tier has already refused a malformed push: the `ResourceEntry` envelope rules (bare-host `domain`, absolute `path`, bounded fields, at most 32 terms, and at most 256 entries in the submission) and the `LicenseTerm` rules (including at most 8 restrictions, and at most 64 quotas or obligations, per term) are protovalidate rules applied at the boundary. That tier is something a deployment turns on: an Exchange built on the Go SDK mounts its handler with `connectserver.WithValidation(connect.ValidationStrict)`, which is what composes protovalidate onto it — the option is not the default, and the reference implementation passes it on every mount. The handler then canonicalises the terms and applies registry membership — the ingest tier — before the attestation checks. The ingest tier also re-asserts that no token is both permitted and prohibited (`restriction.canonical_disjoint`), because the boundary rule compares the tokens as written and two spellings of one token become one token only once folded. That is the check a mount without the wire tier still gets, and the one that keeps a self-contradictory term out of the catalog — a stored term that violates the contract would fail validation again on the way out, on every offer that carries it. Both tiers ship in the SDK as a publisher pre-check, so a conforming feed rarely reaches this flow with a refusable entry. When `CatalogService.PushResources` includes attestations, the Exchange performs additional verification before accepting entries into the resource catalog. diff --git a/website/src/content/docs/getting-started/publisher-onboarding.mdx b/website/src/content/docs/getting-started/publisher-onboarding.mdx index 231751f8..58c17485 100644 --- a/website/src/content/docs/getting-started/publisher-onboarding.mdx +++ b/website/src/content/docs/getting-started/publisher-onboarding.mdx @@ -164,7 +164,7 @@ The `catalog_contributors` field authorizes third parties to push attestations a Whatever your source material — a JSONL feed, a sitemap, an RSL file, a crawl, a CMS plugin — it converges on one `ResourceEntry` per resource, and the SDK owns the rest: the shape, the Exchange's own validation run for you to call before you push, and the push itself. 1. Build a `ResourceEntry` per resource: `domain` (your bare host), `path` (an absolute URL path), the metadata you have, and one or more `terms` — every term carries a `pricing` (`model: FREE` is stated, never implied). -2. Pre-check it with `validate_resource_entry` (Python), `validateResourceEntry` (TypeScript) or `helpers.ValidateResourceEntry` (Go). The verdict lists every rule the Exchange would refuse the entry on, with its field path, and the warnings the accepted terms would carry — the exact strings that come back in `PushResourcesResponse.warnings`. Token spellings and aliases (`generative-ai` → `ai-input`, `de` → `DE`) are canonicalised for you. +2. Pre-check it with `validate_resource_entry` (Python), `validateResourceEntry` (TypeScript) or `helpers.ValidateResourceEntry` (Go). The verdict lists every rule the Exchange would refuse the entry on, with its field path, and the warnings the accepted terms would carry — the exact strings that come back in `PushResourcesResponse.warnings`. Token spellings and aliases (`generative-ai` → `ai-input`, `de` → `DE`) are canonicalised for you — which is also why a term must stay disjoint **after** canonicalisation: `permitted: ["scrape"]` with `prohibited: ["crawl"]` names one token on both sides and is refused. 3. Push with the catalog client — `ramp_sdk.client.CatalogClient`, `createCatalogClient`, `connect.NewCatalogClient` — built against the CatalogService address you have configured for that Exchange, and your contributor signing key. The address is configuration, the way the agent client's home Exchange is: nobody but you named it, so the client dials only what you gave it. An Exchange MAY advertise the address as `catalog_endpoint` in its `/.well-known/ramp.json`, and a deployment that reads it from there MUST itself check the binding that field states — same host and port as the manifest, or a subdomain of that host on that port, no userinfo — before dialling it; a manifest naming an unrelated host would otherwise redirect a signed push to a party the signature never covered. Set `exchange` to the Exchange's bare domain, `tenant_id` to your tenant and `caller_id` to your domain; the client stamps `ver`, signs the request with your key and refuses before signing a request whose `exchange` is not a bare domain — the shape the wire rule admits, which is narrower than a usable host. A push is all-or-nothing: an entry that fails a hard rule refuses the whole submission with the offending entry named, and nothing is persisted. Warnings never block. The pre-check is advice about what the Exchange will say; the Exchange re-runs both tiers on every push and its verdict decides. Worked examples in all three languages are in [Source 7: CatalogService API Push](/components/content-ingestion/sources/#source-7-catalogservice-api-push); the feed format is in [JSONL Ingestion Feed](/protocol/jsonl-ingestion/). diff --git a/website/src/content/docs/protocol/jsonl-ingestion.mdx b/website/src/content/docs/protocol/jsonl-ingestion.mdx index 1c686116..a01853ae 100644 --- a/website/src/content/docs/protocol/jsonl-ingestion.mdx +++ b/website/src/content/docs/protocol/jsonl-ingestion.mdx @@ -65,10 +65,11 @@ Rules (enforced by protovalidate at the RPC boundary): - `per_unit` REQUIRES `pricing.unit`; `flat` / `free` carry none. - A `reference_only` term MUST carry `license.uri`; any `license.uri` REQUIRES `license.uri_digest`. - Tokens are canonical registry values or `vendor:namespaced`. Restriction tokens are canonicalised at ingest (whitespace trimmed, ASCII case folded, aliases such as `generative-ai` → `ai-input` resolved) and an unregistered bare token is a **warning**, not a reject; a bare unregistered `pricing.unit` or `quotas[].metric` IS a reject. +- A token cannot be both permitted and prohibited on one axis. This is checked at the boundary over the tokens as written, and again at ingest over the canonicalised tokens (`restriction.canonical_disjoint`) — `permitted: ["scrape"]` with `prohibited: ["crawl"]` names one token twice, and only the second check can see it. ## Pre-validate with the SDK -Both tiers the Exchange applies ship in the SDK, so a feed can be checked before anything is signed — `ramp-ingest` runs them for you, and a program that builds its own entries calls them itself; the catalog client does not. `validate_resource_entry` (Python), `validateResourceEntry` (TypeScript) and `helpers.ValidateResourceEntry` (Go) run the wire rules over the entry as given, then canonicalise a copy of its terms and run registry membership and the coherence lints; the verdict lists every violation with its rule id and field path, and the warnings the accepted terms would carry — the exact strings the Exchange puts in `PushResourcesResponse.warnings`. The normalising faces produce the canonical form the Exchange will store — `Generative-AI` becomes `ai-input`, `de` becomes `DE` — and they differ by language on purpose. `normalize_resource_entry` (Python) and `normalizeResourceEntry` (TypeScript) take a proto-JSON object and **return a new one**, leaving the input untouched. `helpers.NormalizeResourceEntry` (Go) takes a `*rampv1.ResourceEntry` and **rewrites it in place**, returning nothing: the Exchange normalises the entry it is about to persist, and an in-place face is what lets the canonical tokens reach the stored row and the offer projection without a second copy. Each is its language's idiom; the shared conformance corpus pins the output, which is the part that must agree. +Both tiers the Exchange applies ship in the SDK, so a feed can be checked before anything is signed — `ramp-ingest` runs them for you, and a program that builds its own entries calls them itself; the catalog client does not. `validate_resource_entry` (Python), `validateResourceEntry` (TypeScript) and `helpers.ValidateResourceEntry` (Go) run the wire rules over the entry as given, then canonicalise a copy of its terms and run registry membership and the coherence lints; the verdict lists every violation with its rule id and field path — including the ones only the canonicalised form reveals — and the warnings the accepted terms would carry — the exact strings the Exchange puts in `PushResourcesResponse.warnings`. The normalising faces produce the canonical form the Exchange will store — `Generative-AI` becomes `ai-input`, `de` becomes `DE` — and they differ by language on purpose. `normalize_resource_entry` (Python) and `normalizeResourceEntry` (TypeScript) take a proto-JSON object and **return a new one**, leaving the input untouched. `helpers.NormalizeResourceEntry` (Go) takes a `*rampv1.ResourceEntry` and **rewrites it in place**, returning nothing: the Exchange normalises the entry it is about to persist, and an in-place face is what lets the canonical tokens reach the stored row and the offer projection without a second copy. Each is its language's idiom; the shared conformance corpus pins the output, which is the part that must agree. ```python from ramp_sdk import validate_resource_entry, normalize_resource_entry diff --git a/website/src/content/docs/protocol/licensing-terms.mdx b/website/src/content/docs/protocol/licensing-terms.mdx index 862b5bb9..dfb5f44a 100644 --- a/website/src/content/docs/protocol/licensing-terms.mdx +++ b/website/src/content/docs/protocol/licensing-terms.mdx @@ -55,6 +55,8 @@ Each restriction carries: - `prohibited[]` — tokens blocked on this axis. Takes precedence over `permitted[]`. - `advisory` — when `false` (the **default**), the restriction is **binding**: an agent that cannot evaluate every token in it (including an unknown vendor token) MUST decline the term. Set `advisory: true` to downgrade an unverifiable restriction to non-blocking. (Fail-closed by default — a forgotten flag fails safe.) +**A token cannot be both permitted and prohibited** on one axis, and that is checked twice, over two readings of the same lists. At the RPC boundary `restriction.permitted_prohibited_disjoint` compares the tokens **as written**, because it runs over the request exactly as received. At ingest, after canonicalisation, `restriction.canonical_disjoint` compares what the fold produced. The second is not a repetition: several tokens have more than one accepted spelling, so `permitted: ["scrape"]` with `prohibited: ["crawl"]` — or `["us"]` against `["US"]` — names one token on both sides while passing the first check. Both refuse the term. + **Reading a restriction**: a value is in-scope when it matches at least one `permitted[]` token AND matches none of the `prohibited[]` tokens. Multiple restrictions on the same term are AND-combined — the agent must satisfy all of them. The agent applies this itself when self-selecting a term; the Exchange does not pre-filter terms against requester attributes. **Standard function tokens** (rendered from the proto at build, not hand-listed): @@ -69,7 +71,7 @@ Each restriction carries: ::proto-vocab{axis=user-type} -Unknown tokens during ingest produce a `PushResourcesResponse.warnings[]` entry — the term is accepted but flagged. This is forward-compatible: new vocab tokens can be added to the registry and used immediately without breaking existing Exchange deployments. Tokens are canonicalised before the check — RFC 8259 whitespace trimmed, ASCII case folded, and the accepted aliases listed in each table above resolved to their registered token — so a spelling difference is never an unknown token; only a non-ASCII byte is left as it came, because a Unicode fold would turn a homograph into a registered token. +Unknown tokens during ingest produce a `PushResourcesResponse.warnings[]` entry — the term is accepted but flagged. This is forward-compatible: new vocab tokens can be added to the registry and used immediately without breaking existing Exchange deployments. Tokens are canonicalised before the check — RFC 8259 whitespace trimmed, ASCII case folded, and the accepted aliases listed in each table above resolved to their registered token — so a spelling difference is never an unknown token; only a non-ASCII byte is left as it came, because a Unicode fold would turn a homograph into a registered token. Canonicalisation is also what the disjointness check above is measured against at this tier: the fold is what turns two spellings into the one token the term may not carry on both sides. ## Quotas diff --git a/website/src/content/docs/reference/changelog.mdx b/website/src/content/docs/reference/changelog.mdx index 0710ddef..08fc4aa9 100644 --- a/website/src/content/docs/reference/changelog.mdx +++ b/website/src/content/docs/reference/changelog.mdx @@ -8,6 +8,30 @@ and protocol history, see [`proto/CHANGELOG.md`](https://github.com/RAMP-Protoco ## Unreleased +**A term is now checked for permitted/prohibited disjointness a second time, over +the canonicalised tokens (`restriction.canonical_disjoint`; SDK behaviour change +plus a comment clarification, no wire change).** `restriction.permitted_prohibited_disjoint` +runs at the wire tier, over the request exactly as received, so it compares token +SPELLINGS. Ten restriction tokens have more than one accepted spelling — `scrape` +is a registered alias of `crawl`, `adapt` and `derivative` both mean `modify`, +`personal` means `individual` — and every axis also folds ASCII case. A term naming +one spelling under `permitted` and another under `prohibited` therefore passed the +boundary check and became, once the ingest tier folded it, a stored term with the +same token in both lists. + +Nothing looked at it again, and the failure surfaced elsewhere: the term rides on +offers, an Exchange validates its own responses, and so every discovery request +returning that resource answered with an internal error while the push had looked +clean. The ingest tier now asserts the same property over the canonical values, +under its own rule id, in `ValidateLicenseTerm` and its Python and TypeScript twins. +The boundary rule is unchanged. + +Disjointness is now the one property both tiers assert, deliberately: they read +different values, neither suppresses the other, and a deployment that does not mount +the wire tier still gets the second. The `Restriction` and `CatalogService` comments +say so, and a conformance guard holds the contract's statement to the rule the SDKs +run. + **`Offer.offer_id` is documented as an opaque unique identifier, not a resource key (comment clarification; no wire change).** The comment already said the id is assigned by the Exchange, but an implementation historically derived it from the diff --git a/website/src/content/docs/reference/proto-ramp.mdx b/website/src/content/docs/reference/proto-ramp.mdx index 25dc321b..92a20efa 100644 --- a/website/src/content/docs/reference/proto-ramp.mdx +++ b/website/src/content/docs/reference/proto-ramp.mdx @@ -363,7 +363,7 @@ Broker returns to Agent (Step 6). Discovery-only: `offer_groups` (field 4, one ` Messages for the optional CatalogService RPC — the publisher role's surface: push, remove and refresh the catalog entries a publisher, or a contributor it authorised, supplies to an Exchange. -Every pushed entry is checked in two tiers, and both are stated so a publisher can run them before sending. The **wire tier** is protovalidate: the `ResourceEntry` envelope rules below and the `LicenseTerm` rules, applied to the request exactly as received. The **ingest tier** runs over the canonicalised terms: restriction tokens are trimmed of RFC 8259 whitespace, ASCII-case-folded (lower for `RESTRICTION_KIND_FUNCTION` and `RESTRICTION_KIND_USER_TYPE`, upper for `RESTRICTION_KIND_GEOGRAPHY`; a non-ASCII byte is never folded) and alias-resolved to their registered token; then a bare (non-namespaced) `Pricing.unit` or `Quota.metric` that is not a registered token is rejected, while an unregistered restriction token and an `OBLIGATION_KIND_OTHER` obligation without detail are accepted and reported in `PushResourcesResponse.warnings`. The SDK ships both tiers (`ValidateResourceEntry` and its Python/TypeScript twins) and a catalog client (`NewCatalogClient` / `createCatalogClient` / `CatalogClient`) in all three languages; the Exchange's own run of the checks is the deciding one. +Every pushed entry is checked in two tiers, and both are stated so a publisher can run them before sending. The **wire tier** is protovalidate: the `ResourceEntry` envelope rules below and the `LicenseTerm` rules, applied to the request exactly as received. The **ingest tier** runs over the canonicalised terms: restriction tokens are trimmed of RFC 8259 whitespace, ASCII-case-folded (lower for `RESTRICTION_KIND_FUNCTION` and `RESTRICTION_KIND_USER_TYPE`, upper for `RESTRICTION_KIND_GEOGRAPHY`; a non-ASCII byte is never folded) and alias-resolved to their registered token; then a bare (non-namespaced) `Pricing.unit` or `Quota.metric` that is not a registered token is rejected, as is a restriction whose `permitted` and `prohibited` lists name one token once folded (`restriction.canonical_disjoint`), while an unregistered restriction token and an `OBLIGATION_KIND_OTHER` obligation without detail are accepted and reported in `PushResourcesResponse.warnings`. Disjointness is the one property both tiers assert, over different values, so a term the boundary clears can still be refused at ingest. The SDK ships both tiers (`ValidateResourceEntry` and its Python/TypeScript twins) and a catalog client (`NewCatalogClient` / `createCatalogClient` / `CatalogClient`) in all three languages; the Exchange's own run of the checks is the deciding one. ### PushResourcesRequest @@ -424,7 +424,7 @@ code is the cross-field CEL rule `id:` that enforces the rule (e.g. `license_ter - `pricing` MUST be present on **every** term, any semantics — absent Pricing is a validation error. `model = FREE` must be stated explicitly — absent Pricing is not free. - `semantics` MUST be set — `TERM_SEMANTICS_UNSPECIFIED` is rejected (the field's `enum.not_in:[0]` rule). - `REFERENCE_ONLY` requires `license.uri` to be non-empty (`license_term.reference_only.requires_uri`); a `License` with a `uri` requires a `uri_digest` (`license.digest_required_with_uri`). -- At most one `Restriction` per `kind` (`license_term.one_restriction_per_kind`); a token cannot be both permitted and prohibited (`restriction.permitted_prohibited_disjoint`). +- At most one `Restriction` per `kind` (`license_term.one_restriction_per_kind`); a token cannot be both permitted and prohibited (`restriction.permitted_prohibited_disjoint`). That rule compares the tokens **as written**, because it runs over the request as received — so two accepted spellings of one token (an alias beside its registered form, or either in another ASCII case) clear it and collide once folded. The ingest tier asserts the same property over the canonicalised tokens as `restriction.canonical_disjoint`; both refuse the term, and a term that fails both is reported by both. - `quotas` and `obligations` each carry at most 64 items, the bound every per-message list in the contract carries when no rule walks it more than once. They bound what one term can carry, not the cost of checking it. `restrictions` carries at most **8**, and like the other two this bounds the document, not the cost of checking it. Only one restriction per axis is valid and `Restriction.kind` is defined-only, so four is the longest conformant list and eight leaves room for an axis this version does not have. The tighter bound is deliberate for a second reason: this is the one list a message rule walks against itself, so the cap is also the threshold of the size test the one-per-kind rule carries, and a conformance guard holds the two equal. The disjointness rule on each element is quadratic only in that element's two token lists, both capped at 64, so its cost is bounded per restriction and linear across the list. - Unknown tokens in `restrictions[].permitted` / `prohibited` produce `PushResourcesResponse.warnings[]` but do NOT cause hard rejection (ingest-time, not CEL). Tokens are canonicalised first — RFC 8259 whitespace trimmed, ASCII case folded, and the aliases authored beside the tokens resolved (`train-ai` → `ai-train`, `tdm` → `text-and-data-mining`, `personal` → `individual`, …) — so `Generative-AI` is the registered `ai-input`, not an unknown token. - A bare (non-namespaced) `pricing.unit` or `quotas[].metric` that is not a registered token IS a hard rejection at ingest (registry membership, not CEL: a rule that re-listed the vocabulary would drift from it). A `vendor:token` value bypasses membership on every axis. From deb3ef66cc19ed1df1e584f467b848d09f57b7dc Mon Sep 17 00:00:00 2001 From: legendko Date: Wed, 2 Sep 2026 11:15:19 +0200 Subject: [PATCH 5/9] test(sdk): derive which rule ids are term rejects instead of listing them thrice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sorting an entry's violations into term rejects, cross-field rules and structural faults needed the same set of ingest-tier ids written down in four places: the emitter, the Go replay and the two ports. Adding one reject meant four edits, and the failure of missing one is quiet — a violation lands in the structural bucket and the entry assertions still pass. The three replays now read the set out of the corpus, because an ingest-tier reject IS an id the per-term list can carry as a violation. That is the trade the neighbouring cross-field set already makes, and for the same reason. The emitter cannot derive it — it writes the corpus the others read — so it keeps the one authored copy, beside the checks it classifies. Each derivation carries the vacuity guard the ids guard in conformance already uses: an empty set would move every term reject to structural and leave the assertions green, so both long-standing ids are required to be present, which pins that the column is still being read. The corpus is byte-identical. This changes how the replays classify, not what they assert. --- .../helpers/gen_licenseterm_vectors_test.go | 16 ++++++++-- sdk/go/helpers/licenseterm_corpus_test.go | 32 +++++++++++++++++-- sdk/python/tests/test_licenseterm_parity.py | 18 +++++++---- sdk/ts/tests/licenseterm.parity.test.ts | 23 +++++++++---- 4 files changed, 72 insertions(+), 17 deletions(-) diff --git a/sdk/go/helpers/gen_licenseterm_vectors_test.go b/sdk/go/helpers/gen_licenseterm_vectors_test.go index 26a18544..fa491329 100644 --- a/sdk/go/helpers/gen_licenseterm_vectors_test.go +++ b/sdk/go/helpers/gen_licenseterm_vectors_test.go @@ -111,6 +111,19 @@ type ltEntryVector struct { Warnings []ltFinding `json:"warnings"` } +// ltIngestRejectRuleIDs is the set of rule ids ValidateLicenseTerm can return as a +// VIOLATION. It is the one authored copy of that classification: the emitter uses it +// to sort an entry's violations into columns, and the three replays derive the same +// set from the corpus this emitter writes, so a new ingest-tier reject is registered +// here and nowhere else. +// +// It cannot be derived on this side — the emitter produces the corpus the others read. +var ltIngestRejectRuleIDs = map[string]bool{ + RulePricingUnitRegistered: true, + RuleQuotaMetricRegistered: true, + RuleRestrictionCanonicalDisjoint: true, +} + // ltCrossFieldRuleIDs is every message-level CEL id the contract declares, read // from the generated descriptor rather than listed here. Listing them would be a // fourth copy of a set the proto already owns, and the copy that drifts is the one @@ -724,8 +737,7 @@ func buildLTEntryVectors(t *testing.T) []ltEntryVector { CrossFieldRules: []string{}, TermRules: []ltFinding{}, Warnings: ltWarningsOf(verdict.Warnings)} for _, viol := range verdict.Violations { switch { - case viol.Rule == RulePricingUnitRegistered || viol.Rule == RuleQuotaMetricRegistered || - viol.Rule == RuleRestrictionCanonicalDisjoint: + case ltIngestRejectRuleIDs[viol.Rule]: v.TermRules = append(v.TermRules, ltFindingOf(viol.Rule, viol.Path, viol.Token, viol.Message)) case celIDs[viol.Rule]: v.CrossFieldRules = append(v.CrossFieldRules, viol.Rule) diff --git a/sdk/go/helpers/licenseterm_corpus_test.go b/sdk/go/helpers/licenseterm_corpus_test.go index 1ea94f30..6a469b9d 100644 --- a/sdk/go/helpers/licenseterm_corpus_test.go +++ b/sdk/go/helpers/licenseterm_corpus_test.go @@ -211,9 +211,36 @@ func TestLicenseTermCorpus_Validate(t *testing.T) { } } +// corpusTermRuleIDs is the set of ids ValidateLicenseTerm can return as a violation, +// read out of the corpus's own per-term list rather than listed here: an ingest-tier +// reject IS an id that list can carry as a violation. Reading them beats restating +// them, the same trade the cross-field set already makes — a classification written +// down twice is a classification that can disagree with itself. +func corpusTermRuleIDs(t *testing.T, c licenseTermCorpus) map[string]bool { + t.Helper() + ids := map[string]bool{} + for _, v := range c.Validate { + if v.Violation != nil { + ids[v.Violation.Rule] = true + } + } + // Guard the guard. A derivation that stopped reading the column would leave an + // empty set here and quietly move every term reject into the structural bucket, + // where the entry assertions would still pass. Both long-standing ids are + // reachable from that list, so requiring them pins that it is still being read. + for _, want := range []string{helpers.RulePricingUnitRegistered, helpers.RuleQuotaMetricRegistered} { + if !ids[want] { + t.Fatalf("the per-term list carries no %q — the column this classification is derived from has moved", want) + } + } + return ids +} + func TestLicenseTermCorpus_Entry(t *testing.T) { + corpus := loadLicenseTermCorpus(t) celIDs := corpusCrossFieldRuleIDs(t) - for _, v := range loadLicenseTermCorpus(t).Entry { + termRuleIDs := corpusTermRuleIDs(t, corpus) + for _, v := range corpus.Entry { var entry rampv1.ResourceEntry if err := protojson.Unmarshal(v.Entry, &entry); err != nil { t.Fatalf("%s: decode entry: %v", v.Name, err) @@ -230,8 +257,7 @@ func TestLicenseTermCorpus_Entry(t *testing.T) { termRules := []corpusFinding{} for _, viol := range verdict.Violations { switch { - case viol.Rule == helpers.RulePricingUnitRegistered || viol.Rule == helpers.RuleQuotaMetricRegistered || - viol.Rule == helpers.RuleRestrictionCanonicalDisjoint: + case termRuleIDs[viol.Rule]: termRules = append(termRules, corpusFinding{Rule: viol.Rule, Path: viol.Path, Token: viol.Token, Message: viol.Message}) case celIDs[viol.Rule]: crossField = append(crossField, viol.Rule) diff --git a/sdk/python/tests/test_licenseterm_parity.py b/sdk/python/tests/test_licenseterm_parity.py index 4f6ee7ee..7dc2081c 100644 --- a/sdk/python/tests/test_licenseterm_parity.py +++ b/sdk/python/tests/test_licenseterm_parity.py @@ -24,7 +24,6 @@ from ramp_sdk.licenseterm import ( RULE_PRICING_UNIT_REGISTERED, RULE_QUOTA_METRIC_REGISTERED, - RULE_RESTRICTION_CANONICAL_DISJOINT, canonical_restriction_token, known_restriction_token, normalize_license_term, @@ -38,11 +37,18 @@ _KNOWN = _VECTORS["known"] _VALIDATE = _VECTORS["validate"] _ENTRY = _VECTORS["entry"] -_TERM_RULES = { - RULE_PRICING_UNIT_REGISTERED, - RULE_QUOTA_METRIC_REGISTERED, - RULE_RESTRICTION_CANONICAL_DISJOINT, -} +# The ingest-tier reject ids, read out of the corpus's own per-term list — an +# ingest-tier reject IS an id that list can carry as a violation. Reading them beats +# listing them here, the same trade the cross-field set below already makes: a +# classification written down in four places is one that can disagree with itself. +_TERM_RULES = {case["violation"]["rule"] for case in _VALIDATE if case["violation"] is not None} + +# Guard the guard. A derivation that stopped reading the column would leave an empty +# set and quietly move every term reject into the structural bucket, where the entry +# assertions would still pass. Both long-standing ids are reachable from that list, so +# requiring them pins that it is still being read. +for _id in (RULE_PRICING_UNIT_REGISTERED, RULE_QUOTA_METRIC_REGISTERED): + assert _id in _TERM_RULES, f"the per-term list carries no {_id} — the column this classification is derived from has moved" # The registered cross-field rule ids, read from the generated cross-field corpus — # corpusgen emits one mutant per message-level CEL rule, so its ``rules`` are the diff --git a/sdk/ts/tests/licenseterm.parity.test.ts b/sdk/ts/tests/licenseterm.parity.test.ts index 2651689e..2840323f 100644 --- a/sdk/ts/tests/licenseterm.parity.test.ts +++ b/sdk/ts/tests/licenseterm.parity.test.ts @@ -17,7 +17,6 @@ import vectorsFile from "../../go/helpers/testdata/licenseterm-vectors.json"; import { RULE_PRICING_UNIT_REGISTERED, RULE_QUOTA_METRIC_REGISTERED, - RULE_RESTRICTION_CANONICAL_DISJOINT, canonicalRestrictionToken, knownRestrictionToken, normalizeLicenseTerm, @@ -44,11 +43,23 @@ type Vectors = { }; const vectors = vectorsFile as Vectors; -const TERM_RULES = new Set([ - RULE_PRICING_UNIT_REGISTERED, - RULE_QUOTA_METRIC_REGISTERED, - RULE_RESTRICTION_CANONICAL_DISJOINT, -]); +// The ingest-tier reject ids, read out of the corpus's own per-term list — an +// ingest-tier reject IS an id that list can carry as a violation. Reading them beats +// listing them here, the same trade the cross-field set below already makes: a +// classification written down in four places is one that can disagree with itself. +const TERM_RULES = new Set( + vectors.validate.flatMap((v) => (v.violation ? [v.violation.rule] : [])), +); + +// Guard the guard. A derivation that stopped reading the column would leave an empty +// set and quietly move every term reject into the structural bucket, where the entry +// assertions would still pass. Both long-standing ids are reachable from that list, +// so requiring them pins that it is still being read. +for (const id of [RULE_PRICING_UNIT_REGISTERED, RULE_QUOTA_METRIC_REGISTERED]) { + if (!TERM_RULES.has(id)) { + throw new Error(`the per-term list carries no ${id} — the column this classification is derived from has moved`); + } +} // The registered cross-field rule ids, read from the generated cross-field corpus — // corpusgen emits one mutant per message-level CEL rule, so its `rules` are the From 10e87f2308876af2b76f7c756858341d592cede5 Mon Sep 17 00:00:00 2001 From: legendko Date: Wed, 2 Sep 2026 15:00:50 +0200 Subject: [PATCH 6/9] fix(sdk): give the disjointness refusal one message in all three languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refusal's message is wire-visible — an Exchange copies it into the rejection it answers a publisher with — so it is pinned byte for byte across the three SDKs, the committed corpus, and a byte-compare on the consuming side. It was not actually identical. Go formatted the token with %q and the two ports interpolated it raw, so the three answers agreed only on tokens that need no escaping. A token carrying a quote, a backslash or a control byte produced three different strings from one input. The corpus could not see it: every token in it is lowercase alphanumerics and hyphens, on which the two verbs agree. Go now spells the token the way the file's three other messages already spell it. The direction is deliberate. Aligning the ports to %q would mean reimplementing Go's escape syntax in TypeScript and Python byte-exactly, a new cross-language hazard for no gain, while aligning Go to the ports makes the fourth message behave like the other three: the neighbouring pricing-unit and quota-metric messages have always interpolated raw in all three languages, and their fields carry the same wire pattern, so this narrows a difference rather than widening an exposure. The corpus regenerates byte-identically. The two JSON ports also stop coercing an ill-typed token to the empty string. Their string accessors turn anything else into "", and the disjointness check is the one check in the file that cannot skip an empty token — two empty strings really are one token named on both sides, which is what the Go oracle answers — so a list carrying a number and a null was reported as a collision on the empty token. That names the wrong fault; the real one, "this element is not a string", is what the wire tier already reports beside it. An ill-typed element is now skipped, on the same division the file already applies to empty and vendor-namespaced tokens. Go needs nothing here: its token lists are []string. That behaviour cannot reach the shared corpus, because the Go oracle cannot construct a non-string token any more than it can construct an entry that is a string. It is pinned where the repo already pins that class, in the two port-local tests that mirror each other and say in their header why no vector can exist. --- sdk/go/helpers/licenseterm.go | 2 +- sdk/python/ramp_sdk/licenseterm.py | 14 ++++- .../tests/test_licenseterm_non_object.py | 52 +++++++++++++++++-- sdk/ts/src/licenseterm.ts | 14 ++++- sdk/ts/tests/licenseterm-non-object.test.ts | 51 ++++++++++++++++-- 5 files changed, 118 insertions(+), 15 deletions(-) diff --git a/sdk/go/helpers/licenseterm.go b/sdk/go/helpers/licenseterm.go index 50a7b6cb..087c40bb 100644 --- a/sdk/go/helpers/licenseterm.go +++ b/sdk/go/helpers/licenseterm.go @@ -351,7 +351,7 @@ func canonicalDisjointViolation(i int, r *rampv1.Restriction) *RuleViolation { Rule: RuleRestrictionCanonicalDisjoint, Path: fmt.Sprintf("restrictions[%d].permitted[%d]", i, j), Token: canon, - Message: fmt.Sprintf("restriction token %q is both permitted and prohibited after canonicalisation", canon), + Message: fmt.Sprintf("restriction token \"%s\" is both permitted and prohibited after canonicalisation", canon), } } return nil diff --git a/sdk/python/ramp_sdk/licenseterm.py b/sdk/python/ramp_sdk/licenseterm.py index dda2f9f0..1deba113 100644 --- a/sdk/python/ramp_sdk/licenseterm.py +++ b/sdk/python/ramp_sdk/licenseterm.py @@ -247,15 +247,25 @@ def _canonical_disjoint_violation(i: int, restriction: dict[str, Any]) -> RuleVi equality, which is what a server that never asked for the wire tier needs. The finding names the canonical token, not the spellings that produced it, so the message does not depend on whether the caller folded first. + + An element that is not a string is SKIPPED rather than coerced. Coercing it to ``""`` + would report two ill-typed elements as a collision on the empty token, which names the + wrong fault; the wire tier already refuses a non-string where the schema says string, + the same division this file applies to an empty or namespaced token. Two genuinely + empty strings still collide, which is what the Go oracle answers. """ permitted = _as_list(restriction.get("permitted")) prohibited = _as_list(restriction.get("prohibited")) if not permitted or not prohibited: return None kind = _str(restriction.get("kind")) - banned = {canonical_restriction_token(kind, _str(tok)) for tok in prohibited} + banned = { + canonical_restriction_token(kind, tok) for tok in prohibited if isinstance(tok, str) + } for j, tok in enumerate(permitted): - canon = canonical_restriction_token(kind, _str(tok)) + if not isinstance(tok, str): + continue + canon = canonical_restriction_token(kind, tok) if canon not in banned: continue return RuleViolation( diff --git a/sdk/python/tests/test_licenseterm_non_object.py b/sdk/python/tests/test_licenseterm_non_object.py index 31cf6f84..eb783212 100644 --- a/sdk/python/tests/test_licenseterm_non_object.py +++ b/sdk/python/tests/test_licenseterm_non_object.py @@ -1,8 +1,10 @@ -"""A value that is not an entry gets a VERDICT, not an exception. +"""An ill-typed value gets a VERDICT, not an exception, and is not mistaken for a +well-typed one. -This one has no shared vector and cannot get one from the Go oracle: the Go face takes -a ``*rampv1.ResourceEntry``, so "a string where an entry should be" is not a value it -can construct, let alone marshal into a corpus. The behaviour is a property of the two +Neither case here has a shared vector, and neither can get one from the Go oracle: the +Go face takes a ``*rampv1.ResourceEntry``, so "a string where an entry should be" is not +a value it can construct, and ``Restriction.permitted`` is ``[]string``, so "a number +where a token should be" is not either — let alone marshal into a corpus. The behaviour is a property of the two JSON ports, whose faces take whatever a caller parsed. It is reachable the ordinary way. The pre-check exists for feeds, a JSONL line is parsed @@ -26,7 +28,11 @@ import pytest -from ramp_sdk.licenseterm import validate_resource_entry +from ramp_sdk.licenseterm import ( + RULE_RESTRICTION_CANONICAL_DISJOINT, + validate_license_term, + validate_resource_entry, +) @pytest.mark.parametrize( @@ -43,3 +49,39 @@ def test_refuses_a_non_object_with_a_verdict(label: str, value: Any) -> None: def test_a_real_entry_is_still_valid() -> None: """The guard must not have swallowed the walk it stands in front of.""" assert validate_resource_entry({"domain": "e.co", "path": "/a"}).ok + + +# The same division one level down, on the restriction token lists. +# +# Every other check in the module skips an empty token, on the reasoning that an empty +# string is the wire tier's business. The canonical-disjointness check cannot skip it — +# two empty strings really are one token named on both sides, which is what the Go +# oracle answers — so it has to tell an EMPTY token from an ILL-TYPED one. Coercing the +# latter to "" would report a collision on the empty token and name the wrong fault, +# while the real fault, "this element is not a string", is what the wire tier already +# reports beside it. +def _term(permitted: list[Any], prohibited: list[Any]) -> dict[str, Any]: + return { + "restrictions": [ + {"kind": "RESTRICTION_KIND_FUNCTION", "permitted": permitted, "prohibited": prohibited} + ] + } + + +def test_ill_typed_elements_are_not_a_collision_on_the_empty_token() -> None: + assert validate_license_term(_term(["ai-train", 5], ["search", None])).violation is None + + +def test_a_token_named_on_both_sides_is_still_refused() -> None: + violation = validate_license_term(_term(["scrape", 5], ["crawl", None])).violation + assert violation is not None + assert violation.rule == RULE_RESTRICTION_CANONICAL_DISJOINT + assert violation.token == "crawl" + + +def test_the_empty_token_still_collides() -> None: + """Two empty strings are one token named on both sides; the Go oracle refuses them.""" + violation = validate_license_term(_term([""], [""])).violation + assert violation is not None + assert violation.rule == RULE_RESTRICTION_CANONICAL_DISJOINT + assert violation.token == "" diff --git a/sdk/ts/src/licenseterm.ts b/sdk/ts/src/licenseterm.ts index 1b98d10e..eb453bbb 100644 --- a/sdk/ts/src/licenseterm.ts +++ b/sdk/ts/src/licenseterm.ts @@ -251,15 +251,25 @@ function restrictionTokenWarning(kind: string, tok: string, path: string): RuleW * plain equality, which is what a server that never asked for the wire tier * needs. The finding names the canonical token, not the spellings that produced * it, so the message does not depend on whether the caller folded first. + * + * An element that is not a string is SKIPPED rather than coerced. Coercing it to "" + * would report two ill-typed elements as a collision on the empty token, which names + * the wrong fault; the wire tier already refuses a non-string where the schema says + * string, the same division this file applies to an empty or namespaced token. Two + * genuinely empty strings still collide, which is what the Go oracle answers. */ function canonicalDisjointViolation(i: number, r: Obj): RuleViolation | undefined { const permitted = asArr(r["permitted"]); const prohibited = asArr(r["prohibited"]); if (permitted.length === 0 || prohibited.length === 0) return undefined; const kind = str(r["kind"]); - const banned = new Set(prohibited.map((t) => canonicalRestrictionToken(kind, str(t)))); + const banned = new Set( + prohibited.filter((t) => typeof t === "string").map((t) => canonicalRestrictionToken(kind, t)), + ); for (let j = 0; j < permitted.length; j++) { - const canon = canonicalRestrictionToken(kind, str(permitted[j])); + const tok = permitted[j]; + if (typeof tok !== "string") continue; + const canon = canonicalRestrictionToken(kind, tok); if (!banned.has(canon)) continue; return { rule: RULE_RESTRICTION_CANONICAL_DISJOINT, diff --git a/sdk/ts/tests/licenseterm-non-object.test.ts b/sdk/ts/tests/licenseterm-non-object.test.ts index 3a94d085..c7ecb983 100644 --- a/sdk/ts/tests/licenseterm-non-object.test.ts +++ b/sdk/ts/tests/licenseterm-non-object.test.ts @@ -1,8 +1,10 @@ -// A value that is not an entry gets a VERDICT, not an exception. +// An ill-typed value gets a VERDICT, not an exception, and is not mistaken for a +// well-typed one. // -// This one has no shared vector and cannot get one from the Go oracle: the Go face -// takes a *rampv1.ResourceEntry, so "a string where an entry should be" is not a -// value it can construct, let alone marshal into a corpus. The behaviour is a +// Neither case here has a shared vector, and neither can get one from the Go oracle: +// the Go face takes a *rampv1.ResourceEntry, so "a string where an entry should be" is +// not a value it can construct, and Restriction.permitted is []string, so "a number +// where a token should be" is not either — let alone marshal into a corpus. The behaviour is a // property of the two JSON ports, whose faces take whatever a caller parsed. // // It is reachable the ordinary way. The pre-check exists for feeds, a JSONL line is @@ -20,7 +22,11 @@ // Mirrors sdk/python/tests/test_licenseterm_non_object.py. import { describe, expect, it } from "vitest"; -import { validateResourceEntry } from "../src/licenseterm.ts"; +import { + RULE_RESTRICTION_CANONICAL_DISJOINT, + validateLicenseTerm, + validateResourceEntry, +} from "../src/licenseterm.ts"; describe("validateResourceEntry on a value that is not an object", () => { for (const [label, value] of [ @@ -43,3 +49,38 @@ describe("validateResourceEntry on a value that is not an object", () => { expect(verdict.ok).toBe(true); }); }); + +// The same division one level down, on the restriction token lists. +// +// Every other check in the file skips an empty token, on the reasoning that an empty +// string is the wire tier's business. The canonical-disjointness check cannot skip it — +// two empty strings really are one token named on both sides, which is what the Go +// oracle answers — so it has to tell an EMPTY token from an ILL-TYPED one. Coercing +// the latter to "" would report a collision on the empty token and name the wrong +// fault, while the real fault, "this element is not a string", is what the wire tier +// already reports beside it. +describe("canonical disjointness on ill-typed restriction tokens", () => { + const term = (permitted: unknown[], prohibited: unknown[]) => ({ + restrictions: [{ kind: "RESTRICTION_KIND_FUNCTION", permitted, prohibited }], + }); + + it("does not read two ill-typed elements as a collision on the empty token", () => { + // biome-ignore lint/suspicious/noExplicitAny: the point is the untyped caller + const verdict = validateLicenseTerm(term(["ai-train", 5], ["search", null]) as any); + expect(verdict.violation).toBeNull(); + }); + + it("still refuses a token genuinely named on both sides", () => { + // biome-ignore lint/suspicious/noExplicitAny: the point is the untyped caller + const verdict = validateLicenseTerm(term(["scrape", 5], ["crawl", null]) as any); + expect(verdict.violation?.rule).toBe(RULE_RESTRICTION_CANONICAL_DISJOINT); + expect(verdict.violation?.token).toBe("crawl"); + }); + + it("keeps the empty token colliding, which is what the Go oracle answers", () => { + // biome-ignore lint/suspicious/noExplicitAny: the point is the untyped caller + const verdict = validateLicenseTerm(term([""], [""]) as any); + expect(verdict.violation?.rule).toBe(RULE_RESTRICTION_CANONICAL_DISJOINT); + expect(verdict.violation?.token).toBe(""); + }); +}); From 4681d96d64ccedd88fe40d8ae2dd186d1b36d4bd Mon Sep 17 00:00:00 2001 From: legendko Date: Wed, 2 Sep 2026 15:02:56 +0200 Subject: [PATCH 7/9] test(sdk): record the fold-dependent accepted term where the Exchange's answer is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corpus's per-term list holds one case per face; the per-entry list holds the composition an Exchange actually runs. A term that is disjoint only after folding was in the wrong one. Read as written, a bare alias is an unregistered token and earns a warning. Read after the fold it is a registered token and earns none. The per-term list records the term as authored, so it recorded a warning that no Exchange ever sends — which is not a difference a consumer can express by rewriting a token, because the warning does not change, it disappears. The per-entry list folds a copy first, exactly as the Exchange does, so the same case there records the answer a publisher is really given. Its neighbour already pins that asymmetry for membership; this now pins it for disjointness. What stays in the per-term list is the half that does not depend on where it is read: every alias-pair refusal, because the finding names the CANONICAL token and is therefore the same whether the caller folded first or not. That is the coverage the rule needs, and it is unmoved. The derived alias table now names every axis that CAN carry aliases rather than every axis that does today. GEOGRAPHY registers none, so it contributes nothing now and contributes a vector the day it registers one. Listing only the axes with aliases made the claim "an alias added to the proto brings its vector with it" false for the axis nobody was thinking about — which is the shape of the defect this whole rule exists to answer. --- .../helpers/gen_licenseterm_vectors_test.go | 32 +++++++--- .../helpers/testdata/licenseterm-vectors.json | 62 ++++++++++--------- 2 files changed, 57 insertions(+), 37 deletions(-) diff --git a/sdk/go/helpers/gen_licenseterm_vectors_test.go b/sdk/go/helpers/gen_licenseterm_vectors_test.go index fa491329..7d49cea7 100644 --- a/sdk/go/helpers/gen_licenseterm_vectors_test.go +++ b/sdk/go/helpers/gen_licenseterm_vectors_test.go @@ -30,6 +30,7 @@ import ( "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" "github.com/RAMP-Protocol/protocol/gen/go/vocab/functiontokens" + "github.com/RAMP-Protocol/protocol/gen/go/vocab/geographytokens" "github.com/RAMP-Protocol/protocol/gen/go/vocab/usertypes" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -523,10 +524,9 @@ func buildLTValidateVectors(t *testing.T) []ltValidateVector { {"restriction_padded_token_collides_after_trim_rejected", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{" crawl "}, []string{"crawl"})} })}, - // The other side of the rule: folding must not invent a collision. - {"restriction_disjoint_after_fold_accepted", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { - x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{"scrape"}, []string{"ai-train"})} - })}, + // The other side of the rule: folding must not invent a collision. The + // alias-bearing form of this case is an ENTRY vector, not one of these — + // see alias_disjoint_after_fold_accepted for why. {"restriction_namespaced_tokens_disjoint_accepted", ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindOther, []string{"acme:read"}, []string{"acme:write"})} })}, @@ -553,15 +553,20 @@ func buildLTValidateVectors(t *testing.T) []ltValidateVector { // than listed, so an alias added to the proto brings its vector with it and the // obligation to cover every alias pair cannot quietly rot. Each pairs the alias // under permitted with its registered form under prohibited: two strings the - // wire tier reads as disjoint and the fold reads as one token. GEOGRAPHY is - // absent because it registers no aliases — its collisions are the case-folding - // ones above. + // wire tier reads as disjoint and the fold reads as one token. + // + // Every axis that CAN carry aliases is listed, not every axis that does today: + // GEOGRAPHY registers none, so it contributes nothing now and contributes a + // vector the day it registers one. Listing only the axes with aliases would have + // made the sentence above false for the axis nobody was thinking about, which is + // how this rule's own defect reached the catalog. for _, axis := range []struct { name string kind rampv1.RestrictionKind aliases map[string]string }{ {"function", ltKindFunction, functiontokens.Aliases}, + {"geography", ltKindGeography, geographytokens.Aliases}, {"user_type", ltKindUserType, usertypes.Aliases}, } { for _, alias := range ltSortedAliases(axis.aliases) { @@ -613,6 +618,19 @@ func buildLTEntryVectors(t *testing.T) []ltEntryVector { {"alias_resolved_before_membership_no_warning", entry(func(e *rampv1.ResourceEntry) { e.Terms[0].Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{"Generative-AI"}, nil)} })}, + // Folding must not invent a collision, recorded here rather than in the + // per-term list because only the composed face answers what an Exchange + // answers. A bare alias earns an unregistered-token warning when it is read + // AS WRITTEN, and earns none once the fold resolves it to a registered token; + // the entry face folds a copy first, exactly as the Exchange does, so the + // warning this records is the one a publisher is really sent. Its neighbour + // above pins the same asymmetry for membership; this one pins it for + // disjointness. + {"alias_disjoint_after_fold_accepted", entry(func(e *rampv1.ResourceEntry) { + e.Terms[0].Restrictions = []*rampv1.Restriction{ + ltRestriction(ltKindFunction, []string{"scrape"}, []string{"ai-train"}), + } + })}, {"unknown_token_warns_with_entry_path", entry(func(e *rampv1.ResourceEntry) { e.Terms = append(e.Terms, ltEnumerated(ltFreePricing(), func(x *rampv1.LicenseTerm) { x.Restrictions = []*rampv1.Restriction{ltRestriction(ltKindFunction, []string{"ai-train", "flibbertigibbet"}, nil)} diff --git a/sdk/go/helpers/testdata/licenseterm-vectors.json b/sdk/go/helpers/testdata/licenseterm-vectors.json index 27b67e6e..7b753b0a 100644 --- a/sdk/go/helpers/testdata/licenseterm-vectors.json +++ b/sdk/go/helpers/testdata/licenseterm-vectors.json @@ -71,6 +71,38 @@ "term_rules": [], "warnings": [] }, + { + "name": "alias_disjoint_after_fold_accepted", + "entry": { + "domain": "publisher.example", + "path": "/premium/article-42.html", + "terms": [ + { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "restrictions": [ + { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "scrape" + ], + "prohibited": [ + "ai-train" + ] + } + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + } + ] + }, + "ok": true, + "structural": false, + "cross_field_rules": [], + "term_rules": [], + "warnings": [] + }, { "name": "unknown_token_warns_with_entry_path", "entry": { @@ -2449,36 +2481,6 @@ }, "warnings": [] }, - { - "name": "restriction_disjoint_after_fold_accepted", - "term": { - "pricing": { - "model": "PRICING_MODEL_FREE", - "rate": "0" - }, - "restrictions": [ - { - "kind": "RESTRICTION_KIND_FUNCTION", - "permitted": [ - "scrape" - ], - "prohibited": [ - "ai-train" - ] - } - ], - "semantics": "TERM_SEMANTICS_ENUMERATED" - }, - "violation": null, - "warnings": [ - { - "rule": "restriction.token.registered", - "path": "restrictions[0].permitted[0]", - "token": "scrape", - "message": "unregistered RESTRICTION_KIND_FUNCTION restriction token \"scrape\" (term accepted)" - } - ] - }, { "name": "restriction_namespaced_tokens_disjoint_accepted", "term": { From a7efd3a6a6a4d093a1906afbd8979d3314950505 Mon Sep 17 00:00:00 2001 From: legendko Date: Wed, 2 Sep 2026 15:04:57 +0200 Subject: [PATCH 8/9] docs(sdk): correct the comments this rule made false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four comments described the file as it was before the canonical-disjointness check existed. The new rule id was inserted between an existing Sphinx comment and the constant it described, so Python's public documentation said the REJECT constant "warns … the term is accepted" while the warn constant was left with none at all, and the new one carried a trailing docstring where every sibling uses the module's comment idiom. Both constants now carry their own description, in that idiom. The two port headers still enumerated the ingest tier's hard rejects as an unregistered unit or metric. They now name the third, matching the Go header and the contract. The per-term verdict was documented in all three languages as reading an already canonical term. It no longer does, and deliberately: the disjointness check folds what it compares. The generator's own note says which half of the list is which, because that is what a maintainer reads to know what may be added to it — a case that is accepted must stay canonical, since a bare alias read as written earns a warning the fold takes away, and such a case belongs in the entry list. The cost note justified itself with the per-list cap while naming, two paragraphs above, the mount that has no wire tier and therefore no cap. The work is linear in the tokens present; what bounds how many arrive is the cap where the wire tier runs, and the size of the request the server agreed to read where it does not. Citing only the cap claimed a bound the one deployment this check exists for does not have. --- .../helpers/gen_licenseterm_vectors_test.go | 12 ++++++++++- sdk/go/helpers/licenseterm.go | 10 +++++++--- sdk/python/ramp_sdk/licenseterm.py | 20 +++++++++++-------- sdk/ts/src/licenseterm.ts | 9 +++++++-- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/sdk/go/helpers/gen_licenseterm_vectors_test.go b/sdk/go/helpers/gen_licenseterm_vectors_test.go index 7d49cea7..96289241 100644 --- a/sdk/go/helpers/gen_licenseterm_vectors_test.go +++ b/sdk/go/helpers/gen_licenseterm_vectors_test.go @@ -74,7 +74,17 @@ type ltFinding struct { Message string `json:"message"` } -// ltValidateVector is one ValidateLicenseTerm case over an already-canonical term. +// ltValidateVector is one ValidateLicenseTerm case. +// +// Its term is canonical wherever the case is ACCEPTED, because every check but one +// reads it that way. The disjointness rejections are the exception and carry the term +// as a publisher authored it — an alias beside its registered form, or two spellings +// differing in case — because that check folds what it compares and exists precisely +// to catch what the fold produces. A case that is accepted must stay canonical: read +// as written, a bare alias earns a warning that the fold takes away, and this list +// would then record a warning no Exchange sends. Such a case belongs in the entry +// list, where a copy is folded first. + type ltValidateVector struct { Name string `json:"name"` Term json.RawMessage `json:"term"` diff --git a/sdk/go/helpers/licenseterm.go b/sdk/go/helpers/licenseterm.go index 087c40bb..cc090eb6 100644 --- a/sdk/go/helpers/licenseterm.go +++ b/sdk/go/helpers/licenseterm.go @@ -328,9 +328,13 @@ func ValidateResourceEntry(entry *rampv1.ResourceEntry) EntryVerdict { // wire-visible and pinned byte-for-byte across the three SDKs. Path locates the // permitted element; the prohibited one is the entry that folds to the same token. // -// The prohibited list is folded once into a set, so the cost is one fold per -// token rather than one per pair: both lists are capped at 64, and a pairwise -// walk would fold four thousand times per restriction. +// The prohibited list is folded once into a set, so the cost is one fold per token +// rather than one per pair — linear in the tokens present, where a pairwise walk +// would have been their product. The bound on how many arrive is NOT this rule's: +// where the wire tier runs it is the max_items cap on each list, and on the mount +// named above, which has no wire tier, it is the size of the request the server +// agreed to read. Stating the cap alone would have claimed a bound that the one +// deployment this check exists for does not have. func canonicalDisjointViolation(i int, r *rampv1.Restriction) *RuleViolation { prohibited := r.GetProhibited() permitted := r.GetPermitted() diff --git a/sdk/python/ramp_sdk/licenseterm.py b/sdk/python/ramp_sdk/licenseterm.py index 1deba113..3b3e087f 100644 --- a/sdk/python/ramp_sdk/licenseterm.py +++ b/sdk/python/ramp_sdk/licenseterm.py @@ -6,7 +6,8 @@ field-level model plus the cross-field (message-CEL) rules, applied to the entry as received. The ingest tier runs over the CANONICALISED terms: restriction tokens are folded and alias-resolved to their registered form, then a bare -``Pricing.unit`` or ``Quota.metric`` that is not a registered token is rejected, +``Pricing.unit`` or ``Quota.metric`` that is not a registered token is rejected, as +is a restriction whose permitted and prohibited lists name one token once folded, while an unregistered restriction token and an ``OBLIGATION_KIND_OTHER`` obligation without detail are accepted with a warning that reaches ``PushResourcesResponse.warnings``. The Exchange's own run is the deciding one; a @@ -36,13 +37,12 @@ RULE_PRICING_UNIT_REGISTERED = "pricing.unit.registered" #: Rejects a bare Quota.metric that is not a registered quota token. RULE_QUOTA_METRIC_REGISTERED = "quota.metric.registered" -#: Warns about a bare restriction token not registered on its axis; the term is accepted. +#: Rejects a restriction whose permitted and prohibited lists name the same token once both +#: are canonicalised. The wire tier's rule compares the tokens AS WRITTEN, so two accepted +#: spellings of one token — an alias beside its registered form, or two spellings differing +#: only in ASCII case — pass it and collide only after the fold. RULE_RESTRICTION_CANONICAL_DISJOINT = "restriction.canonical_disjoint" -"""Rejects a restriction whose permitted and prohibited lists name the same token once -both are canonicalised. The wire tier's rule compares the tokens AS WRITTEN, so two -accepted spellings of one token — an alias beside its registered form, or two spellings -differing only in ASCII case — pass it and collide only after the fold.""" - +#: Warns about a bare restriction token not registered on its axis; the term is accepted. RULE_RESTRICTION_TOKEN_REGISTERED = "restriction.token.registered" #: Warns about an OBLIGATION_KIND_OTHER obligation carrying no detail. RULE_OBLIGATION_OTHER_REQUIRES_DETAIL = "obligation.other.requires_detail" @@ -88,7 +88,11 @@ class RuleWarning: @dataclass(frozen=True) class TermVerdict: - """What :func:`validate_license_term` reports for one already-canonical term.""" + """What :func:`validate_license_term` reports for one term. + + Every check but the disjointness one reads the term as already canonical; that one + folds what it compares, so it is correct on a term as authored too. + """ violation: RuleViolation | None warnings: list[RuleWarning] = field(default_factory=list) diff --git a/sdk/ts/src/licenseterm.ts b/sdk/ts/src/licenseterm.ts index eb453bbb..b24d7d2d 100644 --- a/sdk/ts/src/licenseterm.ts +++ b/sdk/ts/src/licenseterm.ts @@ -7,7 +7,8 @@ // applied to the entry as received. The ingest tier runs over the CANONICALISED // terms: restriction tokens are folded and alias-resolved to their registered // form, then a bare Pricing.unit or Quota.metric that is not a registered token -// is rejected, while an unregistered restriction token and an +// is rejected, as is a restriction whose permitted and prohibited lists name one +// token once folded, while an unregistered restriction token and an // OBLIGATION_KIND_OTHER obligation without detail are accepted with a warning // that reaches PushResourcesResponse.warnings. The Exchange's own run is the // deciding one; a client-side verdict is advice about what that run will say. @@ -69,7 +70,11 @@ export interface RuleWarning { message: string; } -/** What validateLicenseTerm reports for one already-canonical term. */ +/** + * What validateLicenseTerm reports for one term. Every check but the disjointness + * one reads the term as already canonical; that one folds what it compares, so it + * is correct on a term as authored too. + */ export interface TermVerdict { violation: RuleViolation | null; warnings: RuleWarning[]; From 73dd467be6e3b960368b48cc7d0fc91c0390c72e Mon Sep 17 00:00:00 2001 From: legendko Date: Wed, 2 Sep 2026 15:07:57 +0200 Subject: [PATCH 9/9] fix(proto): name the third ingest-tier reject everywhere it is enumerated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ingest tier's hard rejects are enumerated in several places, and adding one updated two of them. The contract's own all-or-nothing paragraph still described that tier as refusing an unregistered unit or metric, sixteen lines below the enumeration that had been corrected — and that paragraph is what a third-party implementor reads to know what a push costs. The design record and the feed-format page carried the same omission. All of them now name it. The guard added with the rule does not catch this class: it asserts that a clause is PRESENT, not that an enumeration is COMPLETE. The contract also said, and still says, that a token cannot be both permitted and prohibited on the same axis — while both rules that enforce it are scoped to a single restriction. Split the two lists across two restrictions of one kind and neither sees a collision; the one-per-kind rule refuses that shape instead. The sentence predates this work but sat beside a new one about a deployment without the wire tier, which together read as a promise neither rule keeps. The axis property is now stated as what it is: held by two rules together, one of which is a wire-tier rule. The new sentence is scoped to the reading it really adds. Whether the ingest tier should aggregate per kind, and so hold the axis property alone, is left open deliberately — deciding it would strengthen the rule beyond moving a check to the values it must read. Both changelogs said ten restriction tokens have more than one spelling. Ten aliases resolve to eight distinct tokens: `modify` and `commercial_entity` each answer to two. --- docs/design-history.md | 11 ++++++++++- gen/descriptor.binpb | Bin 611262 -> 611881 bytes proto/CHANGELOG.md | 6 +++--- proto/ramp/v1/ramp.proto | 17 +++++++++++++---- .../content/docs/protocol/jsonl-ingestion.mdx | 2 +- .../src/content/docs/reference/changelog.mdx | 6 +++--- .../src/content/docs/reference/proto-ramp.mdx | 2 +- 7 files changed, 31 insertions(+), 13 deletions(-) diff --git a/docs/design-history.md b/docs/design-history.md index 8c1a47ef..f8be63d2 100644 --- a/docs/design-history.md +++ b/docs/design-history.md @@ -1070,7 +1070,8 @@ A pushed entry passes two tiers at the Exchange. The wire tier is protovalidate: applied to the request exactly as received. The ingest tier runs afterwards, over the canonicalised terms: restriction tokens are folded and alias-resolved to their registered form, then a bare `Pricing.unit` or `Quota.metric` that is not a registered -token is rejected, while an unregistered restriction token and an `OBLIGATION_KIND_OTHER` +token is rejected, as is a restriction whose permitted and prohibited lists name one +token once folded, while an unregistered restriction token and an `OBLIGATION_KIND_OTHER` obligation without detail are accepted and reported in `PushResourcesResponse.warnings`. The first tier was always in the contract. The second lived only inside the reference Exchange, so a publisher learned what it would say by pushing and reading the refusal. @@ -1170,6 +1171,14 @@ ground — the server binding defaults to validation off, so on a mount that nev for the wire tier the ingest check is the only one a pushed term meets, and a rule that stayed silent because "the boundary already caught it" would catch nothing there. +One boundary of that argument is worth stating, because the contract's own sentence +invites the stronger reading. Both disjointness rules are scoped to a single +`Restriction`; what makes one restriction the whole of an axis is +`license_term.one_restriction_per_kind`, which is a wire-tier rule. Split the two lists +across two restrictions of one kind and neither disjointness rule sees a collision. So a +mount without the wire tier gains the second READING of the rule, not the per-axis +property — the property has always been held jointly, and this change does not move it. + ## A catalog client is its own constructor The usage-report decision recorded above settled a rule that generalises: a client's diff --git a/gen/descriptor.binpb b/gen/descriptor.binpb index a7d7b7dee27a03113fa4119bc5a4e661148bccf1..c894a8ca52bf2b4e2391d924d2d900738ce511f3 100644 GIT binary patch delta 34222 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 diff --git a/proto/CHANGELOG.md b/proto/CHANGELOG.md index 1bbc1f15..f02f9e45 100644 --- a/proto/CHANGELOG.md +++ b/proto/CHANGELOG.md @@ -6,9 +6,9 @@ the canonicalised tokens (`restriction.canonical_disjoint`; SDK behaviour change plus a comment clarification, no wire change).** `restriction.permitted_prohibited_disjoint` runs at the wire tier, over the request exactly as received, so it compares token -SPELLINGS. Ten restriction tokens have more than one accepted spelling — `scrape` -is a registered alias of `crawl`, `adapt` and `derivative` both mean `modify`, -`personal` means `individual` — and every axis also folds ASCII case. A term naming +SPELLINGS. Ten registered aliases resolve to eight distinct restriction tokens — +`scrape` is a registered alias of `crawl`, `adapt` and `derivative` both mean +`modify`, `personal` means `individual` — and every axis also folds ASCII case. A term naming one spelling under `permitted` and another under `prohibited` therefore passed the boundary check and became, once the ingest tier folded it, a stored term with the same token in both lists. diff --git a/proto/ramp/v1/ramp.proto b/proto/ramp/v1/ramp.proto index 749be1ee..4208671e 100644 --- a/proto/ramp/v1/ramp.proto +++ b/proto/ramp/v1/ramp.proto @@ -1223,6 +1223,13 @@ message License { message Restriction { // A token cannot be both permitted and prohibited on the same axis. // + // Read that as a property of the AXIS, held by two rules together: this one is + // scoped to a single Restriction, and one restriction per kind — the rule on + // LicenseTerm below — is what makes one Restriction the whole of an axis. Split + // the two lists across two restrictions of the same kind and neither disjointness + // rule sees a collision; that shape is refused by the one-per-kind rule instead, + // at the wire tier. + // // This rule compares the tokens AS WRITTEN, because it runs at the wire tier, // over the request exactly as received. Several tokens have more than one // accepted spelling — a registered alias beside its canonical form, and either @@ -2207,16 +2214,18 @@ enum IngestionSource { // // Disjointness is the one property BOTH tiers assert, and deliberately so: the // wire tier reads the tokens as written and the ingest tier reads what the fold -// produced, so a term the boundary clears can still be refused here. A -// deployment that does not mount the wire tier still gets the second. +// produced, so a term the boundary clears can still be refused here. A deployment +// that does not mount the wire tier still gets the second of those two readings — +// it does not thereby get the one-per-kind rule that makes a restriction the whole +// of its axis, which stays a wire-tier rule. // // The SDK ships both tiers as a publisher-side pre-check; the Exchange's own // run of them is the deciding one. // // A push is ALL-OR-NOTHING, at both tiers. A hard rejection anywhere in the // submission — an envelope or term rule at the wire tier, an unregistered bare -// unit or metric at the ingest tier — refuses the entire submission and persists -// nothing; the publisher fixes and resubmits the whole set. The refusal names +// unit or metric or a restriction that is not disjoint once folded at the ingest +// tier — refuses the entire submission and persists nothing; the publisher fixes and resubmits the whole set. The refusal names // the entries that failed and why, and that per-entry detail is REPORTING, never // partial acceptance: no entry of a refused submission is stored. Warnings do // not refuse anything. diff --git a/website/src/content/docs/protocol/jsonl-ingestion.mdx b/website/src/content/docs/protocol/jsonl-ingestion.mdx index a01853ae..0acba815 100644 --- a/website/src/content/docs/protocol/jsonl-ingestion.mdx +++ b/website/src/content/docs/protocol/jsonl-ingestion.mdx @@ -87,7 +87,7 @@ The check is advice about what the Exchange will say, not a substitute for it: t ## All-or-nothing -`PushResources` is atomic, at **both** tiers: if any entry fails a hard rule — the envelope rules above, a term's shape or cross-field rule, or a bare unregistered `pricing.unit` or `quotas[].metric` — the **whole submission is rejected** (`InvalidArgument`, naming the offending entries) and **nothing is persisted**. The publisher fixes and resubmits the full set. What the two tiers change is *when* the refusal happens, not what survives it: the wire tier refuses at the RPC boundary, before any entry is classified, while the ingest tier refuses after canonicalising the terms. Either way no entry of a refused submission is stored — naming the entries that failed is how the refusal tells you what to fix, not a sign that the rest went through. Membership / lint issues are returned in `PushResourcesResponse.warnings[]` and do not block. +`PushResources` is atomic, at **both** tiers: if any entry fails a hard rule — the envelope rules above, a term's shape or cross-field rule, a bare unregistered `pricing.unit` or `quotas[].metric`, or a restriction that is not disjoint once its tokens are canonicalised — the **whole submission is rejected** (`InvalidArgument`, naming the offending entries) and **nothing is persisted**. The publisher fixes and resubmits the full set. What the two tiers change is *when* the refusal happens, not what survives it: the wire tier refuses at the RPC boundary, before any entry is classified, while the ingest tier refuses after canonicalising the terms. Either way no entry of a refused submission is stored — naming the entries that failed is how the refusal tells you what to fix, not a sign that the rest went through. Membership / lint issues are returned in `PushResourcesResponse.warnings[]` and do not block. ## Extension profiles in the feed diff --git a/website/src/content/docs/reference/changelog.mdx b/website/src/content/docs/reference/changelog.mdx index 08fc4aa9..a2f3c209 100644 --- a/website/src/content/docs/reference/changelog.mdx +++ b/website/src/content/docs/reference/changelog.mdx @@ -12,9 +12,9 @@ and protocol history, see [`proto/CHANGELOG.md`](https://github.com/RAMP-Protoco the canonicalised tokens (`restriction.canonical_disjoint`; SDK behaviour change plus a comment clarification, no wire change).** `restriction.permitted_prohibited_disjoint` runs at the wire tier, over the request exactly as received, so it compares token -SPELLINGS. Ten restriction tokens have more than one accepted spelling — `scrape` -is a registered alias of `crawl`, `adapt` and `derivative` both mean `modify`, -`personal` means `individual` — and every axis also folds ASCII case. A term naming +SPELLINGS. Ten registered aliases resolve to eight distinct restriction tokens — +`scrape` is a registered alias of `crawl`, `adapt` and `derivative` both mean +`modify`, `personal` means `individual` — and every axis also folds ASCII case. A term naming one spelling under `permitted` and another under `prohibited` therefore passed the boundary check and became, once the ingest tier folded it, a stored term with the same token in both lists. diff --git a/website/src/content/docs/reference/proto-ramp.mdx b/website/src/content/docs/reference/proto-ramp.mdx index 92a20efa..5f306e11 100644 --- a/website/src/content/docs/reference/proto-ramp.mdx +++ b/website/src/content/docs/reference/proto-ramp.mdx @@ -424,7 +424,7 @@ code is the cross-field CEL rule `id:` that enforces the rule (e.g. `license_ter - `pricing` MUST be present on **every** term, any semantics — absent Pricing is a validation error. `model = FREE` must be stated explicitly — absent Pricing is not free. - `semantics` MUST be set — `TERM_SEMANTICS_UNSPECIFIED` is rejected (the field's `enum.not_in:[0]` rule). - `REFERENCE_ONLY` requires `license.uri` to be non-empty (`license_term.reference_only.requires_uri`); a `License` with a `uri` requires a `uri_digest` (`license.digest_required_with_uri`). -- At most one `Restriction` per `kind` (`license_term.one_restriction_per_kind`); a token cannot be both permitted and prohibited (`restriction.permitted_prohibited_disjoint`). That rule compares the tokens **as written**, because it runs over the request as received — so two accepted spellings of one token (an alias beside its registered form, or either in another ASCII case) clear it and collide once folded. The ingest tier asserts the same property over the canonicalised tokens as `restriction.canonical_disjoint`; both refuse the term, and a term that fails both is reported by both. +- At most one `Restriction` per `kind` (`license_term.one_restriction_per_kind`); a token cannot be both permitted and prohibited (`restriction.permitted_prohibited_disjoint`). That rule compares the tokens **as written**, because it runs over the request as received — so two accepted spellings of one token (an alias beside its registered form, or either in another ASCII case) clear it and collide once folded. The ingest tier asserts the same property over the canonicalised tokens as `restriction.canonical_disjoint`; both refuse the term, and a term that fails both is reported by both. Both are scoped to one `Restriction`: what makes one restriction the whole of an axis is `license_term.one_restriction_per_kind`, so the per-axis reading is held by the two rules together. - `quotas` and `obligations` each carry at most 64 items, the bound every per-message list in the contract carries when no rule walks it more than once. They bound what one term can carry, not the cost of checking it. `restrictions` carries at most **8**, and like the other two this bounds the document, not the cost of checking it. Only one restriction per axis is valid and `Restriction.kind` is defined-only, so four is the longest conformant list and eight leaves room for an axis this version does not have. The tighter bound is deliberate for a second reason: this is the one list a message rule walks against itself, so the cap is also the threshold of the size test the one-per-kind rule carries, and a conformance guard holds the two equal. The disjointness rule on each element is quadratic only in that element's two token lists, both capped at 64, so its cost is bounded per restriction and linear across the list. - Unknown tokens in `restrictions[].permitted` / `prohibited` produce `PushResourcesResponse.warnings[]` but do NOT cause hard rejection (ingest-time, not CEL). Tokens are canonicalised first — RFC 8259 whitespace trimmed, ASCII case folded, and the aliases authored beside the tokens resolved (`train-ai` → `ai-train`, `tdm` → `text-and-data-mining`, `personal` → `individual`, …) — so `Generative-AI` is the registered `ai-input`, not an unknown token. - A bare (non-namespaced) `pricing.unit` or `quotas[].metric` that is not a registered token IS a hard rejection at ingest (registry membership, not CEL: a rule that re-listed the vocabulary would drift from it). A `vendor:token` value bypasses membership on every axis.