{"record":{"id":"7ab620fe863bba18","repo":"gastownhall/beads","slug":"unsupported-issue-table-q","errorCode":null,"errorMessage":"unsupported issue table %q","messagePattern":"unsupported issue table %q","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/issueops/delete.go","lineNumber":362,"sourceCode":"\n\tif err := RecomputeIsBlockedInTx(ctx, tx, affectedIssues, affectedWisps); err != nil {\n\t\treturn nil, fmt.Errorf(\"recompute is_blocked after batch delete: %w\", err)\n\t}\n\n\treturn result, nil\n}\n\n// ExistingIssueIDsInTableInTx returns the requested IDs that currently exist\n// in the selected issue table. It preserves caller ordering so delete and\n// journal records are deterministic across batches.\nfunc ExistingIssueIDsInTableInTx(ctx context.Context, tx DBTX, table string, ids []string) ([]string, error) {\n\tif len(ids) == 0 {\n\t\treturn nil, nil\n\t}\n\tswitch table {\n\tcase \"issues\", \"wisps\":\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported issue table %q\", table)\n\t}\n\texists := make(map[string]struct{}, len(ids))\n\tfor i := 0; i < len(ids); i += deleteBatchSize {\n\t\tend := i + deleteBatchSize\n\t\tif end > len(ids) {\n\t\t\tend = len(ids)\n\t\t}\n\t\tinClause, args := buildSQLInClause(ids[i:end])\n\t\t//nolint:gosec // table is validated above and inClause contains only placeholders.\n\t\trows, err := tx.QueryContext(ctx, \"SELECT id FROM \"+table+\" WHERE id IN (\"+inClause+\")\", args...)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tfor rows.Next() {\n\t\t\tvar id string\n\t\t\tif err := rows.Scan(&id); err != nil {\n\t\t\t\t_ = rows.Close()\n\t\t\t\treturn nil, err","sourceCodeStart":344,"sourceCodeEnd":380,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/delete.go#L344-L380","documentation":"ExistingIssueIDsInTableInTx only accepts the table names \"issues\" or \"wisps\"; any other table argument returns this error before any query runs. It is a defensive guard against SQL injection via the dynamically interpolated table name and against programming mistakes.","triggerScenarios":"Calling ExistingIssueIDsInTableInTx (directly or via journalableDeletesInTx) with a table string other than \"issues\" or \"wisps\" — e.g. a caller passes \" Issues\", \"issue\", or a variable that is empty or user-derived.","commonSituations":"Internal callers/new code paths passing a caller-supplied table parameter; refactors that renamed tables; tests exercising the function with a made-up table name.","solutions":["Pass exactly \"issues\" or \"wisps\" as the table argument","Normalize/validate the table value at the call site before invoking","If you need a new table, add it to the switch's allowed cases in delete.go"],"exampleFix":"// before\ntable := req.TableName // e.g. \"Issues\"\nids, err := ExistingIssueIDsInTableInTx(ctx, tx, table, ids)\n// after\ntable := strings.ToLower(strings.TrimSpace(req.TableName))\nif table != \"issues\" && table != \"wisps\" { table = \"issues\" }\nids, err := ExistingIssueIDsInTableInTx(ctx, tx, table, ids)","handlingStrategy":"validation","validationCode":"func validIssueTable(t string) bool { return t == \"issues\" || t == \"wisps\" }\n// call only if validIssueTable(table)","typeGuard":"func isIssueTable(t string) bool {\n\treturn t == \"issues\" || t == \"wisps\"\n}","tryCatchPattern":"if !isIssueTable(table) {\n\treturn fmt.Errorf(\"refusing call: table %q must be issues or wisps\", table)\n}\nids, err := ExistingIssueIDsInTableInTx(ctx, tx, table, ids)","preventionTips":["Only pass the literal constants \"issues\" or \"wisps\"","Validate/normalize any table value derived from config or user input at the boundary","Add a unit test covering the reject path for unknown table names"],"tags":["validation","internal-error","sql-injection-guard"],"backgroundTag":"invalid-argument-value","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}