diff --git a/internal/schemas/service-catalog.schema.json b/internal/schemas/service-catalog.schema.json index 58dbc7c4..fb02742a 100644 --- a/internal/schemas/service-catalog.schema.json +++ b/internal/schemas/service-catalog.schema.json @@ -78,8 +78,7 @@ "payTo", "network", "description", - "isDemo", - "available" + "isDemo" ], "properties": { "name": { @@ -154,14 +153,10 @@ "registrationPending": { "type": "boolean" }, - "available": { - "type": "boolean", - "description": "False during a drain window. Catalog consumers should treat unset as true for backwards compatibility." - }, "drainEndsAt": { "type": "string", "format": "date-time", - "description": "RFC3339 timestamp at which the offer's HTTPRoute will be torn down. Set only when available=false." + "description": "RFC3339 timestamp at which the offer's HTTPRoute will be torn down. Set only when the offer is draining. Catalog consumers should detect drain via the presence of this field." } } } diff --git a/internal/schemas/service_catalog.go b/internal/schemas/service_catalog.go index eb8bba78..6f839135 100644 --- a/internal/schemas/service_catalog.go +++ b/internal/schemas/service_catalog.go @@ -40,19 +40,12 @@ type ServiceCatalogEntry struct { // ERC-8004 discovery via the chain still resolves to the prior state. RegistrationPending bool `json:"registrationPending,omitempty"` - // Available is false when the offer is in its drain window. Buyers - // can still complete in-flight payments until DrainEndsAt, but - // discovery surfaces should advertise the wind-down so external - // observers can react. When false, DrainEndsAt is set to the RFC3339 - // timestamp at which the HTTPRoute will be torn down. Catalog - // consumers should treat unset Available (the default-true field) as - // "available" for backwards compatibility — the field is only written - // false during drain. - Available bool `json:"available"` - // DrainEndsAt is the RFC3339 timestamp at which the offer's - // HTTPRoute will be removed. Set only when Available=false. Buyers - // SHOULD migrate to alternative providers before this time. + // HTTPRoute will be removed. Set ONLY when the offer is draining. + // Consumers detect a drain window with `if (entry.drainEndsAt)`: + // active offers serialize without this field, so the schema stays + // purely additive vs. pre-drain catalogs. Buyers SHOULD migrate to + // alternative providers before this time. DrainEndsAt string `json:"drainEndsAt,omitempty"` } diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index 0f65fa89..64eac386 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -753,8 +753,6 @@ func serviceDefWithDrain(offer *monetizeapi.ServiceOffer, svc erc8004.ServiceDef if offer == nil || !offer.IsDraining() || offer.DrainExpired(time.Now()) { return svc } - available := false - svc.Available = &available svc.DrainEndsAt = offer.DrainEndsAt().UTC().Format(time.RFC3339) return svc } @@ -827,16 +825,16 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin } lines = append(lines, "## Services", "") - lines = append(lines, "| Service | Type | Model | Price | Available | Endpoint |") - lines = append(lines, "|---------|------|-------|-------|-----------|----------|") + lines = append(lines, "| Service | Type | Model | Price | Status | Endpoint |") + lines = append(lines, "|---------|------|-------|-------|--------|----------|") for _, offer := range ready { modelName := offer.Spec.Model.Name if modelName == "" { modelName = "—" } - availability := "yes" + status := "—" if offer.IsDraining() { - availability = fmt.Sprintf("draining (ends %s)", offer.DrainEndsAt().UTC().Format(time.RFC3339)) + status = fmt.Sprintf("draining · ends `%s`", offer.DrainEndsAt().UTC().Format(time.RFC3339)) } lines = append(lines, fmt.Sprintf( "| [%s](#%s) | %s | %s | %s | %s | `%s%s` |", @@ -845,7 +843,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin fallbackOfferType(offer), modelName, describeOfferPrice(offer), - availability, + status, baseURL, offer.EffectivePath(), )) @@ -863,10 +861,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin lines = append(lines, fmt.Sprintf("- **Pay To**: `%s`", firstNonEmpty(offer.Spec.Payment.PayTo, "—"))) lines = append(lines, fmt.Sprintf("- **Network**: %s", firstNonEmpty(offer.Spec.Payment.Network, "—"))) if offer.IsDraining() { - lines = append(lines, "- **Available**: false (draining)") lines = append(lines, fmt.Sprintf("- **Drain ends at**: %s", offer.DrainEndsAt().UTC().Format(time.RFC3339))) - } else { - lines = append(lines, "- **Available**: true") } description := offer.Spec.Registration.Description if description == "" { @@ -979,7 +974,6 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) modelName = offer.Status.AgentResolution.Model } - available := !offer.IsDraining() drainEndsAt := "" if offer.IsDraining() { drainEndsAt = offer.DrainEndsAt().UTC().Format(time.RFC3339) @@ -997,7 +991,6 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) Description: desc, IsDemo: offer.Namespace == "demo", RegistrationPending: offerAwaitingRegistration(offer), - Available: available, DrainEndsAt: drainEndsAt, } diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index 286e1763..9f82c743 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -440,11 +440,8 @@ func TestBuildRegistrationServices_IncludesDrainMetadata(t *testing.T) { t.Fatalf("services = %+v, want web + A2A", services) } for _, svc := range services { - if svc.Available == nil { - t.Fatalf("%s missing available=false drain marker: %+v", svc.Name, svc) - } - if *svc.Available { - t.Fatalf("%s available = true, want false during drain: %+v", svc.Name, svc) + if svc.Available != nil { + t.Fatalf("%s.Available = %v, want nil (drain is signalled via DrainEndsAt only): %+v", svc.Name, *svc.Available, svc) } if _, err := time.Parse(time.RFC3339, svc.DrainEndsAt); err != nil { t.Fatalf("%s drainEndsAt = %q is not RFC3339: %v", svc.Name, svc.DrainEndsAt, err) @@ -474,8 +471,8 @@ func TestBuildIdentityRegistrationServices_IncludesDrainMetadata(t *testing.T) { t.Fatalf("services = %+v, want web + MCP", services) } for _, svc := range services { - if svc.Available == nil || *svc.Available { - t.Fatalf("%s missing available=false drain marker: %+v", svc.Name, svc) + if svc.Available != nil { + t.Fatalf("%s.Available = %v, want nil (drain is signalled via DrainEndsAt only): %+v", svc.Name, *svc.Available, svc) } if _, err := time.Parse(time.RFC3339, svc.DrainEndsAt); err != nil { t.Fatalf("%s drainEndsAt = %q is not RFC3339: %v", svc.Name, svc.DrainEndsAt, err) @@ -648,6 +645,64 @@ func TestBuildSkillCatalogMarkdown(t *testing.T) { } } +// TestBuildSkillCatalogMarkdown_DrainAdditiveDetail locks in the +// pure-additive markdown surface: active offers must NOT emit a +// `- **Available**:` detail bullet (that wire was removed when drain +// landed). Draining offers may have a `- **Drain ends at**:` bullet +// but never a separate Available bullet, because consumers detect +// drain solely via the timestamp's presence. +func TestBuildSkillCatalogMarkdown_DrainAdditiveDetail(t *testing.T) { + readyCond := []monetizeapi.Condition{{Type: "Ready", Status: "True"}} + activeOffer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "alpha", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "http", + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base", + PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }, + Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond}, + } + + drainAt := metav1.NewTime(time.Now()) + grace := metav1.Duration{Duration: time.Hour} + drainingOffer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "bravo", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "http", + DrainAt: &drainAt, + DrainGracePeriod: &grace, + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base", + PayTo: "0x2222222222222222222222222222222222222222", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }, + Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond}, + } + + content := buildSkillCatalogMarkdown( + []*monetizeapi.ServiceOffer{activeOffer, drainingOffer}, + "https://example.com", + ) + + if strings.Contains(content, "- **Available**:") { + t.Errorf("markdown contains `- **Available**:` bullet; drain wire is additive (drainEndsAt only):\n%s", content) + } + if !strings.Contains(content, "- **Drain ends at**:") { + t.Errorf("draining offer missing `- **Drain ends at**:` bullet:\n%s", content) + } + // Table header should expose Status, not the legacy Available column. + if strings.Contains(content, "| Available |") { + t.Errorf("markdown table header still has `Available` column; expected `Status`:\n%s", content) + } + if !strings.Contains(content, "| Status |") { + t.Errorf("markdown table header missing `Status` column:\n%s", content) + } +} + func TestBuildSkillCatalogHTTPRoute(t *testing.T) { route := buildSkillCatalogHTTPRoute() if route.GetName() != skillCatalogRouteName { @@ -856,16 +911,28 @@ func TestBuildServiceCatalogJSON_ExcludesNonReady(t *testing.T) { if services[0].Name != "ready-svc" { t.Errorf("got %q, want ready-svc — filter pipeline leaked another offer", services[0].Name) } - if !services[0].Available { - t.Errorf("ready-svc.available = false, want true (offer is not draining)") + + // Pure-additive wire schema: active offers must serialize without + // `available` (no field at all). Consumers detect drain via the + // presence of `drainEndsAt`, not via a legacy `available` boolean. + var raw []map[string]any + if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { + t.Fatalf("invalid raw JSON: %v\n%s", err, jsonStr) + } + if _, ok := raw[0]["available"]; ok { + t.Errorf("ready-svc JSON contains `available` key; drain wire schema must be additive (drainEndsAt only)") + } + if _, ok := raw[0]["drainEndsAt"]; ok { + t.Errorf("ready-svc JSON contains `drainEndsAt`; should only appear on draining offers") } } // TestBuildServiceCatalogJSON_DrainLifecycle covers the three drain -// states explicitly: pre-drain (available=true, no drainEndsAt), mid-drain -// (in catalog, available=false, drainEndsAt populated), and drain-expired -// (filtered out of the catalog because the controller has torn down the -// underlying route). +// states explicitly under the pure-additive wire schema: pre-drain +// (no `available` key, no `drainEndsAt`), mid-drain (no `available` +// key, only `drainEndsAt` populated), and drain-expired (filtered out +// of the catalog because the controller has torn down the underlying +// route). Consumers detect drain with `if (entry.drainEndsAt)`. func TestBuildServiceCatalogJSON_DrainLifecycle(t *testing.T) { readyCond := []monetizeapi.Condition{{Type: "Ready", Status: "True"}} mkOffer := func(name string) monetizeapi.ServiceOffer { @@ -901,39 +968,41 @@ func TestBuildServiceCatalogJSON_DrainLifecycle(t *testing.T) { exp.Spec.DrainGracePeriod = &expGrace jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{&pre, &mid, &exp}, "https://example.com") - var services []schemas.ServiceCatalogEntry - if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + var raw []map[string]any + if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) } - if len(services) != 2 { - t.Fatalf("expected 2 services (pre + mid; expired filtered out), got %d: %+v", len(services), services) + if len(raw) != 2 { + t.Fatalf("expected 2 services (pre + mid; expired filtered out), got %d: %+v", len(raw), raw) } - byName := map[string]schemas.ServiceCatalogEntry{} - for _, s := range services { - byName[s.Name] = s + byName := map[string]map[string]any{} + for _, s := range raw { + name, _ := s["name"].(string) + byName[name] = s } - if pre, ok := byName["pre"]; !ok { + if entry, ok := byName["pre"]; !ok { t.Fatal("pre-drain offer missing from catalog") } else { - if !pre.Available { - t.Errorf("pre.available = false, want true") + if _, has := entry["available"]; has { + t.Errorf("pre entry contains `available` key; drain wire schema must be additive") } - if pre.DrainEndsAt != "" { - t.Errorf("pre.drainEndsAt = %q, want empty", pre.DrainEndsAt) + if _, has := entry["drainEndsAt"]; has { + t.Errorf("pre entry contains `drainEndsAt` key; should only appear on draining offers") } } - if mid, ok := byName["mid"]; !ok { + if entry, ok := byName["mid"]; !ok { t.Fatal("mid-drain offer missing from catalog") } else { - if mid.Available { - t.Errorf("mid.available = true, want false (offer is draining)") + if _, has := entry["available"]; has { + t.Errorf("mid entry contains `available` key; drain wire schema must be additive (drainEndsAt only)") } - if mid.DrainEndsAt == "" { - t.Errorf("mid.drainEndsAt is empty, want RFC3339 timestamp") + drainEndsAt, has := entry["drainEndsAt"].(string) + if !has || drainEndsAt == "" { + t.Errorf("mid entry missing `drainEndsAt`; should be populated for draining offers") } - if _, err := time.Parse(time.RFC3339, mid.DrainEndsAt); err != nil { - t.Errorf("mid.drainEndsAt = %q is not RFC3339: %v", mid.DrainEndsAt, err) + if _, err := time.Parse(time.RFC3339, drainEndsAt); err != nil { + t.Errorf("mid.drainEndsAt = %q is not RFC3339: %v", drainEndsAt, err) } } if _, ok := byName["expired"]; ok {