gastownhall/beads · error · ErrSelfDependency

%w: %s cannot depend on itself

Error message

%w: %s cannot depend on itself

What it means

A self-dependency rejection: the dependency being added has IssueID equal to DependsOnID, so an issue/wisp would depend on itself. It wraps the sentinel ErrSelfDependency so callers can detect this case with errors.Is. This check runs before the cycle probe because a blocking self-edge would otherwise be misreported as a generic cycle error (#4547 F-1).

Source

Thrown at internal/storage/domain/dependency.go:284

func (u *dependencyUseCaseImpl) AddWispDependency(ctx context.Context, dep *types.Dependency, actor string) error {
	return u.add(ctx, dep, actor, true)
}

func (u *dependencyUseCaseImpl) add(ctx context.Context, dep *types.Dependency, actor string, useWisp bool) error {
	if dep == nil {
		return fmt.Errorf("add dep: dep must not be nil")
	}
	if dep.IssueID == "" || dep.DependsOnID == "" {
		return fmt.Errorf("add dep: IssueID and DependsOnID must be non-empty")
	}

	// Self-dependency guard mirrors issueops.CheckDependencyCycleInTx: it is
	// checked BEFORE the cycle probe and for ALL dep types, and emits the
	// dedicated self-dep message. A blocking self-edge otherwise trips HasCycle
	// and would report the wrong (cycle) error (#4547 F-1).
	if dep.IssueID == dep.DependsOnID {
		return fmt.Errorf("%w: %s cannot depend on itself", ErrSelfDependency, dep.IssueID)
	}
	if err := u.depRepo.ValidateBlockingHierarchy(ctx, dep); err != nil {
		var hierarchyConflict *DependencyHierarchyConflictError
		if errors.As(err, &hierarchyConflict) {
			return err
		}
		return fmt.Errorf("add dep: hierarchy check: %w", err)
	}

	if types.IsSchedulingEdge(dep.Type) {
		cycle, err := u.depRepo.HasCycle(ctx, dep.IssueID, dep.DependsOnID)
		if err != nil {
			return fmt.Errorf("add dep: cycle check: %w", err)
		}
		if cycle {
			// Match the embedded store's user-facing wording verbatim (no ids
			// prefix) so gc code that string-matches this error behaves the same
			// on both plumbings (#4547 F-1).

View on GitHub (pinned to 71377f2769)

Solutions

  1. Ensure the DependsOnID points to a different issue than IssueID
  2. Detect the case programmatically with errors.Is(err, ErrSelfDependency) and surface a friendly message
  3. Fix scripts/UI to use distinct source and target IDs
  4. Sanitize imports to drop self-referencing edges

Example fix

// before
dep := &types.Dependency{IssueID: id, DependsOnID: id} // self-dep
err := uc.AddDependency(ctx, dep, actor)
// after
if id == targetID { return fmt.Errorf("cannot block %s with itself", id) }
dep := &types.Dependency{IssueID: id, DependsOnID: targetID}
err := uc.AddDependency(ctx, dep, actor)
Defensive patterns

Strategy: validation

Validate before calling

if issueID == dependsOnID {
    return fmt.Errorf("%s cannot depend on itself", issueID)
}

Type guard

func isSelfDependency(err error) bool {
    return errors.Is(err, storage.ErrSelfDependency)
}

Try / catch

err := uc.AddDependency(ctx, dep, actor)
if err != nil {
    if errors.Is(err, storage.ErrSelfDependency) {
        return fmt.Errorf("friendly message: %s cannot block itself", dep.IssueID)
    }
    return err
}

Prevention

When it happens

Trigger: Calling AddDependency or AddWispDependency where dep.IssueID == dep.DependsOnID — typically the same ID variable passed to both fields, or a UI/script echoing one ID into both slots.

Common situations: Shell script reusing $ISSUE_ID for both arguments; form/UI pre-filling the 'depends on' field with the current issue; data import mapping the source column to both endpoints.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/08b88c21793ccc7a. Report an issue: GitHub.