gastownhall/beads · error

applyGraph: node %d (key=%q) has nil Issue

Error message

applyGraph: node %d (key=%q) has nil Issue

What it means

applyGraph validates that every plan node carries a non-nil Issue before minting IDs and inserting. A nil node.Issue means the plan was constructed incorrectly (e.g. a node added as a link-only placeholder without issue data). This is a caller/plan-construction bug, not a storage failure.

Source

Thrown at internal/storage/domain/issue.go:1116

func (u *issueUseCaseImpl) ApplyWispGraph(ctx context.Context, plan GraphPlan, actor string) (GraphApplyResult, error) {
	return u.applyGraph(ctx, plan, actor, true)
}

func (u *issueUseCaseImpl) applyGraph(ctx context.Context, plan GraphPlan, actor string, useWisp bool) (GraphApplyResult, error) {
	keyToID := make(map[string]string, len(plan.Nodes))
	pendingAssignees := make(map[int]string, len(plan.Nodes))

	// Pass 1 — create every node as a top-level issue. We deliberately do
	// not pass ParentID to u.create: graph nodes with parent_key/parent_id
	// receive top-level hash (or counter) IDs and have their parent linkage
	// added as a separate parent-child dep below. This matches embedded
	// executeGraphApply (cmd/bd/graph_apply.go) and lets children precede
	// parents in plan order — keyToID is only consulted after every node
	// has minted its ID.
	for i, node := range plan.Nodes {
		if node.Issue == nil {
			return GraphApplyResult{}, fmt.Errorf("applyGraph: node %d (key=%q) has nil Issue", i, node.Key)
		}
		// The whole plan routes to one table, so every node's storage class
		// must match. The CLI pre-validates this; guard here too so other
		// callers cannot route wisp-flagged issues into the durable table.
		if nodeWisp := node.Issue.Ephemeral || node.Issue.NoHistory; nodeWisp != useWisp {
			return GraphApplyResult{}, fmt.Errorf("applyGraph: node %q storage class (ephemeral=%t, no_history=%t) does not match plan routing (wisp=%t)", node.Key, node.Issue.Ephemeral, node.Issue.NoHistory, useWisp)
		}

		if node.AssignAfterCreate {
			pendingAssignees[i] = node.Assignee
			node.Issue.Assignee = ""
		} else if node.Assignee != "" {
			node.Issue.Assignee = node.Assignee
		}

		params := CreateIssueParams{
			Issue:  node.Issue,
			Labels: node.Labels,

View on GitHub (pinned to 71377f2769)

Solutions

  1. Regenerate the plan with the current bd CLI (bd graph apply plan) instead of hand-building it
  2. Ensure every node added to plan.Nodes has a populated Issue struct
  3. Skip/convert link-only placeholder nodes into proper dependency edges rather than plan nodes
  4. Check for nil-Issue assignments when deserializing plan JSON

Example fix

// before: placeholder node with no issue
plan.Nodes = append(plan.Nodes, GraphNode{Key: "parent"})
// after: attach the issue payload
plan.Nodes = append(plan.Nodes, GraphNode{Key: "parent", Issue: &types.Issue{Title: "parent", ...}})
Defensive patterns

Strategy: validation

Validate before calling

for i, n := range plan.Nodes {
    if n.Issue == nil {
        return fmt.Errorf("plan node %d (key=%s) has nil Issue; regenerate plan", i, n.Key)
    }
}

Type guard

func validNodes(plan GraphPlan) bool {
    for _, n := range plan.Nodes {
        if n.Issue == nil { return false }
    }
    return true
}

Try / catch

result, err := uc.ApplyGraph(ctx, plan, actor)
if err != nil && strings.Contains(err.Error(), "has nil Issue") {
    plan = regeneratePlan(sourceFile) // rebuild from authoritative source
    result, err = uc.ApplyGraph(ctx, plan, actor)
}

Prevention

When it happens

Trigger: Calling ApplyGraph with a plan whose Nodes[i].Issue is nil — typically from hand-built plans or a bug in plan generation that emits placeholder nodes.

Common situations: Custom tooling building GraphPlan structs directly; older plan files generated by a different bd version missing issue payloads; deserialized plans where issues were dropped.

Related errors


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