gastownhall/beads · error

cannot create %q: ID already exists in the %s table (issues

Error message

cannot create %q: ID already exists in the %s table (issues and wisps share one ID space)

What it means

checkCrossTableIDCollision guards the fact that the issues table and the wisps (ephemeral) table share a single ID space. When an insert finds an existing sibling row with the same ID in the other table and ConflictSkip is not set, creation is rejected with this error rather than silently overwriting or shadowing the sibling record. It exists so a wisp and a durable issue can never both exist under one ID.

Source

Thrown at internal/storage/issueops/create.go:645

func checkCrossTableIDCollision(ctx context.Context, tx DBTX, id, issueTable string, opts storage.BatchCreateOptions) (skip bool, err error) {
	if id == "" {
		return false, nil
	}
	siblingTable := "wisps"
	if issueTable == "wisps" {
		siblingTable = "issues"
	}
	var siblingCount int
	if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ?`, siblingTable), id).Scan(&siblingCount); err != nil {
		return false, fmt.Errorf("failed to check cross-table ID collision for %s: %w", id, err)
	}
	if siblingCount == 0 {
		return false, nil
	}
	if opts.ConflictSkip {
		return true, nil
	}
	return false, fmt.Errorf("cannot create %q: ID already exists in the %s table (issues and wisps share one ID space)", id, siblingTable)
}

// InsertIssueIfNew inserts the issue and returns whether it was genuinely new,
// and whether the RejectStaleUpserts guard rejected it.
//
// When opts.ConflictSkip is true and an issue with the same ID already exists,
// the row is left untouched (no UPSERT) and isNew is false. This is the
// auto-import upgrade-recovery guarantee (GH#3955): even if the emptiness
// guard in maybeAutoImportJSONL regresses, a stale issues.jsonl can never
// overwrite live rows — worst case is a no-op. Otherwise the INSERT … ON
// DUPLICATE KEY UPDATE runs, so explicit `bd import` keeps UPSERT semantics;
// with opts.RejectStaleUpserts the update half is conditional on the incoming
// row being strictly newer than the stored one (bd-pkim8, bd-hj85c).
// Staleness is decided by an explicit in-transaction read (stored updated_at
// strictly newer ⇒ rejected) so callers can skip aux persistence and count
// the row as skipped instead of created (bd-578h9.8). Equal-timestamp rows
// are deliberately NOT rejected here, even though the ODKU's
// VALUES(updated_at) > updated_at condition keeps every stored column for

View on GitHub (pinned to 71377f2769)

Solutions

  1. Choose a different ID for the new issue (or let beads auto-assign one).
  2. Delete the conflicting sibling row (the wisp or issue) if it is stale, then retry the create.
  3. Set opts.ConflictSkip if the desired semantics are 'skip if the ID already exists in either plane'.
  4. Promote the wisp properly through PromoteFromEphemeralInTx instead of creating a parallel issue with the same ID.

Example fix

// before
issue.ID = existingWispID
_, err := issueops.CreateIssueInTxWithResult(ctx, tx, issue, issueops.CreateOpts{}) // collision
// after
issue.ID = "" // auto-assign a fresh ID, or use a unique explicit ID
_, err := issueops.CreateIssueInTxWithResult(ctx, tx, issue, issueops.CreateOpts{})
Defensive patterns

Strategy: validation

Validate before calling

// pre-check both planes before creating with an explicit ID
var n int
tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM issues WHERE id = ?", id).Scan(&n)
if n == 0 {
    tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM wisps WHERE id = ?", id).Scan(&n)
}
if n > 0 { id = "" } // fall back to auto-assignment

Type guard

func idIsFreeInBothPlanes(ctx context.Context, tx DBTX, id string) bool {
    for _, table := range []string{"issues", "wisps"} {
        var n int
        if err := tx.QueryRowContext(ctx,
            "SELECT COUNT(*) FROM "+table+" WHERE id = ?", id).Scan(&n); err != nil || n > 0 {
            return false
        }
    }
    return true
}

Try / catch

if err := createIssue(issue); err != nil {
    if strings.Contains(err.Error(), "share one ID space") {
        issue.ID = "" // auto-assign fresh ID
        return createIssue(issue)
    }
    return err
}

Prevention

When it happens

Trigger: Creating an issue whose ID already exists in the wisps table (or vice versa) through CreateIssueInTxWithResult, with opts.ConflictSkip false — e.g. promoting an ephemeral wisp whose ID collides with a durable issue, or explicitly specifying an ID that another plane already uses.

Common situations: Manual ID assignment that happens to collide with an ephemeral wisp; synced data where the same ID was materialized in both planes by an older buggy version; imports that reuse IDs across planes; hierarchical/derived ID generation producing an already-taken ID.

Related errors


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