gastownhall/beads · error · issueops.ErrSelfDependency

%w: apply batch item %d: %s 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.

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)

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.