diff --git a/docs/internal/sources.md b/docs/internal/sources.md index fc85ed6..4bec302 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -72,6 +72,10 @@ manifest, layout, profile ownership, inheritance, and secret-safety rules before returning raw catalog sources. Internal engine assembly uses those validated catalogs as the maintained lowest-precedence profile source. +Normalized external catalog profile IDs with the exact `promptkit-` prefix are +rejected at this adapter boundary. That reservation applies only to imported +catalogs; configured consumer sources retain the ordinary profile-ID rules. + The overlay repository consults the next repository only when the higher-precedence repository reports that a profile is absent. A reliably selected malformed profile stops fallback, while an unrelated malformed file diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 625fe68..0950b5a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -128,6 +128,8 @@ checkouts, workspaces, or replacements. ## Stage 3: Enforce The External Catalog Namespace +**Completed:** 2026-09-22 + Repository: Promptkit. Keep current v1 dependencies during this stage; dependency adoption remains atomic in Stage 5. diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 43d42dc..8dfdb53 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -64,6 +64,9 @@ func Load(sources ...Source) (Set, error) { } resolvingRepository := profile.NewResolvingRepository(repository) for _, entry := range metadata { + if strings.HasPrefix(entry.ID, "promptkit-") { + return Set{}, fmt.Errorf("catalog %s: %s uses the reserved promptkit- profile ID prefix", source.Name, entry.Path) + } if profileIDs[entry.ID] { return Set{}, fmt.Errorf("catalog %s: %s duplicates an earlier profile ID", source.Name, entry.Path) } diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index 292f380..08d4f5a 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -193,6 +193,39 @@ func TestLoadRejectsInvalidCatalogProfiles(t *testing.T) { } } +func TestLoadReservesPromptkitProfilePrefixForCatalogs(t *testing.T) { + tests := map[string]struct { + profileID string + wantError bool + }{ + "maintained name": { + profileID: "promptkit-openrouter-standard", + wantError: true, + }, + "other reserved name": { + profileID: "promptkit-example", + wantError: true, + }, + "ordinary name": { + profileID: "ordinary-profile", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + fsys := validCatalogFS("one") + fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile(test.profileID, "one")) + + _, err := Load(testSource("one", fsys)) + if (err != nil) != test.wantError { + t.Fatalf("Load() error = %v, want error %t", err, test.wantError) + } + if err != nil && !strings.Contains(err.Error(), "catalog one: one-profile.yml") { + t.Fatalf("Load() error = %v, want catalog source and path context", err) + } + }) + } +} + func TestLoadRejectsCrossCatalogConflicts(t *testing.T) { t.Run("duplicate backend", func(t *testing.T) { second := catalogFS("one", map[string]string{