{"record":{"id":"b5ddb18d34864b96","repo":"gastownhall/beads","slug":"scan-epic-id-w","errorCode":null,"errorMessage":"scan epic id: %w","messagePattern":"scan epic id: %w","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/epic_closure.go","lineNumber":27,"sourceCode":"\n// GetEpicsEligibleForClosureInTx returns epics whose children are all closed.\n// nolint:gosec // G201: table names are hardcoded, placeholders contain only ? markers\nfunc GetEpicsEligibleForClosureInTx(ctx context.Context, tx DBTX) ([]*types.EpicStatus, error) {\n\t// Step 1: Get open epic IDs (single-table scan)\n\tepicRows, err := tx.QueryContext(ctx, `\n\t\tSELECT id FROM issues\n\t\tWHERE issue_type = 'epic'\n\t\t  AND status != 'closed'\n\t`)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get epics: %w\", err)\n\t}\n\tvar epicIDs []string\n\tfor epicRows.Next() {\n\t\tvar id string\n\t\tif err := epicRows.Scan(&id); err != nil {\n\t\t\tepicRows.Close()\n\t\t\treturn nil, fmt.Errorf(\"scan epic id: %w\", err)\n\t\t}\n\t\tepicIDs = append(epicIDs, id)\n\t}\n\tepicRows.Close()\n\n\tif len(epicIDs) == 0 {\n\t\treturn nil, nil\n\t}\n\n\t// Step 2: Get parent-child dependencies from both tables (bd-w2w)\n\t// Wisp children store their parent-child deps in wisp_dependencies,\n\t// so we must check both tables to find all children of an epic.\n\tepicChildMap := make(map[string][]string)\n\tepicSet := make(map[string]bool, len(epicIDs))\n\tfor _, id := range epicIDs {\n\t\tepicSet[id] = true\n\t}\n\tfor _, depTable := range []string{\"dependencies\", \"wisp_dependencies\"} {","sourceCodeStart":9,"sourceCodeEnd":45,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/epic_closure.go#L9-L45","documentation":"While iterating the epic-ID result rows, each row is scanned into a single string. If Scan fails — the row shape does not match one string, or the driver returns a type it cannot convert — the function closes the rows and returns this error. It indicates malformed or unexpected data in the epic result set.","triggerScenarios":"Calling GetEpicsEligibleForClosureInTx when a row returned for the epic query cannot be scanned into a string — e.g. a NULL id, or a driver returning an unexpected column type.","commonSituations":"Manually inserted rows with NULL ids; driver/version mismatches producing different column types; corrupted rows in the issues table.","solutions":["Check for NULL or malformed `id` values in the issues table (`SELECT id FROM issues WHERE issue_type='epic'`).","Fix or remove rows with NULL/invalid ids.","Ensure the storage driver version matches what bd expects.","Retry after repairing the data."],"exampleFix":"// before (raw SQL repair)\nSELECT id FROM issues WHERE issue_type='epic';\n// after\nSELECT id FROM issues WHERE issue_type='epic' AND id IS NOT NULL AND id != '';","handlingStrategy":"validation","validationCode":"rows, _ := db.Query(\"SELECT id FROM issues WHERE issue_type='epic' AND status != 'closed'\")\nfor rows.Next() {\n    var id sql.NullString\n    _ = rows.Scan(&id)\n    if !id.Valid || id.String == \"\" { return fmt.Errorf(\"invalid epic id row\") }\n}","typeGuard":"func isEpicScanErr(err error) bool {\n    return err != nil && strings.Contains(err.Error(), \"scan epic id:\")\n}","tryCatchPattern":"epics, err := GetEpicsEligibleForClosureInTx(ctx, tx)\nif err != nil {\n    if isEpicScanErr(err) { return repairIssueRowsThenRetry(ctx) }\n    return err\n}","preventionTips":["Enforce NOT NULL on issue ids and rely on bd for inserts.","Validate imported data before loading it into the database.","Pin bd and driver versions together.","Audit issues rows after external tooling touches the DB."],"tags":["go","sql","scan","data-integrity"],"backgroundTag":"sql-row-scan-failed","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}