From 3e71fe8418c9ec8e99fcac75a3b6b813d6f433d5 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 15 May 2026 17:31:37 +0000 Subject: [PATCH] fix(store): gate SeedDefaultCollections on zero-collection workspaces (IDEA-1479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server's startup auto-upgrade hook (cmd/pad/main.go) called SeedDefaultCollections against every workspace at boot. That hook dates to the initial release — long before workspace templates existed — and was written as a backfill for workspaces created before tasks/ideas/plans/docs landed in Defaults(). Post-templates, the hook unconditionally re-materialized the Software-template collections into any workspace missing them — including blank-template workspaces (IDEA-1479), which ship only Conventions + Playbooks by design. Result: every restart silently regrew the ghost user-facing collections the blank template was explicitly built to avoid. Fix: SeedDefaultCollections now returns nil immediately when the workspace has any existing collection (system or user-facing). The rescue path still triggers for genuinely-empty workspaces, preserving the original backfill intent. Tests: - TestBlankWorkspaceSurvivesSeedDefaultCollections — blank workspace remains 2 collections after auto-upgrade (and after a second pass). - TestEmptyWorkspaceStillGetsDefaults — zero-collection workspace still gets the full Software default set. --- internal/store/collections.go | 27 +++++++++++ internal/store/collections_test.go | 77 ++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/internal/store/collections.go b/internal/store/collections.go index 865ac526..7fc00770 100644 --- a/internal/store/collections.go +++ b/internal/store/collections.go @@ -405,7 +405,34 @@ func (s *Store) MigrateItemFieldValues(collectionID string, migrations []models. return totalAffected, nil } +// SeedDefaultCollections is a rescue hook for workspaces that somehow ended +// up with zero collections — e.g. workspaces created before the seed-on-init +// flow existed, or a partial init that failed before any collection landed. +// The server calls it for every workspace at startup as an auto-upgrade. +// +// It is intentionally a no-op for workspaces that already have at least one +// collection (system or user-facing). Without this guard, the hook would +// re-materialize the standard Software-template collections (Tasks/Ideas/ +// Plans/Docs) into workspaces created from non-default templates on every +// boot — including the `blank` template (IDEA-1479), which ships only +// Conventions + Playbooks and would silently grow ghost user-facing +// collections on each restart. +// +// The "zero collections" guard preserves the original backfill intent +// (rescue truly-empty workspaces) while making the hook safe for the +// post-templates world where workspaces legitimately diverge from +// Defaults(). func (s *Store) SeedDefaultCollections(workspaceID string) error { + existing, err := s.ListCollectionsMinimal(workspaceID) + if err != nil { + return fmt.Errorf("check existing collections for rescue seed: %w", err) + } + if len(existing) > 0 { + // Workspace already has collections — its shape was set by a + // template (default, blank, hiring, etc.) or by manual user + // edits. Either way, the rescue path doesn't apply. + return nil + } return s.SeedCollectionsFromTemplate(workspaceID, "") } diff --git a/internal/store/collections_test.go b/internal/store/collections_test.go index e0e251fd..90854a79 100644 --- a/internal/store/collections_test.go +++ b/internal/store/collections_test.go @@ -49,6 +49,83 @@ func TestSeedFromBlankTemplate(t *testing.T) { } } +// TestBlankWorkspaceSurvivesSeedDefaultCollections is the regression guard +// for codex round-2: the server runs SeedDefaultCollections against every +// workspace at startup as an auto-upgrade rescue. Before the fix, that hook +// unconditionally re-materialized the standard Software-template collections +// (tasks/ideas/plans/docs) into any workspace missing them — including +// blank-template workspaces, which would silently grow ghost collections on +// every server restart. The fix gates the rescue on "workspace has zero +// collections" so blank (which ships 2 system collections) is a no-op. +func TestBlankWorkspaceSurvivesSeedDefaultCollections(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Blank Survival Test") + + if err := s.SeedCollectionsFromTemplate(ws.ID, "blank"); err != nil { + t.Fatalf("SeedCollectionsFromTemplate(blank) error: %v", err) + } + + // Simulate a server restart firing the auto-upgrade hook. + if err := s.SeedDefaultCollections(ws.ID); err != nil { + t.Fatalf("SeedDefaultCollections error: %v", err) + } + + colls, err := s.ListCollections(ws.ID) + if err != nil { + t.Fatalf("ListCollections error: %v", err) + } + if len(colls) != 2 { + t.Fatalf("blank workspace has %d collections after auto-upgrade, want 2 (Conventions + Playbooks only); got %+v", len(colls), collectionSlugs(colls)) + } + for _, c := range colls { + if c.Slug == "tasks" || c.Slug == "ideas" || c.Slug == "plans" || c.Slug == "docs" { + t.Errorf("blank workspace acquired user-facing software collection %q after SeedDefaultCollections — auto-upgrade rescue gate is broken", c.Slug) + } + } + + // And repeated invocations remain no-ops. + if err := s.SeedDefaultCollections(ws.ID); err != nil { + t.Fatalf("SeedDefaultCollections (second run) error: %v", err) + } + colls, _ = s.ListCollections(ws.ID) + if len(colls) != 2 { + t.Errorf("blank workspace has %d collections after second auto-upgrade pass, want 2", len(colls)) + } +} + +// TestEmptyWorkspaceStillGetsDefaults verifies the rescue path still works +// for a workspace that genuinely has zero collections — the original intent +// of the SeedDefaultCollections hook (predates templates; see git blame on +// cmd/pad/main.go's auto-upgrade block). If a workspace was created before +// the seed-on-init flow existed, or a partial init failed before any +// collection landed, the auto-upgrade must still materialize the Software +// defaults. +func TestEmptyWorkspaceStillGetsDefaults(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Empty Rescue Test") + + // No SeedCollectionsFromTemplate — workspace starts with zero + // collections (simulating a pre-templates-era workspace, or a + // partial init). + if err := s.SeedDefaultCollections(ws.ID); err != nil { + t.Fatalf("SeedDefaultCollections error: %v", err) + } + + colls, err := s.ListCollections(ws.ID) + if err != nil { + t.Fatalf("ListCollections error: %v", err) + } + slugs := make(map[string]bool, len(colls)) + for _, c := range colls { + slugs[c.Slug] = true + } + for _, want := range []string{"tasks", "ideas", "plans", "docs", "conventions", "playbooks"} { + if !slugs[want] { + t.Errorf("empty workspace rescue did not materialize default collection %q (got %v)", want, collectionSlugs(colls)) + } + } +} + func collectionSlugs(colls []models.Collection) []string { out := make([]string, 0, len(colls)) for _, c := range colls {