{"record":{"id":"05ea4b2b6f849252","repo":"gastownhall/beads","slug":"refusing-to-write-unexpected-column-q-of-s-w","errorCode":null,"errorMessage":"refusing to write unexpected column %q of %s: %w","messagePattern":"refusing to write unexpected column %q of (.+?): %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/versioncontrolops/conflicts.go","lineNumber":400,"sourceCode":"\t\t// resurrect or destroy a row the operator never looked at.\n\t\treturn fmt.Errorf(\"conflict for %s %s is not a modify/modify conflict (one side has no row); \"+\n\t\t\t\"resolve it with a whole-table strategy or edit the row directly\", table, key)\n\t}\n\n\tif strategy == ConflictStrategyTheirs {\n\t\tnames, vals := row.theirFields(keyCol)\n\t\tif len(names) == 0 {\n\t\t\treturn fmt.Errorf(\"conflict for %s %s carries no their_* data columns\", table, key)\n\t\t}\n\t\tsets := make([]string, len(names))\n\t\targs := make([]any, 0, len(names)+1)\n\t\tfor i, n := range names {\n\t\t\t// Column names are interpolated (MySQL cannot bind an\n\t\t\t// identifier) and come from the conflict table's own schema,\n\t\t\t// which a peer's schema merge can extend — gate them exactly\n\t\t\t// like the table name rather than trusting the source.\n\t\t\tif err := ValidateConflictTable(n); err != nil {\n\t\t\t\treturn fmt.Errorf(\"refusing to write unexpected column %q of %s: %w\", n, table, err)\n\t\t\t}\n\t\t\tsets[i] = fmt.Sprintf(\"`%s` = ?\", n)\n\t\t\targs = append(args, vals[i])\n\t\t}\n\t\targs = append(args, ourKey)\n\t\tstmt := fmt.Sprintf(\"UPDATE `%s` SET %s WHERE `%s` = ?\", table, strings.Join(sets, \", \"), keyCol) //nolint:gosec // identifiers validated above\n\t\tres, err := db.ExecContext(ctx, stmt, args...)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"apply their values for %s %s: %w\", table, key, err)\n\t\t}\n\t\t// Zero rows would mean the row we read the conflict for is no longer\n\t\t// there — another session on the same branch deleted it between the\n\t\t// read and the write. Clearing the conflict now would discard their\n\t\t// side under a --theirs invocation, undetectably. But zero is not\n\t\t// proof of that on its own (see conflictTargetStillPresent), so ask\n\t\t// before refusing: an operator who named this row deserves the abort\n\t\t// only when the row really is gone.\n\t\tif n, err := res.RowsAffected(); err != nil || n == 0 {","sourceCodeStart":382,"sourceCodeEnd":418,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/versioncontrolops/conflicts.go#L382-L418","documentation":"Before building the UPDATE for a 'theirs' resolution, every their_* column name is passed through ValidateConflictTable because identifiers cannot be bound as SQL parameters and a peer's schema merge could have introduced hostile or unexpected column names. When a column name fails validation, the library refuses to interpolate it into the UPDATE, wrapping the validation error.","triggerScenarios":"ResolveConflictRows -> resolveOneConflictRow (theirs strategy) iterates row.theirFields(keyCol); a their_* column name contains characters disallowed by ValidateConflictTable (quotes, backticks, spaces, injection-shaped names) introduced via a peer's schema merge or a corrupted conflict table.","commonSituations":"Merging with a peer whose schema was modified to include odd column names; a malicious/buggy remote pushing a crafted schema; schema drift where conflict-table columns no longer match the base table's validated naming rules.","solutions":["Inspect the peer's schema and rename/remove the offending column before merging","Abort the merge, fix the schema on the offending branch, and redo the merge","Drop and recreate the conflict via whole-table resolution after the schema is corrected","Only merge from trusted remotes; review schema changes before pulling"],"exampleFix":"// before\n-- peer branch schema\ncol `user name` VARCHAR(...)  -- fails ValidateConflictTable\n// after\nALTER TABLE t RENAME COLUMN `user name` TO user_name;","handlingStrategy":"validation","validationCode":"// pre-validate all their_* column names against the same rule the library uses\nfor _, c := range conflictColumns {\n    if err := ValidateConflictTable(strings.TrimPrefix(c, \"their_\")); err != nil {\n        return fmt.Errorf(\"peer schema has invalid column %q: %w\", c, err)\n    }\n}","typeGuard":null,"tryCatchPattern":"err := resolveOne(ctx, db, table, keyCol, key, \"theirs\")\nvar ve *ValidationError\nif err != nil && errors.As(err, &ve) && strings.Contains(err.Error(), \"refusing to write unexpected column\") {\n    return rejectPeerSchema(ctx, table, ve)\n}\nreturn err","preventionTips":["Only merge from trusted remotes","Review schema diffs before pulling peer changes","Enforce naming conventions (snake_case identifiers) repo-wide","Validate identifiers at schema-migration time, not merge time"],"tags":["security","sql-injection","dolt","identifier-validation"],"backgroundTag":"invalid-identifier","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}