{"record":{"id":"20ad4b1c5945d4dd","repo":"gastownhall/beads","slug":"cannot-create-q-id-already-exists-in-the-s-tabl","errorCode":null,"errorMessage":"cannot create %q: ID already exists in the %s table (issues and wisps share one ID space)","messagePattern":"cannot create %q: ID already exists in the (.+?) table \\(issues and wisps share one ID space\\)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/create.go","lineNumber":645,"sourceCode":"func checkCrossTableIDCollision(ctx context.Context, tx DBTX, id, issueTable string, opts storage.BatchCreateOptions) (skip bool, err error) {\n\tif id == \"\" {\n\t\treturn false, nil\n\t}\n\tsiblingTable := \"wisps\"\n\tif issueTable == \"wisps\" {\n\t\tsiblingTable = \"issues\"\n\t}\n\tvar siblingCount int\n\tif err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ?`, siblingTable), id).Scan(&siblingCount); err != nil {\n\t\treturn false, fmt.Errorf(\"failed to check cross-table ID collision for %s: %w\", id, err)\n\t}\n\tif siblingCount == 0 {\n\t\treturn false, nil\n\t}\n\tif opts.ConflictSkip {\n\t\treturn true, nil\n\t}\n\treturn false, fmt.Errorf(\"cannot create %q: ID already exists in the %s table (issues and wisps share one ID space)\", id, siblingTable)\n}\n\n// InsertIssueIfNew inserts the issue and returns whether it was genuinely new,\n// and whether the RejectStaleUpserts guard rejected it.\n//\n// When opts.ConflictSkip is true and an issue with the same ID already exists,\n// the row is left untouched (no UPSERT) and isNew is false. This is the\n// auto-import upgrade-recovery guarantee (GH#3955): even if the emptiness\n// guard in maybeAutoImportJSONL regresses, a stale issues.jsonl can never\n// overwrite live rows — worst case is a no-op. Otherwise the INSERT … ON\n// DUPLICATE KEY UPDATE runs, so explicit `bd import` keeps UPSERT semantics;\n// with opts.RejectStaleUpserts the update half is conditional on the incoming\n// row being strictly newer than the stored one (bd-pkim8, bd-hj85c).\n// Staleness is decided by an explicit in-transaction read (stored updated_at\n// strictly newer ⇒ rejected) so callers can skip aux persistence and count\n// the row as skipped instead of created (bd-578h9.8). Equal-timestamp rows\n// are deliberately NOT rejected here, even though the ODKU's\n// VALUES(updated_at) > updated_at condition keeps every stored column for","sourceCodeStart":627,"sourceCodeEnd":663,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/create.go#L627-L663","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Choose a different ID for the new issue (or let beads auto-assign one).","Delete the conflicting sibling row (the wisp or issue) if it is stale, then retry the create.","Set opts.ConflictSkip if the desired semantics are 'skip if the ID already exists in either plane'.","Promote the wisp properly through PromoteFromEphemeralInTx instead of creating a parallel issue with the same ID."],"exampleFix":"// before\nissue.ID = existingWispID\n_, err := issueops.CreateIssueInTxWithResult(ctx, tx, issue, issueops.CreateOpts{}) // collision\n// after\nissue.ID = \"\" // auto-assign a fresh ID, or use a unique explicit ID\n_, err := issueops.CreateIssueInTxWithResult(ctx, tx, issue, issueops.CreateOpts{})","handlingStrategy":"validation","validationCode":"// pre-check both planes before creating with an explicit ID\nvar n int\ntx.QueryRowContext(ctx, \"SELECT COUNT(*) FROM issues WHERE id = ?\", id).Scan(&n)\nif n == 0 {\n    tx.QueryRowContext(ctx, \"SELECT COUNT(*) FROM wisps WHERE id = ?\", id).Scan(&n)\n}\nif n > 0 { id = \"\" } // fall back to auto-assignment","typeGuard":"func idIsFreeInBothPlanes(ctx context.Context, tx DBTX, id string) bool {\n    for _, table := range []string{\"issues\", \"wisps\"} {\n        var n int\n        if err := tx.QueryRowContext(ctx,\n            \"SELECT COUNT(*) FROM \"+table+\" WHERE id = ?\", id).Scan(&n); err != nil || n > 0 {\n            return false\n        }\n    }\n    return true\n}","tryCatchPattern":"if err := createIssue(issue); err != nil {\n    if strings.Contains(err.Error(), \"share one ID space\") {\n        issue.ID = \"\" // auto-assign fresh ID\n        return createIssue(issue)\n    }\n    return err\n}","preventionTips":["Prefer auto-assigned IDs over explicit ones.","Promote wisps via PromoteFromEphemeralInTx instead of re-creating with the same ID.","Clean up stale wisps before materializing durable issues.","Never copy IDs verbatim between differently-scoped databases."],"tags":["storage","id-collision","transaction"],"backgroundTag":"duplicate-id-collision","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}