-
Notifications
You must be signed in to change notification settings - Fork 927
[PM-28468] Updated validation and navigation for MigrateToMyItems #6279
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
[PM-28468] Updated validation and navigation for MigrateToMyItems #6279
Conversation
|
Great job! No new security vulnerabilities introduced in this pull request |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6279 +/- ##
==========================================
- Coverage 85.52% 85.49% -0.03%
==========================================
Files 763 764 +1
Lines 54621 54715 +94
Branches 7852 7881 +29
==========================================
+ Hits 46712 46776 +64
- Misses 5181 5201 +20
- Partials 2728 2738 +10 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
Claude finished @aj-rosado's task in 5m 22s —— View job Overall Assessment: REQUEST CHANGES Reviewed vault migration navigation implementation including new VaultMigrationManager, database schema changes, and RootNavViewModel integration. Found critical issues with database migration configuration and flow observation patterns that must be addressed before merge. Code Review Details
Key Strengths:
Critical Fixes Required:
|
app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt
Outdated
Show resolved
Hide resolved
| val orgId = policyManager.getPersonalOwnershipPolicyOrganizationId() | ||
| val orgName = authRepository.userStateFlow.value | ||
| ?.activeAccount | ||
| ?.organizations | ||
| ?.firstOrNull { it.id == orgId } | ||
| ?.name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❓ Passing args makes for a simpler ViewModel; specifically in regards to handling scenarios when either orgId or orgName are null. Is there a reason why this approach is taken instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have changed this quite a bit from original idea. RootNavViewModel only verifies if it should navigate from the shouldMigratePersonalVaultFlow, not having any info regarding the org that should do the migration.
This was changed because if the vault was updated on a different client, the migration flow would still be displayed as it would not check with the most synced data. Now we are syncing before displaying the screen to make sure we are not displaying the screen unless it is needed.
This being said, I agree that if we could pass the parameters would simplify the VM, not sure about a good way to achieve it, only that I can think about is the flow having the Organization object?
| val shouldMigrate = policyManager | ||
| .getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP) | ||
| .any() && | ||
| featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) && | ||
| connectionManager.isNetworkConnected && | ||
| cipherList.any { it.organizationId == null } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⛏️ 🎨 Not a mandatory change request, but some of this duplication could be eliminated if userShouldMigrate() is changed to accept hasPersonalItems as a lambda.
private fun userShouldMigrateVault(
hasPersonalItems: () -> Boolean,
) : Boolean {
return connectionManager.isNetworkConnected &&
featureFlagManager.getFeatureFlag(...) &&
policyManager.getActivePolicies(...).any() &&
hasPersonalItems
}
private fun verifyAndUpdateIfUserShouldMigrateVaultToMyItems(
cipherList: List<Cipher>,
) {
mutableShouldMIgratePersonalVaultFlow.update {
userShouldMigrateVault {
cipherList.any { it.organizationId == null }
}
}
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would also be a performance improvement since it would short-circuit before attempting to iterate over the entire cipher collection when any of the preceding conditions are false.
…arameter to MigrateToMyItems
…ation # Conflicts: # app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModelTest.kt
app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManager.kt
Outdated
Show resolved
Hide resolved
app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt
Outdated
Show resolved
Hide resolved
| result.successes.any { it.organizationId == null } | ||
| } | ||
| ) { | ||
| sync(forced = true) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IDK how I feel about having side-affects in this flow 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not feel totally comfortable with it as well. Any idea of a better approach?
Otherwise we will display the takeover to the user until a sync is forced even if the user does not need to do the migration anymore
app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt
Outdated
Show resolved
Hide resolved
| if (orgId != null && orgName != null) { | ||
| VaultMigrationData.MigrationRequired( | ||
| organizationId = orgId, | ||
| organizationName = orgName, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The VaultSyncManager handling the migration feels a little odd to me. It make it responsible for more scope than it maybe should be responsible for.
What would you think of a VaultMigrationManager that consumed the cipher data to handle the migration?
| vaultMigrationManager.verifyAndUpdateMigrationState( | ||
| userId = userId, | ||
| cipherList = it, | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This still feels like an inappropriate place to have this side-affect.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed. I think we can encapsulate all of this into VaultMigrationManager if we shuffle around where and how we're observing things. 🤔
app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultMigrationManagerImpl.kt
Outdated
Show resolved
Hide resolved
Addresses architectural concern about side effect location in VaultSyncManagerImpl. VaultMigrationManager now reactively observes cipher data changes via observeWhenSubscribedAndUnlocked instead of being explicitly called by VaultSyncManager. Changes: - VaultMigrationManager observes VaultDiskSource cipher flow directly - Removed verifyAndUpdateMigrationState() from public API - Removed VaultMigrationManager dependency from VaultSyncManagerImpl - Complete separation of concerns between sync and migration logic - Refactored tests to validate reactive behavior with flow emissions
...in/kotlin/com/x8bit/bitwarden/ui/vault/feature/migratetomyitems/MigrateToMyItemsViewModel.kt
Outdated
Show resolved
Hide resolved
| @HiltViewModel | ||
| class MigrateToMyItemsViewModel @Inject constructor( | ||
| private val organizationEventManager: OrganizationEventManager, | ||
| val vaultMigrationManager: VaultMigrationManager, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
♻️ DEBT: Unused dependency injection
Details and fix
The vaultMigrationManager parameter is injected but never used in this ViewModel. This creates unnecessary coupling and adds confusion about the intended purpose.
Fix:
Remove the unused parameter:
@HiltViewModel
class MigrateToMyItemsViewModel @Inject constructor(
private val organizationEventManager: OrganizationEventManager,
val vaultSyncManager: VaultSyncManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<...>Rationale:
The migration state observation happens in RootNavViewModel, not here. This ViewModel only needs to trigger a sync and handle user actions.
| specialCircumstanceManager.specialCircumstanceStateFlow, | ||
| ) { authState, userState, specialCircumstance -> | ||
| vaultMigrationManager.vaultMigrationDataStateFlow, | ||
| ) { authState, userState, specialCircumstance, shouldMigratePersonalVault -> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎨 SUGGESTED: Variable name implies boolean but receives sealed class
Details and fix
The parameter name shouldMigratePersonalVault suggests a boolean value, but it actually receives VaultMigrationData (a sealed class with migration state and metadata).
Current:
) { authState, userState, specialCircumstance, shouldMigratePersonalVault ->Suggested:
) { authState, userState, specialCircumstance, vaultMigrationData ->Rationale:
Aligning the variable name with its actual type reduces cognitive load and makes the code self-documenting. The current name creates a type/name mismatch that could confuse future maintainers.
Objective: Remove unused dependencies and simplify the vault sync invocation during ViewModel initialization. Behavioral changes: The vault sync is now triggered using `sync` instead of `syncForResult` and is no longer wrapped in a `viewModelScope` coroutine, simplifying the initialization flow. Specific changes: - Modify `MigrateToMyItemsViewModel` to remove the unused `VaultMigrationManager` dependency. - Update `MigrateToMyItemsViewModel` constructor to accept `VaultSyncManager` as a parameter rather than a property. - Update the `init` block in `MigrateToMyItemsViewModel` to call `vaultSyncManager.sync(forced = true)` directly without a coroutine wrapper. - Update `MigrateToMyItemsViewModelTest` to remove the mock and constructor usage of `VaultMigrationManager`.
**Objective** The objective of this commit is to improve code clarity and consistency by renaming a parameter and property related to vault migration data within the `RootNavViewModel` and its associated action classes. **Behavioral Changes** There are no functional behavioral changes. The logic remains the same, but the internal naming convention for handling vault migration state has been updated to be more descriptive and aligned with the type being passed.
| */ | ||
| private fun observeCipherDataAndUpdateMigrationState(userId: String) = | ||
| vaultDiskSource | ||
| .getCiphersFlow(userId = userId) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is going to put a large amount of ciphers into memory.
What do you think of adding the organizationId to the CipherEntity and adding a new function to the VaultDiskSource, something like this:
fun hasCiphersWithoutOrganizationFlow(userId: String): Flow<Boolean>That will optimize this quite a bit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like it!
| userState.activeAccount.isVaultUnlocked && | ||
| action.vaultMigrationData is VaultMigrationData.MigrationRequired -> { | ||
| RootNavState.MigrateToMyItems( | ||
| organizationId = action.vaultMigrationData.organizationId, | ||
| organizationName = action.vaultMigrationData.organizationName, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Details and fix
This condition doesn't check if specialCircumstance == null, which means migration will interrupt:
- FIDO2 credential creation/assertion (lines 163-197) - OS-triggered, time-sensitive
- Credential Manager password requests (lines 199-213) - OS-triggered
- TOTP QR code addition (line 144) - User data would be lost
- Send sharing (line 150) - User's share intent would be lost
- Passwordless login requests (line 159) - Time-sensitive auth flow
These flows are handled later in the when block (lines 142-234) and should take priority over migration prompts.
Recommended fix:
Add specialCircumstance == null check or move migration below Autofill but check for null circumstances:
userState.activeAccount.isVaultUnlocked &&
specialCircumstance == null &&
action.vaultMigrationData is VaultMigrationData.MigrationRequired -> {
RootNavState.MigrateToMyItems(
organizationId = action.vaultMigrationData.organizationId,
organizationName = action.vaultMigrationData.organizationName,
)
}Why this matters: Interrupting FIDO2 flows breaks Android credential manager integration and creates poor UX. Migration can safely wait until the user's current task completes.
The test 'vault unlock should decrypt cipher list from disk' was identical to the existing test 'ciphersStateFlow should emit decrypted list of ciphers when decryptCipherList succeeds'. This duplication arose from refactoring that removed migration verification logic, leaving only the cipher decryption behavior which was already tested.
**Objective:** Fix an issue where vault migration could be triggered incorrectly when the user is in a "special circumstance" (e.g., during specific onboarding flows or error states). **Behavioral Changes:** The application will now check if `specialCircumstance` is null before initiating the `MigrateToMyItems` navigation state. This ensures migration only occurs during standard vault operations. **Specific Changes:** - `RootNavViewModel.kt`: Added a check for `specialCircumstance == null` within the condition that triggers `RootNavState.MigrateToMyItems`.
Optimize VaultMigrationManagerImpl memory usage by replacing expensive getCiphersFlow() calls (loads all cipher JSON) with lightweight indexed hasPersonalCiphersFlow() boolean query. Database Schema Changes: - Add organizationId column to CipherEntity with composite index - Bump VaultDatabase version from 8 to 9 - Destructive migration triggers automatic vault sync - Column populated during sync operations Query Optimization: - Add hasPersonalCiphersFlow() to CiphersDao using EXISTS query - Query uses indexed organization_id column (no JSON parsing) - LIMIT 1 for short-circuit optimization Integration: - VaultMigrationManagerImpl uses optimized boolean flow - Replace List<Cipher> parameter with Boolean in verification methods - Update method signatures: verifyAndUpdateMigrationState, shouldMigrateVault Testing: - Update VaultMigrationManagerTest to use Boolean flow mocks - Add VaultDiskSourceTest cases for hasPersonalCiphersFlow - Update FakeCiphersDao test implementation Performance Impact: - Memory: Reduces from ~10-100MB cipher data to ~1KB boolean query - Query: O(n) JSON deserialization -> O(log n) indexed lookup
| init { | ||
| // Observe cipher data changes and automatically verify migration state | ||
| mutableVaultMigrationDataStateFlow |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❌ CRITICAL: Incorrect flow observation pattern
The observeWhenSubscribedAndUnlocked extension is being called on mutableVaultMigrationDataStateFlow, but this is the same flow that gets updated inside the observation callback (line 94). This creates a self-referential pattern.
Problem: The extension checks this.subscriptionCount (line 51 in StateFlowExtensions.kt), meaning it's checking if anyone is subscribed to the flow that's being updated. This likely won't work as intended because:
- The flow may not have subscribers initially
- The observation may never start
- If it does start, the subscription count behavior is unclear
Compare with correct usage in VaultSyncManagerImpl:
mutableVaultDataStateFlow
.observeWhenSubscribedAndUnlocked(...) { userId ->
observeVaultDiskCiphersToCipherListView(userId)
}There, the data flow is observed when the vault data state flow has subscribers.
Recommended fix:
Don't use observeWhenSubscribedAndUnlocked here. Instead, observe the vault unlock flow directly:
init {
combine(
authDiskSource.userStateFlow,
vaultLockManager.vaultUnlockDataStateFlow,
) { userState, unlockData ->
userState?.activeUserId?.takeIf { userId ->
unlockData.any { it.userId == userId && it.status == VaultUnlockData.Status.UNLOCKED }
}
}
.filterNotNull()
.flatMapLatest { userId ->
observeCipherDataAndUpdateMigrationState(userId)
}
.launchIn(unconfinedScope)
}| .filter { | ||
| // Only process cipher data after sync has occurred at least once | ||
| settingsDiskSource.getLastSyncTime(userId = userId) != null |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The filter checks if lastSyncTime != null to ensure data freshness, but this creates a race condition:
Scenario:
- User unlocks vault →
hasPersonalCiphersFlowimmediately emitstrue lastSyncTimeisnull(no sync yet) → emission filtered out- Sync completes →
lastSyncTimeset - BUT
hasPersonalCiphersFlowdoesn't emit again (cipher data unchanged) - Result: Migration state never updates even though all conditions are met
Why this matters: Users who unlock their vault before the first sync completes won't see the migration prompt until they lock/unlock again or cipher data changes.
Recommended fix: Observe both the cipher flow AND sync completion:
private fun observeCipherDataAndUpdateMigrationState(userId: String) =
combine(
vaultDiskSource.hasPersonalCiphersFlow(userId = userId),
settingsDiskSource.getLastSyncTimeFlow(userId = userId),
) { hasPersonalCiphers, lastSyncTime ->
hasPersonalCiphers to lastSyncTime
}
.filter { (_, lastSyncTime) -> lastSyncTime != null }
.onEach { (hasPersonalCiphers, _) ->
verifyAndUpdateMigrationState(
userId = userId,
hasPersonalCiphers = hasPersonalCiphers,
)
}This ensures the check runs both when ciphers change AND when sync completes.
| .getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP) | ||
| .any() && | ||
| featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) && | ||
| connectionManager.isNetworkConnected && |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The connectionManager.isNetworkConnected check on line 127 is evaluated only when cipher data changes or policy updates trigger a re-evaluation.
Impact: If a user unlocks their vault while offline:
- Migration check runs →
isNetworkConnectedreturnsfalse - State set to
NoMigrationRequired - User goes online
- Migration state doesn't update unless:
- Cipher data changes (cipher added/deleted)
- User locks and unlocks vault
- Policy changes
Why this matters for this feature: The migration requires network to sync items to the organization. Showing the prompt only when online is correct, but the prompt should appear as soon as connectivity is restored if all other conditions are met.
Two potential approaches:
- Make network reactive (more complex but better UX):
private fun observeCipherDataAndUpdateMigrationState(userId: String) =
combine(
vaultDiskSource.hasPersonalCiphersFlow(userId = userId),
connectionManager.isNetworkConnectedFlow, // if this exists
) { hasPersonalCiphers, isConnected ->
// Re-evaluate when either changes
}- Remove network check entirely (simpler):
Let the migration screen handle the offline case with an appropriate error message when the user tries to accept. This is consistent with other flows that allow users to view screens while offline but show errors on network operations.
Recommendation: Option 2 is simpler and provides better UX - users can read the migration prompt and learn about the requirement while offline, then act when online.
| SendEntity::class, | ||
| ], | ||
| version = 8, | ||
| version = 9, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❌ CRITICAL: Missing database migration for v8 → v9
The database version is set to 9, but autoMigrations only includes migrations from 6→7 and 7→8. There is no migration defined for 8→9.
Impact: When users upgrade from database version 8, the new organizationId column won't be added to the ciphers table, causing:
- Database schema mismatch crashes
- Or if
fallbackToDestructiveMigrationtriggers, complete vault data loss for users
Required fix:
autoMigrations = [
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9), // Add this
],Since organizationId is nullable and has no default value specified in the entity, Room's AutoMigration should handle this by adding the column with NULL values for existing rows, which is correct for this use case (existing ciphers will get their organizationId populated on next sync).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@claude we fallback to destructive migrations, which in turn trigger a full sync. Migrations are not required in most cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Claude finished @SaintPatrck's task in 52s —— View job
Database Migration Strategy Clarification
Thank you for the correction! I've verified this:
- Verify destructive migration fallback configuration
- Assess impact on users with this approach
- Update my previous review comment accordingly
Findings:
Confirmed in VaultDiskModule.kt:43:
.fallbackToDestructiveMigration(dropAllTables = false)This means:
- When Room encounters a missing migration (v8 → v9), it will perform a destructive migration
- The vault tables get cleared
- A full sync is triggered from the server to repopulate the data
- No user data is actually lost since vault data is server-authoritative
This is a perfectly valid approach for the VaultDatabase since all data comes from the server anyway. My "CRITICAL" flag on the missing AutoMigration(from = 8, to = 9) was incorrect - the existing fallback mechanism handles this case safely.
I'll note that explicit AutoMigrations for 6→7 and 7→8 exist, which suggests those were added for specific reasons (perhaps performance to avoid unnecessary re-syncs, or for tables with local-only data at the time). For this change, relying on the destructive fallback is reasonable.
My previous comment on this should be disregarded. ✅
| val id: String, | ||
|
|
||
| @ColumnInfo(name = "user_id", index = true) | ||
| @ColumnInfo(name = "user_id") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
| tableName = "ciphers", | ||
| indices = [ | ||
| Index(value = ["user_id"]), | ||
| Index(value = ["user_id", "organization_id"]), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the basic user_id needed since you already added it to the ColumnInfo?
Do we need the compound Index? We do not query by a specific org id, so I'm unsure if that will gain us anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I removed it from the ColumnInfo, so it is needed here.
We don't gain much from this particular query, but we'll be fetching all personal ciphers from the DB when we implement the actual migration API's. That's where the new index will really shine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Technically we don't need the individual user_id index and the composite index. At least I don't think we do. 🤔
@claude is the individual user_id column index needed if it's include in the composite index of user_id and organization_id?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Claude finished @SaintPatrck's task in 30s —— View job
Index Redundancy Analysis
- Analyze the database index question
- Research SQLite/Room index behavior with composite indexes
- Provide answer with recommendation
Answer: You're correct - the individual user_id index is not needed when you have a composite index with user_id as the leftmost column.
How SQLite composite indexes work:
A composite index on (user_id, organization_id) can satisfy queries that filter by:
user_idalone ✅user_idANDorganization_id✅organization_idalone ❌ (would require a separate index)
This is because SQLite (and most relational databases) can use the "leftmost prefix" of a composite index. The B-tree structure allows efficient lookups starting from the leftmost column.
Current state in CipherEntity.kt:13-16:
indices = [
Index(value = ["user_id"]), // Redundant
Index(value = ["user_id", "organization_id"]),
],Recommended change:
indices = [
Index(value = ["user_id", "organization_id"]),
],Why remove the redundant index:
- Saves storage space (less disk usage)
- Faster write operations (fewer indexes to update on INSERT/UPDATE/DELETE)
- No impact on read performance for
user_id-only queries
The only scenario where you'd want both is if the single-column index provides significantly better performance for user_id-only queries (e.g., the composite index is very large). In practice, this is rarely a concern for mobile databases.

🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-28468
📔 Objective
Added an observable flow that will be updated after a sync and if the conditions are met will trigger the RootNavViewModel to display the
MigrateToVaultItemsflow.Only after a sync in order to ensure we are not displaying the migration screen with stale data.
The conditions for the navigation are
VAULT_OWNERSHIPpolicy is activeorganizationId)This is only in draft, missing all the unit tests and some manual tests
⏰ Reminders before review
🦮 Reviewer guidelines
:+1:) or similar for great changes:memo:) or ℹ️ (:information_source:) for notes or general info:question:) for questions:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion:art:) for suggestions / improvements:x:) or:warning:) for more significant problems or concerns needing attention:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt:pick:) for minor or nitpick changes