gastownhall/beads · error

serializing waits-for metadata: unresolved spawner key %q

Error message

serializing waits-for metadata: unresolved spawner key %q

What it means

NewGraphEdgeDependency builds a dependency row for a graph-plan edge. For waits-for edges it must record the spawner in metadata; if the plan supplies a plan-local spawnerKey that is absent from keyToID (the map of plan node keys to minted issue IDs), the spawner cannot be resolved and construction fails with this error.

Source

Thrown at internal/types/types.go:1389

// stored rows stay self-describing rather than depending on every reader
// defaulting a missing gate (the runtime SQL predicate COALESCEs to
// all-children, but readers before migration 0059 did not, so '{}' or empty
// metadata must never be stored for graph-created waits-for dependencies).
// A plan-local spawnerKey resolves through keyToID; the spawner is recorded
// for compatibility only — gate evaluation reads the spawner from
// dependencies.depends_on_id (see ParseWaitsForGateMetadata).
func NewGraphEdgeDependency(fromID, toID string, depType DependencyType, gate, spawnerKey, spawnerID, threadID string, keyToID map[string]string) (*Dependency, error) {
	dep := &Dependency{
		IssueID:     fromID,
		DependsOnID: toID,
		Type:        depType,
		ThreadID:    threadID,
	}
	if depType == DepWaitsFor {
		if spawnerKey != "" {
			resolved, ok := keyToID[spawnerKey]
			if !ok {
				return nil, fmt.Errorf("serializing waits-for metadata: unresolved spawner key %q", spawnerKey)
			}
			spawnerID = resolved
		}
		if gate == "" {
			gate = WaitsForAllChildren
		}
		raw, err := json.Marshal(WaitsForMeta{Gate: gate, SpawnerID: spawnerID})
		if err != nil {
			return nil, fmt.Errorf("serializing waits-for metadata: %w", err)
		}
		dep.Metadata = string(raw)
	}
	return dep, nil
}

// NewWaitsForDependency builds the waits-for dependency record for a single
// issue outside a graph plan: the spawner is the depends_on target and the
// metadata carries the gate (defaulted to all-children). Shares

View on GitHub (pinned to 71377f2769)

Solutions

  1. Ensure the spawner node exists in the graph plan and is included when building keyToID before edges are applied
  2. Fix the spawnerKey spelling in the plan edge so it matches the node key exactly
  3. Pass an empty spawnerKey (or the final spawnerID directly) when the spawner is already resolved — the code only consults keyToID when spawnerKey is non-empty

Example fix

// before
NewGraphEdgeDependency(fromID, toID, DepWaitsFor, "", "node-7", "", "", keyToID) // "node-7" missing from plan
// after
// add the node keyed "node-7" to the plan first, or pass "" as spawnerKey
NewGraphEdgeDependency(fromID, toID, DepWaitsFor, "", "", spawnerID, "", nil)
Defensive patterns

Strategy: validation

Validate before calling

func validateSpawnerKey(spawnerKey string, keyToID map[string]string) error {
	if spawnerKey != "" && keyToID != nil {
		if _, ok := keyToID[spawnerKey]; !ok {
			return fmt.Errorf("spawner key %q not in plan: add the node before applying edges", spawnerKey)
		}
	}
	return nil
}

Type guard

func spawnerKeyResolved(spawnerKey string, keyToID map[string]string) bool {
	_, ok := keyToID[spawnerKey]
	return ok
}

Prevention

When it happens

Trigger: Calling NewGraphEdgeDependency with depType=DepWaitsFor and a non-empty spawnerKey that is not a key in the keyToID map passed by the graph-apply path (embedded or domain).

Common situations: A graph plan references a spawner node key that was never defined as a node, was renamed, or the keyToID map was built before that node was minted; hand-assembled plans or partial plan imports hitting this check.

Related errors


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