gastownhall/beads · error

expansion depth limit exceeded: max %d levels (currently at

Error message

expansion depth limit exceeded: max %d levels (currently at %d) - step %q

What it means

expandStep tracks recursion depth; if depth exceeds DefaultMaxExpansionDepth (5), it aborts with this error. This guards against infinite/recursive expansions where templates reference themselves or expansions cascade unboundedly through nested children.

Source

Thrown at internal/formula/expand.go:198

// countStepIDs counts occurrences of each step ID recursively.
func countStepIDs(steps []*Step, counts map[string]int) {
	for _, step := range steps {
		counts[step.ID]++
		if len(step.Children) > 0 {
			countStepIDs(step.Children, counts)
		}
	}
}

// expandStep expands a target step using the given template.
// Returns the expanded steps with placeholders substituted.
// The depth parameter tracks recursion depth for children; if it exceeds
// DefaultMaxExpansionDepth, an error is returned.
// The vars parameter provides variable values for {varname} substitution.
func expandStep(target *Step, template []*Step, depth int, vars map[string]string) ([]*Step, error) {
	if depth > DefaultMaxExpansionDepth {
		return nil, fmt.Errorf("expansion depth limit exceeded: max %d levels (currently at %d) - step %q",
			DefaultMaxExpansionDepth, depth, target.ID)
	}

	result := make([]*Step, 0, len(template))

	for _, tmpl := range template {
		expanded := &Step{
			ID:             substituteVars(substituteTargetPlaceholders(tmpl.ID, target), vars),
			Title:          substituteVars(substituteTargetPlaceholders(tmpl.Title, target), vars),
			Description:    substituteVars(substituteTargetPlaceholders(tmpl.Description, target), vars),
			Type:           tmpl.Type,
			Priority:       tmpl.Priority,
			Assignee:       substituteVars(tmpl.Assignee, vars),
			SourceFormula:  tmpl.SourceFormula,  // Preserve source from template
			SourceLocation: tmpl.SourceLocation, // Preserve source location
		}

		// Substitute placeholders in labels

View on GitHub (pinned to 71377f2769)

Solutions

  1. Break the recursion in the template so expansion is finite
  2. Make select patterns exclude generated steps (prefix-based patterns)
  3. Restructure deeply nested generations into explicit steps or raise depth intentionally via the library constant if truly needed

Example fix

// before: template expands steps that re-match the rule
// after: generate IDs that don't match the select pattern
steps:
  - id: "done-${i}"   # select: "gen-*" no longer matches
Defensive patterns

Strategy: validation

Validate before calling

// static check: expansion templates must not reference steps matching their own select/expand rule
func templateSelfReferences(f *formula.Formula, pattern string) bool {
    for _, s := range f.Template {
        if matched, _ := path.Match(pattern, s.ID); matched { return true }
    }
    return false
}

Try / catch

steps, err := formula.ApplyExpansions(steps, compose)
if err != nil && strings.Contains(err.Error(), "depth limit exceeded") {
    return fmt.Errorf("recursive expansion detected (max %d): %w", 5, err)
}

Prevention

When it happens

Trigger: Any expandStep call chain reaching depth 6+: recursive expansion templates, an expansion whose output is re-expanded by a map/inline rule, or chained expansions across formulas (callers include ApplyExpansions, MaterializeExpansion, applyInlineExpansionsRecursive).

Common situations: Self-referential formula (template expands to steps that match the same expansion rule); mutually recursive expansions; a bug in select patterns matching newly created steps.

Related errors


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