{"record":{"id":"b2c7e55c0b6e8268","repo":"gastownhall/beads","slug":"db-update-field-q-is-not-allowed","errorCode":null,"errorMessage":"db: Update: field %q is not allowed","messagePattern":"db: Update: field %q is not allowed","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/domain/db/issue.go","lineNumber":239,"sourceCode":"\t// work. The wrap keeps the sentinels matchable, so a caller distinguishes\n\t// these refusals here exactly as it does on the close path.\n\tif statusChanging {\n\t\tcrossing, err := issueops.CrossesIntoDoneCategoryInTx(ctx, r.runner, oldIssue.Status, updates)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"db: Update %s: %w\", id, err)\n\t\t}\n\t\tif crossing {\n\t\t\tif _, err := issueops.EnforceClosePolicyInTx(ctx, r.runner, id, forceClosePolicy); err != nil {\n\t\t\t\treturn fmt.Errorf(\"db: Update %s: %w\", id, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tsetClauses := make([]string, 0, len(updates)+3)\n\targs := make([]any, 0, len(updates)+4)\n\tfor key, value := range updates {\n\t\tif _, ok := allowedUpdateFields[key]; !ok {\n\t\t\treturn fmt.Errorf(\"db: Update: field %q is not allowed\", key)\n\t\t}\n\t\tcolumn := key\n\t\tif renamed, ok := updateFieldColumnRename[key]; ok {\n\t\t\tcolumn = renamed\n\t\t}\n\t\tsetClauses = append(setClauses, fmt.Sprintf(\"`%s` = ?\", column))\n\t\targs = append(args, normalizeUpdateValue(key, value))\n\t}\n\tsetClauses = append(setClauses, \"updated_at = ?\")\n\targs = append(args, time.Now().UTC())\n\n\t// Lifecycle parity with issueops.updateIssueInTx: auto-manage closed_at and\n\t// started_at from the status transition unless the caller set them\n\t// explicitly.\n\tif statusChanging {\n\t\tsetClauses, args = issueops.ManageClosedAt(oldIssue, updates, setClauses, args)\n\t\tsetClauses, args = issueops.ManageStartedAt(oldIssue, updates, setClauses, args)\n\t}","sourceCodeStart":221,"sourceCodeEnd":257,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/domain/db/issue.go#L221-L257","documentation":"Direct field-allowlist rejection from Update: every key in the updates map must exist in allowedUpdateFields (status, priority, title, assignee, owner, description, design, acceptance_criteria, notes, issue_type, estimated_minutes, external_ref, spec_id, started_at, closed_at, close_reason, closed_by_session, source_repo, sender, wisp, wisp_type, no_history, pinned, mol_type, event_kind, actor, target, payload, due_at, defer_until, await_id, waiters, metadata). Unknown keys are refused with this error before any SQL is built, preventing accidental column injection.","triggerScenarios":"Calling Update with an updates map containing a key not in allowedUpdateFields — typos (e.g. 'acceptance' instead of 'acceptance_criteria'), camelCase keys ('externalRef'), internal-only columns, or keys that only exist on create.","commonSituations":"Hand-building update maps and misspelling a field; mapping API/JSON field names (camelCase) directly into the updates map; forwarding user-supplied key/value pairs straight into Update; code written against a different backend whose field set differs.","solutions":["Compare the offending key (echoed in the error with %q) against allowedUpdateFields in internal/storage/domain/db/issue.go and rename it to the canonical snake_case name.","Validate/whitelist keys in your own layer before building the updates map.","Do not forward arbitrary user input as update keys.","Note 'wisp' is allowed and renamed to the ephemeral column via updateFieldColumnRename — use the field name, not the column name."],"exampleFix":"// before\ncamelCase from user input\nupdates := map[string]any{\"externalRef\": \"JIRA-1\"}\n// after\ncanonical field name\nupdates := map[string]any{\"external_ref\": \"JIRA-1\"}","handlingStrategy":"validation","validationCode":"// mirror the repo allowlist before building the map\nvar allowed = map[string]bool{\n    \"status\": true, \"priority\": true, \"title\": true, \"assignee\": true, \"owner\": true,\n    \"description\": true, \"design\": true, \"acceptance_criteria\": true, \"notes\": true,\n    \"issue_type\": true, \"estimated_minutes\": true, \"external_ref\": true, \"spec_id\": true,\n    \"started_at\": true, \"closed_at\": true, \"close_reason\": true, \"closed_by_session\": true,\n    \"source_repo\": true, \"sender\": true, \"wisp\": true, \"wisp_type\": true,\n    \"no_history\": true, \"pinned\": true, \"metadata\": true,\n}\nfor k := range updates {\n    if !allowed[k] { return fmt.Errorf(\"field %q not updatable\", k) }\n}","typeGuard":"func validUpdateKeys(updates map[string]any, allowed map[string]struct{}) bool {\n    for k := range updates {\n        if _, ok := allowed[k]; !ok { return false }\n    }\n    return true\n}","tryCatchPattern":"if err := repo.Update(ctx, id, updates, actor, opts); err != nil {\n    var bad string\n    if n, _ := fmt.Sscanf(err.Error(), \"db: Update: field %q is not allowed\", &bad); n == 1 {\n        delete(updates, bad) // or rename to the canonical field and retry\n    }\n}","preventionTips":["Use the canonical snake_case field names from allowedUpdateFields","Convert camelCase/API names before building the map","Never forward raw user-supplied keys into updates","Use 'wisp' (field name), not 'ephemeral' (column name) — renaming is handled by updateFieldColumnRename"],"tags":["database","validation","allowlist","field-name"],"backgroundTag":"unknown-update-field","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}