diff --git a/.sqlx/query-3b3211af0c35f19ad8c32a048456b2adbb47775cec208ce1aebd543f38a1bfef.json b/.sqlx/query-3b3211af0c35f19ad8c32a048456b2adbb47775cec208ce1aebd543f38a1bfef.json
new file mode 100644
index 00000000..2d014377
--- /dev/null
+++ b/.sqlx/query-3b3211af0c35f19ad8c32a048456b2adbb47775cec208ce1aebd543f38a1bfef.json
@@ -0,0 +1,20 @@
+{
+  "db_name": "SQLite",
+  "query": "select * from hard_to_moderate where usr=?",
+  "describe": {
+    "columns": [
+      {
+        "name": "usr",
+        "ordinal": 0,
+        "type_info": "Int64"
+      }
+    ],
+    "parameters": {
+      "Right": 1
+    },
+    "nullable": [
+      false
+    ]
+  },
+  "hash": "3b3211af0c35f19ad8c32a048456b2adbb47775cec208ce1aebd543f38a1bfef"
+}
diff --git a/.sqlx/query-7577bdae53f7407534a632826a88564656e456434966a3296cc5375414697feb.json b/.sqlx/query-7577bdae53f7407534a632826a88564656e456434966a3296cc5375414697feb.json
new file mode 100644
index 00000000..a48cfd27
--- /dev/null
+++ b/.sqlx/query-7577bdae53f7407534a632826a88564656e456434966a3296cc5375414697feb.json
@@ -0,0 +1,12 @@
+{
+  "db_name": "SQLite",
+  "query": "insert or ignore into hard_to_moderate (usr) values (?)",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Right": 1
+    },
+    "nullable": []
+  },
+  "hash": "7577bdae53f7407534a632826a88564656e456434966a3296cc5375414697feb"
+}
diff --git a/.sqlx/query-f0d2ab5f212ef40d1ecec2a5424d5b861b09058f7527712ef343fc117237847c.json b/.sqlx/query-f0d2ab5f212ef40d1ecec2a5424d5b861b09058f7527712ef343fc117237847c.json
new file mode 100644
index 00000000..4d0b4fc9
--- /dev/null
+++ b/.sqlx/query-f0d2ab5f212ef40d1ecec2a5424d5b861b09058f7527712ef343fc117237847c.json
@@ -0,0 +1,12 @@
+{
+  "db_name": "SQLite",
+  "query": "delete from hard_to_moderate where usr=?",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Right": 1
+    },
+    "nullable": []
+  },
+  "hash": "f0d2ab5f212ef40d1ecec2a5424d5b861b09058f7527712ef343fc117237847c"
+}
diff --git a/base.db b/base.db
index 885d6b26..15be47b9 100644
Binary files a/base.db and b/base.db differ
diff --git a/crates/robbb/src/events/guild_member_addition.rs b/crates/robbb/src/events/guild_member_addition.rs
index e42331a1..f6f7d140 100644
--- a/crates/robbb/src/events/guild_member_addition.rs
+++ b/crates/robbb/src/events/guild_member_addition.rs
@@ -8,6 +8,26 @@ use robbb_util::{
 };
 use std::time::SystemTime;
 
+async fn handle_htm_evasion(ctx: &client::Context, new_member: &mut Member) -> Result<()> {
+    let (config, db) = ctx.get_config_and_db().await;
+    let is_htm = db.check_user_htm(new_member.user.id).await?;
+    if is_htm {
+        config
+            .channel_modlog
+            .send_embed(&ctx, |e| {
+                e.author(|a| a.name("HTM evasion caught").icon_url(new_member.user.face()));
+                e.title(new_member.user.name_with_disc_and_id());
+                e.description(format!(
+                    "User {} was HTM and rejoined.\nRe-applying HTM role.",
+                    new_member.mention()
+                ));
+            })
+            .await?;
+        new_member.add_role(&ctx, config.role_htm).await?;
+    }
+    Ok(())
+}
+
 /// check if there's an active mute of a user that just joined.
 /// if so, reapply the mute and log their mute-evasion attempt in modlog
 async fn handle_mute_evasion(ctx: &client::Context, new_member: &Member) -> Result<()> {
@@ -33,12 +53,13 @@ async fn handle_mute_evasion(ctx: &client::Context, new_member: &Member) -> Resu
     Ok(())
 }
 
-pub async fn guild_member_addition(ctx: client::Context, new_member: Member) -> Result<()> {
+pub async fn guild_member_addition(ctx: client::Context, mut new_member: Member) -> Result<()> {
     let config = ctx.get_config().await;
     if config.guild != new_member.guild_id {
         return Ok(());
     }
 
+    log_error!(handle_htm_evasion(&ctx, &mut new_member).await);
     log_error!(handle_mute_evasion(&ctx, &new_member).await);
 
     config
diff --git a/crates/robbb/src/events/guild_member_removal.rs b/crates/robbb/src/events/guild_member_removal.rs
index 741c27e9..b9a178cd 100644
--- a/crates/robbb/src/events/guild_member_removal.rs
+++ b/crates/robbb/src/events/guild_member_removal.rs
@@ -6,7 +6,7 @@ pub async fn guild_member_removal(
     ctx: client::Context,
     guild_id: GuildId,
     user: User,
-    _member: Option<Member>,
+    member: Option<Member>,
 ) -> Result<()> {
     let db: Arc<Db> = ctx.get_db().await;
     let config = ctx.get_config().await;
@@ -14,6 +14,13 @@ pub async fn guild_member_removal(
         return Ok(());
     }
 
+    if let Some(member) = member {
+        let roles = member.roles(&ctx).unwrap_or_default();
+        if roles.iter().any(|x| x.id == config.role_htm) {
+            log_error!(db.add_htm(member.user.id).await);
+        }
+    }
+
     config
         .channel_bot_traffic
         .send_embed(&ctx, |e| {
diff --git a/crates/robbb/src/events/guild_member_update.rs b/crates/robbb/src/events/guild_member_update.rs
index 82f91118..cb147261 100644
--- a/crates/robbb/src/events/guild_member_update.rs
+++ b/crates/robbb/src/events/guild_member_update.rs
@@ -9,7 +9,14 @@ pub async fn guild_member_update(
     _old: Option<Member>,
     new: Member,
 ) -> Result<()> {
-    dehoist_member(ctx, new).await?;
+    let (config, db) = ctx.get_config_and_db().await;
+    dehoist_member(ctx.clone(), new.clone()).await?;
+
+    let roles = new.roles(&ctx).unwrap_or_default();
+    if roles.iter().any(|x| x.id == config.role_htm) {
+        log_error!(db.add_htm(new.user.id).await);
+    }
+
     Ok(())
 }
 
diff --git a/crates/robbb_db/src/db/htm.rs b/crates/robbb_db/src/db/htm.rs
new file mode 100644
index 00000000..1ab7e449
--- /dev/null
+++ b/crates/robbb_db/src/db/htm.rs
@@ -0,0 +1,37 @@
+use serenity::model::id::UserId;
+
+use crate::Db;
+
+#[derive(Debug)]
+pub struct HardToModerateEntry {
+    pub user: UserId,
+}
+
+impl Db {
+    #[tracing::instrument(skip_all)]
+    pub async fn check_user_htm(&self, id: UserId) -> anyhow::Result<bool> {
+        let id = id.0 as i64;
+        Ok(sqlx::query!(r#"select * from hard_to_moderate where usr=?"#, id)
+            .fetch_optional(&self.pool)
+            .await?
+            .is_some())
+    }
+
+    #[tracing::instrument(skip_all)]
+    pub async fn add_htm(&self, id: UserId) -> anyhow::Result<()> {
+        let id = id.0 as i64;
+        sqlx::query!(r#"insert or ignore into hard_to_moderate (usr) values (?)"#, id)
+            .fetch_optional(&self.pool)
+            .await?;
+        Ok(())
+    }
+
+    #[tracing::instrument(skip_all)]
+    pub async fn remove_htm(&self, id: UserId) -> anyhow::Result<()> {
+        let id = id.0 as i64;
+        sqlx::query!(r#"delete from hard_to_moderate where usr=?"#, id)
+            .fetch_optional(&self.pool)
+            .await?;
+        Ok(())
+    }
+}
diff --git a/crates/robbb_db/src/db/mod.rs b/crates/robbb_db/src/db/mod.rs
index c64e82cf..2cfea064 100644
--- a/crates/robbb_db/src/db/mod.rs
+++ b/crates/robbb_db/src/db/mod.rs
@@ -13,6 +13,7 @@ pub mod emoji_logging;
 pub mod fetch;
 pub mod fetch_field;
 pub mod highlights;
+pub mod htm;
 pub mod mod_action;
 pub mod mute;
 pub mod tag;
@@ -41,7 +42,7 @@ impl Db {
     }
 
     pub async fn run_migrations(&self) -> Result<()> {
-        sqlx::migrate!("./migrations")
+        sqlx::migrate!("../../migrations")
             .run(&self.pool)
             .await
             .context("Failed to run database migrations")?;
diff --git a/crates/robbb_util/src/config.rs b/crates/robbb_util/src/config.rs
index 499eed63..480405dc 100644
--- a/crates/robbb_util/src/config.rs
+++ b/crates/robbb_util/src/config.rs
@@ -17,6 +17,7 @@ pub struct Config {
     pub role_mod: RoleId,
     pub role_helper: RoleId,
     pub role_mute: RoleId,
+    pub role_htm: RoleId,
     pub roles_color: Vec<RoleId>,
 
     pub category_mod_private: ChannelId,
@@ -47,6 +48,7 @@ impl Config {
             role_mod: RoleId(parse_required_env_var("ROLE_MOD")?),
             role_helper: RoleId(parse_required_env_var("ROLE_HELPER")?),
             role_mute: RoleId(parse_required_env_var("ROLE_MUTE")?),
+            role_htm: RoleId(parse_required_env_var("ROLE_HTM")?),
             roles_color: required_env_var("ROLES_COLOR")?
                 .split(',')
                 .map(|x| Ok(RoleId(x.trim().parse()?)))
diff --git a/crates/robbb_db/migrations/20220521195821_initialize.sql b/migrations/20220521195821_initialize.sql
similarity index 100%
rename from crates/robbb_db/migrations/20220521195821_initialize.sql
rename to migrations/20220521195821_initialize.sql
diff --git a/migrations/20231120193725_Add_hard_to_moderate_table.sql b/migrations/20231120193725_Add_hard_to_moderate_table.sql
new file mode 100644
index 00000000..27aebbe1
--- /dev/null
+++ b/migrations/20231120193725_Add_hard_to_moderate_table.sql
@@ -0,0 +1,3 @@
+CREATE TABLE IF NOT EXISTS hard_to_moderate (
+    usr integer primary key
+);