gastownhall/beads · error · issueops.ErrSelfDependency

: apply batch item : cannot depend on itself

Error message

%w: apply batch item %d: %s cannot depend on itself

What it means

A dependency edge item where Source and Target resolve to the same issue is a self-dependency, which is rejected with issueops.ErrSelfDependency. Self-edges would create meaningless (or cycle-of-one) dependency relationships in the graph.

Solutions

  1. Skip edge rows where source equals target before building the batch
  2. Fix upstream data so no issue depends on itself
  3. Add a unit test on edge generation asserting src != tgt

Example fix

// before
edges = append(edges, edge{src, tgt})
// after
if src != tgt { edges = append(edges, edge{src, tgt}) }
Defensive patterns

Strategy: validation

Validate before calling

for i, it := range items {
  if it.DepAdd == nil { continue }
  if refEqual(it.DepAdd.Source, it.DepAdd.Target) {
    return fmt.Errorf("dep item %d is a self-dependency", i)
  }
}

Type guard

func isSelfDep(e *issueops.DepAddItem) bool {
  return e != nil && refEqual(e.Source, e.Target)
}
func refEqual(a, b issueops.Ref) bool { return a.ID == b.ID && a.Key == b.Key }

Try / catch

if err := store.PlanApplyBatch(plan); err != nil {
  var re *issueops.RefError
  if errors.Is(err, issueops.ErrSelfDependency) {
    // skip or correct the offending edge and rebuild the plan
  }
  _ = re
  return err
}

Prevention

When it happens

Trigger: PlanApplyBatch with a DepAdd item whose Source and Target refs (IDs or keys) both resolve to the same issue — e.g. DepAdd{Source: Ref{Key: "a"}, Target: Ref{Key: "a"}} or both refs pointing at the same key declared once.

Common situations: Generating edges from a data table where source and target columns happen to match on a row; templating edges like (x, parent-of-x) where x is its own parent in bad data.

Related errors


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

Appendix: source

Thrown at internal/storage/batch_apply.go:265

// gate metadata.
//
// IT RECORDS NOTHING AS TOUCHED, and that is a decision rather than an
// oversight. The ExpectedVersion rule above refuses a guard on a row this
// request has already REWRITTEN, and an edge write is a change to the graph
// rather than to either endpoint's row: the role promises nothing about whether
// it moves the source's version token. A later guard on that source is
// therefore left to the substrate, where a genuine mismatch is
// ErrVersionMismatch — an honest refusal — rather than being refused up front
// as a request a caller could not have composed.
func planApplyBatchDepAdd(item *issueops.DepAddItem, index int, keyIndex map[string]int) error {
	if err := validateApplyTargetRef(item.Source, index, "source", keyIndex); err != nil {
		return err
	}
	if err := validateApplyTargetRef(item.Target, index, "target", keyIndex); err != nil {
		return err
	}
	if item.Source == item.Target {
		return fmt.Errorf("%w: apply batch item %d: %s cannot depend on itself",
			issueops.ErrSelfDependency, index, applyRefLabel(item.Source))
	}
	if !item.Type.IsValid() {
		return fmt.Errorf("%w: apply batch item %d requires a dependency type (max %d chars)",
			issueops.ErrValidation, index, types.MaxDependencyTypeLen)
	}
	metadata, err := normalizeApplyEdgeMetadata(item.Type, item.Metadata)
	if err != nil {
		return fmt.Errorf("%w: apply batch item %d: %v", issueops.ErrValidation, index, err)
	}
	item.Metadata = metadata
	return nil
}

// checkApplyExpectedVersion refuses a version guard on a row an earlier item of
// this request already mutated.
//
// IT IS A REQUEST-SHAPE RULE, not a race. The token is server-minted and

View on GitHub (pinned to 71377f2769)