PR #3509
Sections
Review

fix(office): reject empty slug on skill update

main ← feature/reject-empty-slug-on-979c73 6 files +212 −18 PR #3509 ↗

PATCH /skills/:id now rejects an empty slug and heals legacy empty slugs on content-only edits, which stops invalid records and keeps unrelated updates from failing.

Why this change

PATCH /skills/:id accepts an empty slug. The old ValidateSkillUpdate returns nil for an empty slug, so a client can clear the slug and store an invalid record. Legacy rows from config-import can also have an empty slug.

What it does

Architecture, end to end

The PATCH flows through Gin to the service. The service branches on slugRequested to reject, heal, or normalize.

flowchart LR
  Client["PATCH /skills/:id"] --> Handler["Handler.updateSkill"]
  Handler --> Apply["applySkillUpdates"]
  Apply --> Service["SkillService.ValidateSkillUpdate"]
  Service --> Check{"slugRequested?"}
  Check -- "yes + empty" --> Reject["400 invalid slug"]
  Check -- "no + empty" --> Heal["healEmptySlug"]
  Heal --> Repo["(Skill repo)"]
  Check -- "well-formed" --> Norm["Normalize + unique check"]
  Norm --> Repo
  Repo --> Handler
  Handler --> Resp["200 or 400"]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func (h *Handler) updateSkill(c *gin.Context)
Click for details →

The handler tells the service if the client sent slug, so empty can be rejected and omitted can be healed.

Before / After
	}
	applySkillUpdates(skill, &req)
	if err := h.svc.ValidateSkillUpdate(ctx, skill); err != nil {
	if err := h.svc.ValidateSkillUpdate(ctx, skill, req.Slug != nil); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
Service rejects empty slug and heals legacy rowsapps/backend/internal/office/skills/service.go ↗
func (s *SkillService) ValidateSkillUpdate(ctx context.Context, skill *models.Skill, slugRequested bool) error
Click for details →

The service rejects an empty slug when requested and heals an empty stored slug when not requested.

ValidateSkillUpdate
// ValidateSkillUpdate validates a skill update for slug uniqueness. Slug
// handling mirrors ValidateAndPrepareSkill: reject a not-well-formed slug
// outright (AC-001.11), otherwise normalize to canonical before the
// uniqueness check (AC-001.12).
func (s *SkillService) ValidateSkillUpdate(ctx context.Context, skill *models.Skill) error {
	if skill.Slug == "" {
// handling mirrors ValidateAndPrepareSkill: an empty slug is not
// well-formed and is rejected like any other not-well-formed slug when the
// caller supplies one; the caller omits the field entirely (slugRequested
// false) to leave the slug unchanged. A well-formed slug is normalized to
// canonical form before the uniqueness check runs.
//
// A request that never mentions slug must never fail on slug grounds. The
// config-import path can create a row with an empty stored slug outside
// this service's own validation, so an unrelated update to such a row
// heals it to a free name-derived slug rather than rejecting the caller's
// edit; if every candidate collides, the stored empty value is left as-is.
func (s *SkillService) ValidateSkillUpdate(ctx context.Context, skill *models.Skill, slugRequested bool) error {
	if !slugRequested && skill.Slug == "" {
		s.healEmptySlug(ctx, skill)
		return nil
	}
	if !skillslug.WellFormed(skill.Slug) {
		return fmt.Errorf("invalid skill slug %q: must contain only letters, digits, underscore, and hyphen", skill.Slug)
	}
	skill.Slug = skillslug.Normalize(skill.Slug)
	return s.validateSlugUnique(ctx, skill.WorkspaceID, skill.Slug, skill.ID)
}

// healEmptySlug assigns skill a name-derived slug when one is free in its
// workspace, and leaves its stored empty slug untouched otherwise.
func (s *SkillService) healEmptySlug(ctx context.Context, skill *models.Skill) {
	candidate := skillslug.Normalize(GenerateSlug(skill.Name))
	if s.validateSlugUnique(ctx, skill.WorkspaceID, candidate, skill.ID) == nil {
		skill.Slug = candidate
	}
}
New handler tests for PATCH edge casesapps/backend/internal/office/skills/handler_test.go ↗
func TestUpdateSkillHandler_RejectsEmptySlug(t *testing.T)
Click for details →

The tests lock the HTTP contract: empty and malformed slugs return 400, content-only PATCH heals or preserves slug.

Reject empty slug
func TestUpdateSkillHandler_RejectsEmptySlug(t *testing.T) {
	router, svc := newTestSkillRouter(t)
	ctx := context.Background()
	skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-existing", SourceType: "inline"}
	if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
		t.Fatalf("validate: %v", err)
	}
	if err := svc.CreateSkill(ctx, skill); err != nil {
		t.Fatalf("create: %v", err)
	}
	rec := doSkillRequest(t, router, http.MethodPatch, "/api/v1/skills/"+skill.ID, `{"slug":""}`)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status: got %d, want %d (body: %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
	}
	reloaded, _ := svc.GetSkillFromConfig(ctx, skill.ID)
	if reloaded.Slug != "kandev-existing" {
		t.Errorf("stored slug = %q, want unchanged", reloaded.Slug)
	}
}
Heal on content-only PATCH
func TestUpdateSkillHandler_ContentOnlyPatchHealsStoredEmptySlug(t *testing.T) {
	router, svc := newTestSkillRouter(t)
	ctx := context.Background()
	skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing Skill", Slug: "", SourceType: "inline"}
	if err := svc.CreateSkill(ctx, skill); err != nil {
		t.Fatalf("create: %v", err)
	}
	rec := doSkillRequest(t, router, http.MethodPatch, "/api/v1/skills/"+skill.ID, `{"content":"new content"}`)
	if rec.Code != http.StatusOK {
		t.Fatalf("status: got %d, want %d", rec.Code, http.StatusOK)
	}
	var resp skills.SkillResponse
	json.Unmarshal(rec.Body.Bytes(), &resp)
	if resp.Skill.Slug == "" {
		t.Errorf("response slug still empty after content-only PATCH")
	}
}
func TestValidateSkillUpdate_RejectsEmptySlug(t *testing.T)
Click for details →

The service tests verify the same rules without HTTP: empty when requested fails, heal when not requested succeeds.

Service tests
func TestValidateSkillUpdate_RejectsEmptySlug(t *testing.T) {
	svc := newTestSkillService(t)
	ctx := context.Background()
	skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-existing", SourceType: "inline"}
	svc.ValidateAndPrepareSkill(ctx, skill)
	svc.CreateSkill(ctx, skill)
	skill.Slug = ""
	if err := svc.ValidateSkillUpdate(ctx, skill, true); err == nil {
		t.Fatal("expected error for empty slug")
	}
}

func TestValidateSkillUpdate_HealsEmptyStoredSlugWhenNotRequested(t *testing.T) {
	svc := newTestSkillService(t)
	ctx := context.Background()
	skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing Skill", SourceType: "inline"}
	svc.CreateSkill(ctx, skill)
	if err := svc.ValidateSkillUpdate(ctx, skill, false); err != nil {
		t.Fatalf("update: %v", err)
	}
	if skill.Slug == "" {
		t.Error("slug still empty after healing")
	}
}
func TestPollModeGrace_StopJoinsFinalScan(t *testing.T)
Click for details →

The test disables git polling loops so the grace callback is the only poll mode change under test.

Fix
func TestPollModeGrace_StopJoinsFinalScan(t *testing.T) {
	wt := newGraceTestTracker(t, graceFiresQuickly)
	// The test covers the grace callback's cancellation and wait-group
	// ownership. Disable the independent polling loops so their real Git work
	// cannot change poll mode while this lifecycle boundary is under test (the
	// Windows race runner can otherwise report an unrelated paused transition).
	wt.gitIndexPath = ""
	finalScanStarted := make(chan struct{})
	finalScanFinished := make(chan struct{})
	wt.gitStatusObserver = func(ctx context.Context) (types.GitStatusUpdate, error) {
Read the changes as a list

Handler passes slugRequested flag

apps/backend/internal/office/skills/handler.go

The handler tells the service if the client sent slug, so empty can be rejected and omitted can be healed.

Before / After
	}
	applySkillUpdates(skill, &req)
	if err := h.svc.ValidateSkillUpdate(ctx, skill); err != nil {
	if err := h.svc.ValidateSkillUpdate(ctx, skill, req.Slug != nil); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

Service rejects empty slug and heals legacy rows

apps/backend/internal/office/skills/service.go

The service rejects an empty slug when requested and heals an empty stored slug when not requested.

ValidateSkillUpdate
// ValidateSkillUpdate validates a skill update for slug uniqueness. Slug
// handling mirrors ValidateAndPrepareSkill: reject a not-well-formed slug
// outright (AC-001.11), otherwise normalize to canonical before the
// uniqueness check (AC-001.12).
func (s *SkillService) ValidateSkillUpdate(ctx context.Context, skill *models.Skill) error {
	if skill.Slug == "" {
// handling mirrors ValidateAndPrepareSkill: an empty slug is not
// well-formed and is rejected like any other not-well-formed slug when the
// caller supplies one; the caller omits the field entirely (slugRequested
// false) to leave the slug unchanged. A well-formed slug is normalized to
// canonical form before the uniqueness check runs.
//
// A request that never mentions slug must never fail on slug grounds. The
// config-import path can create a row with an empty stored slug outside
// this service's own validation, so an unrelated update to such a row
// heals it to a free name-derived slug rather than rejecting the caller's
// edit; if every candidate collides, the stored empty value is left as-is.
func (s *SkillService) ValidateSkillUpdate(ctx context.Context, skill *models.Skill, slugRequested bool) error {
	if !slugRequested && skill.Slug == "" {
		s.healEmptySlug(ctx, skill)
		return nil
	}
	if !skillslug.WellFormed(skill.Slug) {
		return fmt.Errorf("invalid skill slug %q: must contain only letters, digits, underscore, and hyphen", skill.Slug)
	}
	skill.Slug = skillslug.Normalize(skill.Slug)
	return s.validateSlugUnique(ctx, skill.WorkspaceID, skill.Slug, skill.ID)
}

// healEmptySlug assigns skill a name-derived slug when one is free in its
// workspace, and leaves its stored empty slug untouched otherwise.
func (s *SkillService) healEmptySlug(ctx context.Context, skill *models.Skill) {
	candidate := skillslug.Normalize(GenerateSlug(skill.Name))
	if s.validateSlugUnique(ctx, skill.WorkspaceID, candidate, skill.ID) == nil {
		skill.Slug = candidate
	}
}

New handler tests for PATCH edge cases

apps/backend/internal/office/skills/handler_test.go

The tests lock the HTTP contract: empty and malformed slugs return 400, content-only PATCH heals or preserves slug.

Reject empty slug
func TestUpdateSkillHandler_RejectsEmptySlug(t *testing.T) {
	router, svc := newTestSkillRouter(t)
	ctx := context.Background()
	skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-existing", SourceType: "inline"}
	if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
		t.Fatalf("validate: %v", err)
	}
	if err := svc.CreateSkill(ctx, skill); err != nil {
		t.Fatalf("create: %v", err)
	}
	rec := doSkillRequest(t, router, http.MethodPatch, "/api/v1/skills/"+skill.ID, `{"slug":""}`)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status: got %d, want %d (body: %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
	}
	reloaded, _ := svc.GetSkillFromConfig(ctx, skill.ID)
	if reloaded.Slug != "kandev-existing" {
		t.Errorf("stored slug = %q, want unchanged", reloaded.Slug)
	}
}
Heal on content-only PATCH
func TestUpdateSkillHandler_ContentOnlyPatchHealsStoredEmptySlug(t *testing.T) {
	router, svc := newTestSkillRouter(t)
	ctx := context.Background()
	skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing Skill", Slug: "", SourceType: "inline"}
	if err := svc.CreateSkill(ctx, skill); err != nil {
		t.Fatalf("create: %v", err)
	}
	rec := doSkillRequest(t, router, http.MethodPatch, "/api/v1/skills/"+skill.ID, `{"content":"new content"}`)
	if rec.Code != http.StatusOK {
		t.Fatalf("status: got %d, want %d", rec.Code, http.StatusOK)
	}
	var resp skills.SkillResponse
	json.Unmarshal(rec.Body.Bytes(), &resp)
	if resp.Skill.Slug == "" {
		t.Errorf("response slug still empty after content-only PATCH")
	}
}

Service unit tests for slug update

apps/backend/internal/office/skills/service_slug_update_test.go

The service tests verify the same rules without HTTP: empty when requested fails, heal when not requested succeeds.

Service tests
func TestValidateSkillUpdate_RejectsEmptySlug(t *testing.T) {
	svc := newTestSkillService(t)
	ctx := context.Background()
	skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-existing", SourceType: "inline"}
	svc.ValidateAndPrepareSkill(ctx, skill)
	svc.CreateSkill(ctx, skill)
	skill.Slug = ""
	if err := svc.ValidateSkillUpdate(ctx, skill, true); err == nil {
		t.Fatal("expected error for empty slug")
	}
}

func TestValidateSkillUpdate_HealsEmptyStoredSlugWhenNotRequested(t *testing.T) {
	svc := newTestSkillService(t)
	ctx := context.Background()
	skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing Skill", SourceType: "inline"}
	svc.CreateSkill(ctx, skill)
	if err := svc.ValidateSkillUpdate(ctx, skill, false); err != nil {
		t.Fatalf("update: %v", err)
	}
	if skill.Slug == "" {
		t.Error("slug still empty after healing")
	}
}

Stabilize grace timer test on Windows

apps/backend/internal/agentctl/server/process/workspace_poll_mode_grace_test.go

The test disables git polling loops so the grace callback is the only poll mode change under test.

Fix
func TestPollModeGrace_StopJoinsFinalScan(t *testing.T) {
	wt := newGraceTestTracker(t, graceFiresQuickly)
	// The test covers the grace callback's cancellation and wait-group
	// ownership. Disable the independent polling loops so their real Git work
	// cannot change poll mode while this lifecycle boundary is under test (the
	// Windows race runner can otherwise report an unrelated paused transition).
	wt.gitIndexPath = ""
	finalScanStarted := make(chan struct{})
	finalScanFinished := make(chan struct{})
	wt.gitStatusObserver = func(ctx context.Context) (types.GitStatusUpdate, error) {

Risk

3 / 10 Low
1 low5 medium10 high

Why this score

  • Small blast radius: only PATCH /skills/:id validation changes, no migration or public contract change.
  • Strong test coverage: 8 new tests cover reject, heal, collision, and unchanged cases at handler and service layers.
  • Low rollback cost: revert restores old permissive empty-slug path with no data change.

Trade-offs and review notes

Where to look first

  1. Check handler.go passes req.Slug != nil and that applySkillUpdates runs before validation.
  2. Verify ValidateSkillUpdate branches: slugRequested true with empty fails, false with empty heals, well-formed normalizes.
  3. Confirm healEmptySlug leaves empty on collision and does not return an error.
  4. Scan handler_test.go for 400 vs 200 expectations and that stored slug is unchanged on reject.