{"record":{"id":"7663358d8cfc0424","repo":"we-promise/sure","slug":"label-does-not-belong-to-this-family","errorCode":null,"errorMessage":"#{label} does not belong to this family","messagePattern":"#(.+?) does not belong to this family","errorType":"exception","errorClass":"Category::Merger::UnauthorizedCategoryError","httpStatus":null,"severity":"error","filePath":"app/models/category/merger.rb","lineNumber":42,"sourceCode":"    Category.transaction { merge_sources! }\n    true\n  end\n\n  private\n    def merge_sources!\n      source_categories.each do |source|\n        family.transactions.where(category_id: source.id).update_all(category_id: target_category.id)\n        merge_budget_categories(source)\n        family.categories.where(parent_id: source.id).where.not(id: target_category.id).update_all(parent_id: target_category.id)\n        family.categories.find(source.id).destroy!\n        @merged_count += 1\n      end\n    end\n\n    def validate_category_belongs_to_family!(category, label)\n      return if category&.family_id == family.id\n\n      raise UnauthorizedCategoryError, \"#{label} does not belong to this family\"\n    end\n\n    def validate_hierarchy!\n      target_ancestor_ids = ancestor_ids_for(target_category)\n      return unless source_categories.any? { |source| target_ancestor_ids.include?(source.id) }\n\n      raise UnauthorizedCategoryError, \"A parent category cannot be merged into its own subcategory\"\n    end\n\n    def validate_reparenting!\n      return if target_category.parent_id.blank?\n      return unless source_categories.any? { |source| family.categories.exists?(parent_id: source.id) }\n\n      raise UnauthorizedCategoryError, \"Cannot merge a category with subcategories into a subcategory\"\n    end\n\n    def ancestor_ids_for(category)\n      ids = []","sourceCodeStart":24,"sourceCodeEnd":60,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/category/merger.rb#L24-L60","documentation":"Category::Merger#validate_category_belongs_to_family! raises UnauthorizedCategoryError when a category passed as the merge target or as one of the sources belongs to a different family than the merger's family. It is a multi-tenant ownership guard: category.family_id must equal family.id for every category handed to the merge.","triggerScenarios":"Building Category::Merger.new(family:, target_category:, source_categories:) with category objects fetched outside the family scope — params-supplied IDs resolved with Category.find, IDs belonging to another family, or stale client IDs after a family switch.","commonSituations":"Controller/API endpoints that trust client-provided category IDs without scoping to current_family; scripts or imports referencing category IDs across families; tests using fixtures from a different family.","solutions":["Resolve every category through the family scope: family.categories.find(id) instead of Category.find(id)","Return 404/422 from the endpoint when an ID does not exist within the current family instead of letting the merger raise","If occurrences are real traffic, audit the client for cross-family ID leakage"],"exampleFix":"// before\ntarget = Category.find(params[:target_id])\nmerger = Category::Merger.new(family: family, target_category: target, source_categories: sources)\n\n// after\ntarget = family.categories.find(params[:target_id]) # RecordNotFound stays scoped to the family\nmerger = Category::Merger.new(family: family, target_category: target, source_categories: sources)","handlingStrategy":"validation","validationCode":"family.categories.where(id: target_category_ids + source_category_ids).count == ids.uniq.length # all IDs resolvable within the family","typeGuard":null,"tryCatchPattern":"rescue Category::Merger::UnauthorizedCategoryError (or the merger's error class) in the controller and return 422 without leaking that the IDs exist in another family","preventionTips":["Never resolve merge IDs with global Category.find; always scope family.categories.find","Treat out-of-scope category IDs as 404, not 403, to avoid cross-family probing","Add controller tests that submit foreign-family category IDs"],"tags":["categories","merge","authorization","multi-tenant"],"backgroundTag":"cross-tenant-access-denied","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}