Skip to content

Commit c5beb5c

Browse files
committed
refactor(offerkind): declarative per-type integrity-profile registry
internal/offerkind is the single source of truth for what each ServiceOffer/Request type means — render/discovery shapes, price slots, capability flags, and a declarative IntegrityProfile per type. Routes x402 storefront copy + bazaar, the OpenAPI path shape, and the verifier's 402 integrity-metadata dispatch through it; centralizes price-slot detection in monetizeapi.Price.RawAndSlot(); adds a buy-side owner-pin nudge and a CRD-enum drift guard. Behavior-preserving.
1 parent 271a9d9 commit c5beb5c

11 files changed

Lines changed: 441 additions & 51 deletions

File tree

cmd/obol/dataset.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434

3535
"github.com/ObolNetwork/obol-stack/internal/config"
3636
"github.com/ObolNetwork/obol-stack/internal/dataset"
37+
"github.com/ObolNetwork/obol-stack/internal/offerkind"
3738
x402 "github.com/ObolNetwork/obol-stack/internal/x402"
3839
"github.com/urfave/cli/v3"
3940
x402types "github.com/x402-foundation/x402/go/types"
@@ -448,6 +449,15 @@ func buyDatasetCommand(cfg *config.Config) *cli.Command {
448449
}
449450
out = fmt.Sprintf("%s-v%d.jsonl", id, v)
450451
}
452+
// A dataset's integrity profile mandates signed-log content
453+
// integrity, but the signed chain only proves it is self-consistent
454+
// — not WHO signed it. Without an --owner pin, a seller that swapped
455+
// its signing key still verifies, so surface the gap (the profile
456+
// declares this check required for the guarantee to hold).
457+
if offerkind.Resolve("dataset").Integrity.Content == offerkind.ContentSignedVersionLog &&
458+
strings.TrimSpace(cmd.String("owner")) == "" {
459+
u.Warnf("No --owner pin: the signed version log proves chain consistency but not the signer's identity. Pass --owner 0x<seller> for full content integrity.")
460+
}
451461
u.Infof("Fetching %s (version %v) → %s", id, orHead(cmd.Int("version")), out)
452462
res, err := dataset.Fetch(ctx, dataset.FetchOptions{
453463
BaseURL: base, ID: id, Version: cmd.Int("version"),

internal/monetizeapi/types.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,27 @@ type ServiceOfferPriceTable struct {
402402
PerMB string `json:"perMB,omitempty"`
403403
}
404404

405+
// RawAndSlot returns the raw decimal price string and which price slot is
406+
// populated, in precedence order (perRequest > perMTok > perHour > perMB). It
407+
// is the single source of truth for price-slot detection, shared by the
408+
// catalog renderer (serviceoffercontroller) and the verifier's effectivePrice
409+
// (internal/x402). PerEpoch is intentionally not surfaced — no caller enforces
410+
// it yet, so including it would change behavior. An empty slot means no price.
411+
func (p ServiceOfferPriceTable) RawAndSlot() (raw, slot string) {
412+
switch {
413+
case p.PerRequest != "":
414+
return p.PerRequest, "perRequest"
415+
case p.PerMTok != "":
416+
return p.PerMTok, "perMTok"
417+
case p.PerHour != "":
418+
return p.PerHour, "perHour"
419+
case p.PerMB != "":
420+
return p.PerMB, "perMB"
421+
default:
422+
return "", ""
423+
}
424+
}
425+
405426
type ServiceOfferRegistration struct {
406427
// If true, register on ERC-8004 after routing is live.
407428
// +kubebuilder:default=false

internal/offerkind/offerkind.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Package offerkind is the single source of truth for what a ServiceOffer /
2+
// ServiceRequest "type" means: how it renders (storefront copy, bazaar and
3+
// OpenAPI discovery shapes), which price slot it uses, and — crucially —
4+
// which integrity checks apply to it.
5+
//
6+
// It replaces the type-collapse logic that was previously duplicated across
7+
// packages: internal/x402 (normalizeOfferType) and
8+
// internal/serviceoffercontroller (fallbackOfferType, openAPIPathsForOffer,
9+
// offerPriceRawAndUnit). Each of those re-implemented "given a spec.type,
10+
// what shape is this" with subtly different defaults. Centralizing here means
11+
// adding a 7th service type is a single table entry instead of an 8-file sweep.
12+
//
13+
// Design: this is a ZERO-DEPENDENCY leaf package (stdlib only). Call sites pass
14+
// the raw spec.type string (offer.Spec.Type), never a CRD struct, so both x402
15+
// and the controller can import it with no risk of an import cycle. The data
16+
// table mirrors how internal/bounty/registry.go makes task types data-driven.
17+
//
18+
// Integrity is kept strictly separate from pricing/upstream/rendering: the
19+
// IntegrityProfile declares only authenticity/identity/scope obligations, not
20+
// "is the price valid" (that is a pricing concern, not an integrity one).
21+
package offerkind
22+
23+
// PaymentClass is the payment-integrity obligation for a type. Payment
24+
// verification itself is uniform x402 "exact"; the only real axis is whether a
25+
// payment proof is required at all (it always is, today) — method (card vs
26+
// crypto) is handled separately by the verifier, not here.
27+
type PaymentClass string
28+
29+
const (
30+
PaymentX402Exact PaymentClass = "x402-exact"
31+
PaymentNone PaymentClass = "none"
32+
)
33+
34+
// ContentClass is the data-authenticity obligation: how a buyer proves the
35+
// bytes it received are the bytes the seller committed to.
36+
type ContentClass string
37+
38+
const (
39+
ContentNone ContentClass = "none"
40+
ContentSignedVersionLog ContentClass = "signed-version-log" // dataset / fine-tuning: owner-signed secp256k1 hash-chain (internal/dataset)
41+
ContentBundleSHA256 ContentClass = "bundle-sha256" // skill: controller-validated bundle hash+size
42+
)
43+
44+
// IdentityClass is the caller-identity / membership obligation.
45+
type IdentityClass string
46+
47+
const (
48+
IdentityNone IdentityClass = "none"
49+
IdentityGroupAuth IdentityClass = "groupauth" // membership-gated via internal/research/groupauth
50+
)
51+
52+
// ScopeClass is the entitlement-scope obligation layered on top of membership.
53+
type ScopeClass string
54+
55+
const (
56+
ScopeNone ScopeClass = "none"
57+
ScopeVersionEntitlement ScopeClass = "version-entitlement" // dataset: token entitled only up to a paid version
58+
)
59+
60+
// IntegrityProfile declares the integrity checks a service type requires.
61+
// Consumed by the verifier and controller (to enforce) and by the buy-side
62+
// (to know what to verify before trusting a response).
63+
type IntegrityProfile struct {
64+
Payment PaymentClass
65+
Content ContentClass
66+
Identity IdentityClass
67+
Scope ScopeClass
68+
}
69+
70+
// Kind is the resolved capability + integrity descriptor for one spec.type.
71+
type Kind struct {
72+
// Type is the canonical spec.type string this entry represents ("" for the
73+
// unset default). Resolve(unknown) returns the http Kind, so its Type is
74+
// "http", not the unknown input.
75+
Type string
76+
77+
// PaymentCopy collapses the type into the three storefront-copy branches
78+
// ("inference" | "agent" | "http"). Replaces x402.normalizeOfferType.
79+
PaymentCopy string
80+
// BazaarShape is the x402 bazaar discovery shape ("chat" | "generic").
81+
BazaarShape string
82+
// OpenAPIShape is the controller's OpenAPI path shape
83+
// ("chat" | "multipart" | "generic").
84+
OpenAPIShape string
85+
// CatalogType is the display/catalog label (fallbackOfferType): the type
86+
// string, or "http" when unset.
87+
CatalogType string
88+
89+
// PriceUnits lists the price slot(s) a type conventionally uses, in
90+
// precedence order. Informational/validation; the live price reader still
91+
// keys off whichever Price.* field is populated.
92+
PriceUnits []string
93+
94+
// SemanticInference mirrors monetizeapi.(*ServiceOffer).IsInference(): true
95+
// for "" and "inference". Drives model-reconciliation gating and the
96+
// OpenAPI empty-type edge (IsInference("")==true → chat shape).
97+
SemanticInference bool
98+
// ResolvesAgentRef: upstream comes from an Agent CR status, not spec.
99+
ResolvesAgentRef bool
100+
// RendersBundle: controller renders a skill bundle server.
101+
RendersBundle bool
102+
// OneShotPurchase: price is a total (e.g. perMB × size), not a rate.
103+
OneShotPurchase bool
104+
105+
Integrity IntegrityProfile
106+
}
107+
108+
// paymentOnly is the integrity profile for inference/http/agent: an x402
109+
// payment proof, nothing else.
110+
var paymentOnly = IntegrityProfile{
111+
Payment: PaymentX402Exact,
112+
Content: ContentNone,
113+
Identity: IdentityNone,
114+
Scope: ScopeNone,
115+
}
116+
117+
// kinds is the table. Keys are spec.type strings; "" is the unset default and
118+
// is deliberately distinct from "inference" because the legacy code treats the
119+
// empty type inconsistently — http-presentational (normalizeOfferType,
120+
// fallbackOfferType) yet inference-semantic (IsInference, openAPIPathsForOffer).
121+
// Encoding both faithfully keeps this refactor behavior-preserving.
122+
var kinds = map[string]Kind{
123+
"": {
124+
Type: "", PaymentCopy: "http", BazaarShape: "generic", OpenAPIShape: "chat",
125+
CatalogType: "http", PriceUnits: []string{"perRequest", "perMTok"},
126+
SemanticInference: true, Integrity: paymentOnly,
127+
},
128+
"inference": {
129+
Type: "inference", PaymentCopy: "inference", BazaarShape: "chat", OpenAPIShape: "chat",
130+
CatalogType: "inference", PriceUnits: []string{"perRequest", "perMTok"},
131+
SemanticInference: true, Integrity: paymentOnly,
132+
},
133+
"http": {
134+
Type: "http", PaymentCopy: "http", BazaarShape: "generic", OpenAPIShape: "generic",
135+
CatalogType: "http", PriceUnits: []string{"perRequest"},
136+
Integrity: paymentOnly,
137+
},
138+
"agent": {
139+
Type: "agent", PaymentCopy: "agent", BazaarShape: "chat", OpenAPIShape: "chat",
140+
CatalogType: "agent", PriceUnits: []string{"perRequest", "perMTok"},
141+
ResolvesAgentRef: true, Integrity: paymentOnly,
142+
},
143+
"dataset": {
144+
Type: "dataset", PaymentCopy: "http", BazaarShape: "generic", OpenAPIShape: "generic",
145+
CatalogType: "dataset", PriceUnits: []string{"perMB"},
146+
OneShotPurchase: true,
147+
Integrity: IntegrityProfile{
148+
Payment: PaymentX402Exact,
149+
Content: ContentSignedVersionLog,
150+
Identity: IdentityGroupAuth,
151+
Scope: ScopeVersionEntitlement,
152+
},
153+
},
154+
"fine-tuning": {
155+
Type: "fine-tuning", PaymentCopy: "http", BazaarShape: "generic", OpenAPIShape: "multipart",
156+
CatalogType: "fine-tuning", PriceUnits: []string{"perHour"},
157+
Integrity: IntegrityProfile{
158+
Payment: PaymentX402Exact,
159+
Content: ContentSignedVersionLog, // reuses the dataset signed-log primitives
160+
},
161+
},
162+
"skill": {
163+
Type: "skill", PaymentCopy: "http", BazaarShape: "generic", OpenAPIShape: "generic",
164+
CatalogType: "skill", PriceUnits: []string{"perRequest"},
165+
RendersBundle: true,
166+
Integrity: IntegrityProfile{
167+
Payment: PaymentX402Exact,
168+
Content: ContentBundleSHA256,
169+
},
170+
},
171+
}
172+
173+
// Resolve returns the Kind for a spec.type string. An unrecognized non-empty
174+
// type falls back to the http Kind (payment-only, generic shapes) — matching
175+
// the legacy normalizeOfferType / openAPIPathsForOffer defaults for unknown
176+
// types. The empty string resolves to its own dedicated entry.
177+
func Resolve(t string) Kind {
178+
if k, ok := kinds[t]; ok {
179+
return k
180+
}
181+
return kinds["http"]
182+
}
183+
184+
// Types returns the canonical service-type strings the registry knows
185+
// (excluding the "" default), for drift checks against the CRD enum.
186+
func Types() []string {
187+
out := make([]string, 0, len(kinds))
188+
for k := range kinds {
189+
if k == "" {
190+
continue
191+
}
192+
out = append(out, k)
193+
}
194+
return out
195+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package offerkind
2+
3+
import (
4+
"os"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// TestResolve_CoversCRDEnum is the drift guard: every value in the
10+
// ServiceOffer.spec.type CRD enum (monetizeapi/types.go) must have a Kind.
11+
// Adding a 7th type to the enum without a table entry fails here, the same way
12+
// TestOpenClawVersionConsistency catches version drift.
13+
func TestResolve_CoversCRDEnum(t *testing.T) {
14+
src, err := os.ReadFile("../monetizeapi/types.go")
15+
if err != nil {
16+
t.Fatalf("read types.go: %v", err)
17+
}
18+
var enumLine string
19+
for _, ln := range strings.Split(string(src), "\n") {
20+
if strings.Contains(ln, "+kubebuilder:validation:Enum=") && strings.Contains(ln, "inference") {
21+
enumLine = ln
22+
break
23+
}
24+
}
25+
if enumLine == "" {
26+
t.Fatal("could not find the ServiceOfferSpec.Type enum in monetizeapi/types.go")
27+
}
28+
_, rhs, _ := strings.Cut(enumLine, "Enum=")
29+
values := strings.Split(strings.TrimSpace(rhs), ";")
30+
if len(values) < 6 {
31+
t.Fatalf("expected >=6 enum values, got %d from %q", len(values), rhs)
32+
}
33+
for _, v := range values {
34+
v = strings.TrimSpace(v)
35+
k := Resolve(v)
36+
if k.Type != v {
37+
t.Errorf("CRD enum value %q has no offerkind Kind (Resolve→Type %q); add it to kinds", v, k.Type)
38+
}
39+
if k.Integrity.Payment == "" {
40+
t.Errorf("type %q has an empty Payment class", v)
41+
}
42+
}
43+
}
44+
45+
// TestResolve_LegacyCollapseValues locks the exact collapse values the legacy
46+
// normalizeOfferType / openAPIPathsForOffer produced, so the rewire stays
47+
// behavior-preserving — including the deliberate "" split (generic bazaar but
48+
// chat openapi, because IsInference("")==true).
49+
func TestResolve_LegacyCollapseValues(t *testing.T) {
50+
cases := []struct{ typ, paymentCopy, bazaar, openapi string }{
51+
{"", "http", "generic", "chat"},
52+
{"inference", "inference", "chat", "chat"},
53+
{"http", "http", "generic", "generic"},
54+
{"agent", "agent", "chat", "chat"},
55+
{"dataset", "http", "generic", "generic"},
56+
{"fine-tuning", "http", "generic", "multipart"},
57+
{"skill", "http", "generic", "generic"},
58+
{"totally-unknown", "http", "generic", "generic"},
59+
}
60+
for _, c := range cases {
61+
k := Resolve(c.typ)
62+
if k.PaymentCopy != c.paymentCopy {
63+
t.Errorf("Resolve(%q).PaymentCopy = %q, want %q", c.typ, k.PaymentCopy, c.paymentCopy)
64+
}
65+
if k.BazaarShape != c.bazaar {
66+
t.Errorf("Resolve(%q).BazaarShape = %q, want %q", c.typ, k.BazaarShape, c.bazaar)
67+
}
68+
if k.OpenAPIShape != c.openapi {
69+
t.Errorf("Resolve(%q).OpenAPIShape = %q, want %q", c.typ, k.OpenAPIShape, c.openapi)
70+
}
71+
}
72+
}
73+
74+
func TestResolve_IntegrityProfiles(t *testing.T) {
75+
if got := Resolve("inference").Integrity; got != paymentOnly {
76+
t.Errorf("inference integrity = %+v, want payment-only", got)
77+
}
78+
if got := Resolve("http").Integrity; got != paymentOnly {
79+
t.Errorf("http integrity = %+v, want payment-only", got)
80+
}
81+
ds := Resolve("dataset").Integrity
82+
if ds.Content != ContentSignedVersionLog || ds.Scope != ScopeVersionEntitlement || ds.Identity != IdentityGroupAuth {
83+
t.Errorf("dataset integrity = %+v, want signed-log + version-entitlement + groupauth", ds)
84+
}
85+
if got := Resolve("skill").Integrity.Content; got != ContentBundleSHA256 {
86+
t.Errorf("skill content = %q, want bundle-sha256", got)
87+
}
88+
if got := Resolve("fine-tuning").Integrity.Content; got != ContentSignedVersionLog {
89+
t.Errorf("fine-tuning content = %q, want signed-version-log", got)
90+
}
91+
}
92+
93+
func TestResolve_SemanticInference(t *testing.T) {
94+
for _, typ := range []string{"", "inference"} {
95+
if !Resolve(typ).SemanticInference {
96+
t.Errorf("Resolve(%q).SemanticInference = false, want true (matches IsInference)", typ)
97+
}
98+
}
99+
for _, typ := range []string{"http", "agent", "dataset", "fine-tuning", "skill"} {
100+
if Resolve(typ).SemanticInference {
101+
t.Errorf("Resolve(%q).SemanticInference = true, want false", typ)
102+
}
103+
}
104+
}
105+
106+
func TestResolve_CapabilityFlags(t *testing.T) {
107+
if !Resolve("agent").ResolvesAgentRef {
108+
t.Error("agent should resolve an Agent ref")
109+
}
110+
if !Resolve("skill").RendersBundle {
111+
t.Error("skill should render a bundle")
112+
}
113+
if !Resolve("dataset").OneShotPurchase {
114+
t.Error("dataset is a one-shot purchase (perMB→total)")
115+
}
116+
}

internal/serviceoffercontroller/openapi.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"time"
88

99
"github.com/ObolNetwork/obol-stack/internal/monetizeapi"
10+
"github.com/ObolNetwork/obol-stack/internal/offerkind"
1011
"github.com/ObolNetwork/obol-stack/internal/schemas"
1112
)
1213

@@ -201,8 +202,8 @@ func openAPIPathsForOffer(offer *monetizeapi.ServiceOffer) map[string]map[string
201202
if offer == nil {
202203
return nil
203204
}
204-
switch {
205-
case offer.IsInference(), offer.IsAgent():
205+
switch offerkind.Resolve(offer.Spec.Type).OpenAPIShape {
206+
case "chat":
206207
return map[string]map[string]any{
207208
"/v1/chat/completions": {
208209
"post": openAPIOperation(offer, openAPIOperationOptions{
@@ -219,7 +220,7 @@ func openAPIPathsForOffer(offer *monetizeapi.ServiceOffer) map[string]map[string
219220
}),
220221
},
221222
}
222-
case strings.EqualFold(offer.Spec.Type, "fine-tuning"):
223+
case "multipart":
223224
return map[string]map[string]any{
224225
"": {
225226
"post": openAPIOperation(offer, openAPIOperationOptions{

0 commit comments

Comments
 (0)