[AI Knowledge Base] Remove issue model
# Plan: Remove the Issue Model
## Motivation
The dual-class architecture where WorkItem inherits from Issue creates a constant tax on every feature we build. Engineers have to reason about whether something is an Issue or a WorkItem, decide which model to put new functionality on, and handle cases where behaviour is needed on both the legacy Issue endpoints and the new WorkItem surface. In practice this means logic that conceptually belongs to WorkItem keeps getting added to Issue because the legacy API and controllers still instantiate Issue directly. The result is an ever-growing Issue model that accumulates tech debt instead of shrinking, and a WorkItem that can never fully own its domain.
This is not a theoretical cleanliness concern. It slows down day-to-day development, introduces subtle bugs when the two classes diverge in behaviour, and makes onboarding harder because the relationship between the two is non-obvious. Collapsing them into a single class eliminates the ambiguity entirely: there is one model, it is called WorkItem, and the Issue constant exists only as a backward-compatible alias until all references are migrated.
## Executive Summary
WorkItem < Issue < ApplicationRecord -- both classes share the issues table with STI disabled. Issue carries the bulk of domain logic (1047 lines, 29 concerns, 64 inbound FKs), while WorkItem layers on the widget system, hierarchy, and new-world features. Issuable is a concern shared only between Issue and MergeRequest, providing the common interface for title/description, state, labels, milestones, assignees, notes, and sorting.
The goal is to make WorkItem the single authoritative model backed by the issues table, with Issue reduced to a constant alias for backward compatibility. This document is a strategic plan for that migration. It covers the current architecture, viable migration approaches, the polymorphic compatibility abstraction needed, and the phased execution plan.
This issue serves as the knowledge base and investigation results collection for this effort. Findings, decisions, and open questions are tracked here and updated as work progresses.
## Current Architecture
<details><summary>Click to expand architecture details</summary>
### The Three Layers
```
Issuable (concern)
+-- included by Issue
| +-- inherited by WorkItem
+-- included by MergeRequest
```
**Issuable** (731 lines + 111 EE) provides: state management, author/assignee scaffolding, label/milestone associations, notes, sorting, search, webhook payloads, markdown caching, subscriptions, awards, and 22 nested concerns. It expects columns like title, description, state_id, author_id, project_id, milestone_id on the including model. It does *not* define assignees -- that is left to the includer.
**Issue** (1047 lines + substantial EE) provides: the state machine, all associations (64 inbound FKs from other tables), 45+ scopes, reference patterns (#123), relative positioning, service desk, confidentiality, design collection, incident/severity support, and the work_item_type_id type system via HasType. It defines the issues table schema (29 columns, 32 indexes).
**WorkItem** (~200 lines + EE) adds: parent/child hierarchy (WorkItems::ParentLink), the widget system (19 CE + 11 EE widget types), hierarchy restrictions, and method overrides for references, notifications, and todos. It disables STI (self.inheritance_column = :_type_disabled) and re-sets self.table_name = 'issues'.
After the migration, the new world is: **MergeRequest and WorkItem are Issuables.** Incidents, epics, tasks, etc. are all work item types distinguished by work_item_type_id and the widget system, not by class hierarchy.
### The Widget System
WorkItem's key architectural contribution. Each work item type declares which widgets it supports. Widgets are small objects that delegate to the underlying model but provide a composable feature surface:
- Assignees, Description, Labels, Milestone, Notes -- wrappers around existing Issue associations
- Hierarchy, StartAndDueDate, LinkedItems -- add WorkItem-specific behaviour
- TimeTracking, Designs, Development, CrmContacts -- domain feature wrappers
- EE: Weight, Iteration, HealthStatus, Progress, Color, Status, CustomFields
The callback system (WidgetableService) routes widget-specific params through create/update services without polluting the core model.
### The Type System
Actively transitioning from DB-backed WorkItems::Type (in work_item_types table) to in-memory FixedItemsModel-based SystemDefined::Type with a Provider abstraction. Nine base types: Issue, Incident, TestCase, Requirement, Task, Objective, KeyResult, Epic, Ticket. Custom types are scoped per namespace/org.
### Dual-Class, Single-Table
Both Issue and WorkItem read/write the same issues table rows. Any row can be instantiated as either class. Issue#== explicitly handles this: other.is_a?(Issue) && other.id == id. The differentiation is purely at the Ruby layer -- work_item_type_id is the semantic discriminator but does not drive Rails STI.
</details>
## Scale of the Problem
| Category | Count | Risk |
|----------|-------|------|
| Files referencing Issue class (app/lib) | ~478 | Mechanical but widespread |
| class_name: 'Issue' in associations | 19 | Handled by constant alias |
| target_type: 'Issue' in DB rows | ~93 code refs | Handled by polymorphic abstraction |
| noteable_type: 'Issue' in DB rows | ~115 code refs | Handled by polymorphic abstraction |
| foreign_key: :issue_id associations | 33 | Column name stays; low risk |
| Factory usage in specs | ~3,500 | Alias factory; bulk rename later |
| Issue-specific serializers/presenters/entities | 18 files | Bounded scope |
| Inbound foreign keys to issues table | 64 | Schema stays unchanged |
### Key Architectural Dependencies
1. **WorkItemPolicy < IssuePolicy < IssuablePolicy** -- The policy chain must be preserved or restructured. WorkItemPolicy inherits and maps abilities from IssuePolicy.
2. **WorkItems::CreateService < Issues::CreateService** -- Service inheritance. Same for UpdateService. These consolidate into the WorkItems:: namespace; the Issues:: layer becomes aliases.
3. **Polymorphic type strings in DB** -- notes.noteable_type, todos.target_type, label_links.target_type, events.target_type all store 'Issue'. These strings stay in the DB permanently. A polymorphic compatibility abstraction (see below) ensures they resolve to WorkItem transparently.
4. **REST API** -- lib/api/issues.rb (591 lines) and lib/api/work_items.rb (284 lines) both exist. The /issues endpoints are a public API contract and stay permanently. Internal implementation moves to WorkItem under the hood.
5. **GraphQL** -- Types::IssueType and Types::WorkItemType are separate types. The IssueType contract stays intact -- fields remain available. Internally both can resolve against WorkItem. WorkItemIdType already accepts both GID formats as a shim.
6. **Importers** -- GitHub, Bitbucket, FogBugz, Jira importers all instantiate Issue directly. These continue to work via the constant alias and migrate to WorkItem progressively.
7. **becomes(::WorkItem)** -- Used in 3 places to coerce Issue instances to WorkItem. Becomes unnecessary once Issue = WorkItem.
## Compatibility Shims Already in Place
<details><summary>Click to expand existing shims</summary>
The codebase already has several mechanisms bridging the two classes:
- WorkItemIdType accepts both gid://gitlab/Issue/N and gid://gitlab/WorkItem/N
- WorkItem#noteable_target_type_name returns 'issue' to maintain polymorphic storage
- WorkItem#todoable_target_type_name returns %w[Issue WorkItem] for dual-type todo lookup
- Todos::Destroy::DestroyedIssuableService handles both target types
- Issue#to_work_item_global_id produces WorkItem GIDs from Issue instances
- Issuable#order_labels_priority maps "WorkItem" to "Issue" for target_type
</details>
## Polymorphic Compatibility Abstraction
The biggest concern with removing Issue is the millions of rows in the database storing 'Issue' as a polymorphic type string (noteable_type, target_type, etc.). Migrating these is impractical and unnecessary. Rails provides the exact hooks needed to handle this transparently.
<details><summary>Click to expand technical details</summary>
### How Rails Resolves Polymorphic Types
**Write path:** When Rails writes a polymorphic _type column, it calls record.class.polymorphic_name. By default this returns base_class.name. Currently WorkItem.polymorphic_name returns 'Issue' because WorkItem < Issue and Issue is the base class.
**Read path:** When Rails reads a polymorphic association, it calls owner.class.polymorphic_class_for(type_string), which by default does type_string.constantize. So 'Issue'.constantize returns the Issue class.
### The Abstraction
Once WorkItem stops inheriting from Issue, override both hooks:
```ruby
# app/models/work_item.rb
class WorkItem < ApplicationRecord
def self.polymorphic_name
'Issue'
end
end
```
This ensures all new polymorphic writes continue to store 'Issue' in the DB. No dual-write period, no data migration.
For the read path, override the resolver globally:
```ruby
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
def self.polymorphic_class_for(name)
return WorkItem if name == 'Issue'
super
end
end
```
This ensures every polymorphic lookup of 'Issue' resolves to WorkItem, regardless of which model owns the association.
### Gotcha: Note#noteable_type= Custom Setter
Note has a custom setter (line 447) that explicitly resolves to base_class:
```ruby
def noteable_type=(noteable_type)
super(noteable_type.to_s.classify.constantize.base_class.to_s)
end
```
Once WorkItem no longer inherits from Issue, WorkItem.base_class becomes WorkItem, and this setter would store 'WorkItem' instead of 'Issue'. Fix: update the setter to use polymorphic_name instead of base_class.to_s, or override base_class on WorkItem during the transition.
### Existing Precedent
Namespaces::ProjectNamespace already does this in the codebase -- it overrides both sti_name (returns 'Project') and polymorphic_name (returns 'Namespaces::ProjectNamespace') to decouple STI column storage from polymorphic type storage. The pattern is proven and understood.
</details>
## Migration Approaches
### Why "Lift Logic Up" Does Not Work Incrementally
The original framing proposed extracting logic from Issue into concerns and including them directly in WorkItem, gradually thinning Issue out. This has a fundamental problem: as long as anything in the codebase instantiates Issue and expects full functionality (associations, scopes, state machine), you cannot move that functionality out of Issue without breaking it. Moving an association from Issue to WorkItem (the subclass) means Issue.new loses it. You would have to move everything in a single atomic change -- which is just the alias approach with extra steps.
The only incremental variant would be extracting code into concerns and including them in *both* Issue and WorkItem during the transition, but that is pointless busywork since they share the same table and WorkItem already inherits everything from Issue.
"Lift logic up" describes the *end state* (WorkItem is self-contained), not a viable *migration strategy*.
### Approach 1: Alias-First (Recommended)
Rename the Issue class body to WorkItem, then set Issue = WorkItem. Everything works immediately -- same class, two names.
**How it works in practice:**
1. Move the contents of app/models/issue.rb into app/models/work_item.rb. WorkItem becomes < ApplicationRecord directly, includes Issuable and all other concerns that Issue currently includes.
2. app/models/issue.rb becomes: Issue = WorkItem (a constant alias).
3. All existing code continues to work. Issue.new returns a WorkItem. is_a?(Issue) returns true for any WorkItem. class_name: 'Issue' associations resolve to WorkItem. Factories, services, policies -- everything keeps functioning.
4. The polymorphic abstraction (see above) ensures DB strings stay 'Issue' and resolve correctly.
5. Then progressively: rename references from Issue to WorkItem across the codebase, consolidate duplicate services/policies, update factories, and eventually remove the alias.
**Pros:**
- Fastest path to single-class state
- Zero breakage at the switch point -- the alias is a perfect shim
- Progressive cleanup can happen over many milestones
- No dual-include busywork
**Cons:**
- The Issue alias tends to stick around if cleanup stalls
- The initial merge is large (moving ~1000 lines between files)
- All instance_of?(Issue) checks (37+) need auditing -- with a constant alias, instance_of?(Issue) returns true since Issue IS WorkItem
**Risk mitigation:** Feature-flag the alias. Behind the flag, Issue = WorkItem. Without the flag, the current inheritance is preserved. This allows safe rollback.
### Approach 2: Incremental Inversion
Keep the inheritance but progressively swap who is parent and who is child, arriving at class Issue < WorkItem as the deprecation shim.
**How it works in practice:**
1. Extract Issue logic into concerns (state machine, associations, scopes, etc.) -- but include them in *both* Issue and WorkItem during the transition.
2. Once all logic lives in concerns, make WorkItem < ApplicationRecord and include everything there.
3. Issue becomes class Issue < WorkItem -- an empty subclass.
4. Progressively migrate callers from Issue to WorkItem.
5. Remove Issue once empty.
**Pros:**
- Each step is small and reviewable
- No single large merge
- Familiar refactoring pattern
**Cons:**
- Much slower -- the concern extraction phase is extensive and provides no user-facing value
- During the dual-include phase, there is duplication and confusion about which class to use
- Issue < WorkItem as a subclass creates a weird inheritance direction during transition
- instance_of?(Issue) behaves differently from the alias approach -- it returns true for Issue but false for WorkItem, which may break things that currently rely on WorkItem instances also being instances of Issue
### Recommendation
Approach 1 (alias-first). The concern extraction work in Approach 2 is mechanical overhead that produces no value on its own. The alias approach gets to the same end state faster and with a simpler mental model: there is one class, it is called WorkItem, and Issue is just another name for it.
## Execution Plan
### Phase 1: Preparation (no user-facing changes)
1. **Audit all instance_of?(Issue) and is_a?(Issue) checks** (37+). Classify each as: works with alias (most), needs updating to is_a?(WorkItem), or needs a different guard entirely.
2. **Audit all Issue. class method call sites** (~214 in production). Group into: works via alias (most), needs a WorkItem-specific override, or needs deeper refactoring.
3. **Build the polymorphic compatibility abstraction.** Override WorkItem.polymorphic_name and ApplicationRecord.polymorphic_class_for as described above. This can be done *before* the alias switch -- it is a no-op while the inheritance still exists but proves the mechanism works.
4. **Fix the Note#noteable_type= setter** to use polymorphic_name instead of base_class.to_s. Safe to do pre-migration.
5. **Update the :issue factory** to build a WorkItem instance internally. This is a zero-risk change that immediately validates compatibility across the entire test suite.
### Phase 2: The Alias Switch
1. **Move the contents of issue.rb into work_item.rb.** WorkItem becomes < ApplicationRecord, includes Issuable and all Issue concerns directly. Remove self.inheritance_column = :_type_disabled (no longer needed). Keep self.table_name = 'issues'.
2. **app/models/issue.rb becomes:**
```ruby
Issue = WorkItem
```
3. **Gate behind a feature flag** for safe rollback. The flag controls whether to load the alias version or the original inheritance version.
4. **Validate:** run the full test suite. The :issue factory change from Phase 1 will have already caught most incompatibilities.
### Phase 3: Progressive Consolidation
No specific ordering required -- these are independent workstreams that can happen across milestones.
1. **Consolidate policies.** Absorb IssuePolicy into WorkItemPolicy. The ability mapping (admin_issue to admin_work_item etc.) becomes internal aliases within a single policy class.
2. **Consolidate services.** Move logic from Issues::CreateService / UpdateService into WorkItems::CreateService / UpdateService. Set Issues::CreateService = WorkItems::CreateService as transitional aliases. Eventually remove the Issues:: namespace.
3. **Consolidate serializers, presenters, helpers.** 18 Issue-specific files to merge into WorkItem equivalents or make generic.
4. **Consolidate GraphQL types.** Types::IssueType stays as a public contract but resolves against WorkItem internally. No field removal. Over time, encourage clients to migrate to Types::WorkItemType.
5. **Update REST API internals.** /projects/:id/issues endpoints stay. Controllers switch from Issue to WorkItem internally (or rely on the alias).
6. **Update importers.** GitHub, Bitbucket, FogBugz, Jira importers switch to WorkItem. No urgency -- the alias handles it.
7. **Rename factories.** Bulk update from create(:issue) to create(:work_item). Keep :issue as an alias for the transition.
### Phase 4: Remove the Alias
Once all direct Issue references are cleaned up:
1. Remove app/models/issue.rb (the alias file)
2. Remove Issue constant entirely
3. Remove any remaining compatibility shims
4. Remove Issue-namespaced serializers, presenters, helpers, policies, services
## Decisions
- [x] **Table stays 'issues'.** No rename. 64 inbound FKs, issue_id in 33+ associations. Not worth it.
- [x] **Polymorphic type strings stay 'Issue' in the DB.** No data migration. The polymorphic compatibility abstraction handles resolution transparently.
- [x] **REST API endpoints stay at /issues.** Public contract. Internal implementation switches to WorkItem.
- [x] **GraphQL IssueType stays.** Field contract preserved. Internal resolution switches to WorkItem.
- [x] **Services consolidate into WorkItems:: namespace.** Issues:: becomes aliases during transition, then removed.
- [x] **Factory approach: alias first, rename later.** The :issue factory builds a WorkItem under the hood. Bulk rename to :work_item happens progressively.
- [x] **All issues table columns are WorkItem columns.** moved_to_id, duplicated_to_id, service_desk_reply_to, promoted_to_epic_id are all actively used through WorkItem codepaths. Same migration path as everything else.
## Open Questions
1. **Feature flag strategy.** The alias switch (Phase 2) should be gated. The existing flags (work_item_planning_view, work_items_consolidated_list_user, work_item_legacy_url) gate the UI transition. We likely need a separate flag for the model-level change. Naming TBD.
2. **Issuable concern type checks.** incident_type_issue? uses is_a?(Issue). Post-alias, this becomes equivalent to is_a?(WorkItem). MergeRequest still returns false, which is correct. The full audit in Phase 1 will surface any checks where this semantic shift matters.
3. **becomes(::WorkItem) calls.** Three places coerce Issue to WorkItem. Post-alias these become no-ops (the object is already a WorkItem). They can be removed but are not blocking.
4. **Duplicated service exists only under Issues::.** Issues::DuplicateService has no WorkItems:: equivalent yet. Needs to be created or the existing one renamed during Phase 3.
5. **Ordering of Phase 3 workstreams.** Which consolidation should happen first? Policy and service consolidation have the highest architectural value. Factory rename has the highest line-count impact. No hard dependencies between them.
## Sources
<details><summary>Click to expand</summary>
- app/models/issue.rb -- 1047 lines
- app/models/work_item.rb -- ~200 lines
- app/models/concerns/issuable.rb -- 731 lines
- ee/app/models/concerns/ee/issuable.rb -- 111 lines
- ee/app/models/ee/issue.rb -- substantial
- ee/app/models/ee/work_item.rb -- substantial
- doc/development/work_items.md -- architecture doc
- doc/development/work_items_widgets.md -- widget system doc
- app/policies/issue_policy.rb / app/policies/work_item_policy.rb
- app/graphql/types/issue_type.rb / app/graphql/types/work_item_type.rb
- lib/api/issues.rb / lib/api/work_items.rb
- app/models/note.rb -- custom noteable_type= setter (line 447)
- app/models/namespaces/project_namespace.rb -- precedent for polymorphic_name override
</details>
issue