gastownhall/beads · error · publicops.ErrValidation

mixed regular/wisp CreateIssues batch cannot include cross-b

Error message

mixed regular/wisp CreateIssues batch cannot include cross-bucket dependency %s -> %s; create the issues first, then add the in-batch dependency after both issues exist%.0w

What it means

CrossPlaneBatchEdgeError refuses a CreateIssues batch that mixes regular (durable) and wisp (ephemeral) issues while also declaring a dependency whose source and target land in different buckets. Such a cross-plane edge cannot be written by the batch that creates both ends; it must be added after both issues exist. Wraps publicops.ErrValidation.

Source

Thrown at internal/storage/issueops/create_batch.go:193

	// counter bookkeeping here.
	tables := ChangedTables{}
	tables.Merge(CreateIssuesDirtyTables(ctx, issues, created))
	result := publicops.CreateBatchResult{Issues: make([]*types.Issue, len(issues))}
	for i, issue := range issues {
		hydrated, err := HydrateIssueOperationResult(ctx, tx, issue.ID, false)
		if err != nil {
			return publicops.CreateBatchResult{}, nil, err
		}
		result.Issues[i] = hydrated
	}
	return result, tables, nil
}

// CrossPlaneBatchEdgeError is the refusal the store-backed body raises from
// filterCreateIssuesMixedBucketDependencies, spelled here so the unit-of-work
// body can raise the identical one before it starts writing.
func CrossPlaneBatchEdgeError(sourceID, targetID string) error {
	return fmt.Errorf("mixed regular/wisp CreateIssues batch cannot include cross-bucket dependency %s -> %s; create the issues first, then add the in-batch dependency after both issues exist%.0w",
		sourceID, targetID, publicops.ErrValidation)
}

// ValidateCreateBatchPlanes refuses a batch whose items would write an edge
// BETWEEN the durable and ephemeral planes, which BatchCreator's contract says
// "cannot be written by the batch that creates both of its ends".
//
// THE STORE BODY GETS THIS FOR FREE and the unit-of-work body does not. The
// store body assigns every id first and then hands the whole slice to
// CreateIssuesInTxWithResult, which sees the batch as a set and refuses. The
// unit-of-work body creates item by item, so by the time an edge is written
// its target is an ordinary existing row and the domain layer writes the
// cross-plane edge happily — the identical request was refused whole by two
// backends and landed in full on the third.
//
// Only an EXPLICIT id can be an in-batch target: a caller cannot name an id the
// batch has not minted yet, so the ids known here are the only ones an edge can
// reach inside the batch. infraTypes promotes the same types both bodies

View on GitHub (pinned to 71377f2769)

Solutions

  1. Split the batch: create both issues first (possibly in separate batches per plane), then issue a second dependency-add call for the cross-bucket edge
  2. Convert one endpoint so both ends of the dependency live in the same bucket (both regular or both wisp)
  3. Pre-screen items: for each dependency, check plane(item.Issue) == plane(dep.Target) before submitting the batch

Example fix

// before
batch.Dependencies = []publicops.CreateDependency{{IssueID: regularID, DependsOnID: wispID}}
// after
ExecuteCreateBatch(ctx, batchWithoutCrossEdge)
AddDependency(ctx, regularID, wispID) // after both exist
Defensive patterns

Strategy: validation

Validate before calling

func isWisp(i *types.Issue) bool { /* wisp predicate */ }
for _, item := range req.Items {
    plane := map[string]bool{}
    for _, d := range item.Dependencies {
        if plane[d.IssueID] != plane[d.DependsOnID] {
            return fmt.Errorf("cross-plane edge %s -> %s", d.IssueID, d.DependsOnID)
        }
    }
}

Try / catch

if err := ExecuteCreateBatch(ctx, req); errors.Is(err, publicops.ErrValidation) && strings.Contains(err.Error(), "cross-bucket dependency") {
    // split batch and add edges afterwards
}

Prevention

When it happens

Trigger: A single CreateIssues batch containing e.g. a regular issue A depending on wisp issue B (or vice versa) via item Dependencies, where the edge crosses the durable/ephemeral plane boundary.

Common situations: Bulk-importing mixed-priority work where a long-lived blocker references a transient wisp; tools generating one batch from a graph that does not distinguish wisps from regular issues.

Related errors


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