fix(store): gate SeedDefaultCollections on zero-collection workspaces (IDEA-1479)

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.
This commit is contained in:
xarmian
2026-05-15 17:31:37 +00:00
parent a1692ad431
commit 3e71fe8418
2 changed files with 104 additions and 0 deletions
+27
View File
@@ -405,7 +405,34 @@ func (s *Store) MigrateItemFieldValues(collectionID string, migrations []models.
return totalAffected, nil 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 { 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, "") return s.SeedCollectionsFromTemplate(workspaceID, "")
} }
+77
View File
@@ -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 { func collectionSlugs(colls []models.Collection) []string {
out := make([]string, 0, len(colls)) out := make([]string, 0, len(colls))
for _, c := range colls { for _, c := range colls {