diff --git a/.github/workflows/build-and-push-bsky-aws.yaml b/.github/workflows/build-and-push-bsky-aws.yaml
index 34bba307..9df469c0 100644
--- a/.github/workflows/build-and-push-bsky-aws.yaml
+++ b/.github/workflows/build-and-push-bsky-aws.yaml
@@ -3,7 +3,7 @@ on:
   push:
     branches:
       - main
-      - bsky-node-clustering
+      - appeal-report
 env:
   REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
   USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json
index fae60e45..23448b7a 100644
--- a/lexicons/com/atproto/admin/defs.json
+++ b/lexicons/com/atproto/admin/defs.json
@@ -69,7 +69,8 @@
             "#modEventLabel",
             "#modEventAcknowledge",
             "#modEventEscalate",
-            "#modEventMute"
+            "#modEventMute",
+            "#modEventResolveAppeal"
           ]
         },
         "subject": {
@@ -167,9 +168,18 @@
           "type": "string",
           "format": "datetime"
         },
+        "lastAppealedAt": {
+          "type": "string",
+          "format": "datetime",
+          "description": "Timestamp referencing when the author of the subject appealed a moderation action"
+        },
         "takendown": {
           "type": "boolean"
         },
+        "appealed": {
+          "type": "boolean",
+          "description": "True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators."
+        },
         "suspendUntil": {
           "type": "string",
           "format": "datetime"
@@ -469,6 +479,16 @@
         }
       }
     },
+    "modEventResolveAppeal": {
+      "type": "object",
+      "description": "Resolve appeal on a subject",
+      "properties": {
+        "comment": {
+          "type": "string",
+          "description": "Describe resolution."
+        }
+      }
+    },
     "modEventComment": {
       "type": "object",
       "description": "Add a comment to a subject",
diff --git a/lexicons/com/atproto/admin/queryModerationStatuses.json b/lexicons/com/atproto/admin/queryModerationStatuses.json
index 98fec5bd..e3e2a859 100644
--- a/lexicons/com/atproto/admin/queryModerationStatuses.json
+++ b/lexicons/com/atproto/admin/queryModerationStatuses.json
@@ -64,6 +64,10 @@
             "type": "boolean",
             "description": "Get subjects that were taken down"
           },
+          "appealed": {
+            "type": "boolean",
+            "description": "Get subjects in unresolved appealed status"
+          },
           "limit": {
             "type": "integer",
             "minimum": 1,
diff --git a/lexicons/com/atproto/moderation/defs.json b/lexicons/com/atproto/moderation/defs.json
index a06579a5..b9e980df 100644
--- a/lexicons/com/atproto/moderation/defs.json
+++ b/lexicons/com/atproto/moderation/defs.json
@@ -10,7 +10,8 @@
         "com.atproto.moderation.defs#reasonMisleading",
         "com.atproto.moderation.defs#reasonSexual",
         "com.atproto.moderation.defs#reasonRude",
-        "com.atproto.moderation.defs#reasonOther"
+        "com.atproto.moderation.defs#reasonOther",
+        "com.atproto.moderation.defs#reasonAppeal"
       ]
     },
     "reasonSpam": {
@@ -36,6 +37,10 @@
     "reasonOther": {
       "type": "token",
       "description": "Other: reports not falling under another report category"
+    },
+    "reasonAppeal": {
+      "type": "token",
+      "description": "Appeal: appeal a previously taken moderation action"
     }
   }
 }
diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts
index a42dbd93..df55181a 100644
--- a/packages/api/src/client/index.ts
+++ b/packages/api/src/client/index.ts
@@ -297,6 +297,7 @@ export const COM_ATPROTO_MODERATION = {
   DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual',
   DefsReasonRude: 'com.atproto.moderation.defs#reasonRude',
   DefsReasonOther: 'com.atproto.moderation.defs#reasonOther',
+  DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal',
 }
 export const APP_BSKY_GRAPH = {
   DefsModlist: 'app.bsky.graph.defs#modlist',
diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts
index 3d6c725e..dbbac6b7 100644
--- a/packages/api/src/client/lexicons.ts
+++ b/packages/api/src/client/lexicons.ts
@@ -102,6 +102,7 @@ export const schemaDict = {
               'lex:com.atproto.admin.defs#modEventAcknowledge',
               'lex:com.atproto.admin.defs#modEventEscalate',
               'lex:com.atproto.admin.defs#modEventMute',
+              'lex:com.atproto.admin.defs#modEventResolveAppeal',
             ],
           },
           subject: {
@@ -237,9 +238,20 @@ export const schemaDict = {
             type: 'string',
             format: 'datetime',
           },
+          lastAppealedAt: {
+            type: 'string',
+            format: 'datetime',
+            description:
+              'Timestamp referencing when the author of the subject appealed a moderation action',
+          },
           takendown: {
             type: 'boolean',
           },
+          appealed: {
+            type: 'boolean',
+            description:
+              'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.',
+          },
           suspendUntil: {
             type: 'string',
             format: 'datetime',
@@ -717,6 +729,16 @@ export const schemaDict = {
           },
         },
       },
+      modEventResolveAppeal: {
+        type: 'object',
+        description: 'Resolve appeal on a subject',
+        properties: {
+          comment: {
+            type: 'string',
+            description: 'Describe resolution.',
+          },
+        },
+      },
       modEventComment: {
         type: 'object',
         description: 'Add a comment to a subject',
@@ -1361,6 +1383,10 @@ export const schemaDict = {
               type: 'boolean',
               description: 'Get subjects that were taken down',
             },
+            appealed: {
+              type: 'boolean',
+              description: 'Get subjects in unresolved appealed status',
+            },
             limit: {
               type: 'integer',
               minimum: 1,
@@ -1946,6 +1972,7 @@ export const schemaDict = {
           'com.atproto.moderation.defs#reasonSexual',
           'com.atproto.moderation.defs#reasonRude',
           'com.atproto.moderation.defs#reasonOther',
+          'com.atproto.moderation.defs#reasonAppeal',
         ],
       },
       reasonSpam: {
@@ -1973,6 +2000,10 @@ export const schemaDict = {
         type: 'token',
         description: 'Other: reports not falling under another report category',
       },
+      reasonAppeal: {
+        type: 'token',
+        description: 'Appeal: appeal a previously taken moderation action',
+      },
     },
   },
   ComAtprotoRepoApplyWrites: {
diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts
index 42a587bc..d4b35ae8 100644
--- a/packages/api/src/client/types/com/atproto/admin/defs.ts
+++ b/packages/api/src/client/types/com/atproto/admin/defs.ts
@@ -76,6 +76,7 @@ export interface ModEventViewDetail {
     | ModEventAcknowledge
     | ModEventEscalate
     | ModEventMute
+    | ModEventResolveAppeal
     | { $type: string; [k: string]: unknown }
   subject:
     | RepoView
@@ -147,7 +148,11 @@ export interface SubjectStatusView {
   lastReviewedBy?: string
   lastReviewedAt?: string
   lastReportedAt?: string
+  /** Timestamp referencing when the author of the subject appealed a moderation action */
+  lastAppealedAt?: string
   takendown?: boolean
+  /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */
+  appealed?: boolean
   suspendUntil?: string
   [k: string]: unknown
 }
@@ -538,6 +543,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult {
   return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v)
 }
 
+/** Resolve appeal on a subject */
+export interface ModEventResolveAppeal {
+  /** Describe resolution. */
+  comment?: string
+  [k: string]: unknown
+}
+
+export function isModEventResolveAppeal(
+  v: unknown,
+): v is ModEventResolveAppeal {
+  return (
+    isObj(v) &&
+    hasProp(v, '$type') &&
+    v.$type === 'com.atproto.admin.defs#modEventResolveAppeal'
+  )
+}
+
+export function validateModEventResolveAppeal(v: unknown): ValidationResult {
+  return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v)
+}
+
 /** Add a comment to a subject */
 export interface ModEventComment {
   comment: string
diff --git a/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts b/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts
index 80eb17d8..0039016a 100644
--- a/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts
+++ b/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts
@@ -31,6 +31,8 @@ export interface QueryParams {
   sortDirection?: 'asc' | 'desc'
   /** Get subjects that were taken down */
   takendown?: boolean
+  /** Get subjects in unresolved appealed status */
+  appealed?: boolean
   limit?: number
   cursor?: string
 }
diff --git a/packages/api/src/client/types/com/atproto/moderation/defs.ts b/packages/api/src/client/types/com/atproto/moderation/defs.ts
index b6463993..802cd2bc 100644
--- a/packages/api/src/client/types/com/atproto/moderation/defs.ts
+++ b/packages/api/src/client/types/com/atproto/moderation/defs.ts
@@ -13,6 +13,7 @@ export type ReasonType =
   | 'com.atproto.moderation.defs#reasonSexual'
   | 'com.atproto.moderation.defs#reasonRude'
   | 'com.atproto.moderation.defs#reasonOther'
+  | 'com.atproto.moderation.defs#reasonAppeal'
   | (string & {})
 
 /** Spam: frequent unwanted promotion, replies, mentions */
@@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
 export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'
 /** Other: reports not falling under another report category */
 export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther'
+/** Appeal: appeal a previously taken moderation action */
+export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal'
diff --git a/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts b/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts
index 5a74bfca..e664e903 100644
--- a/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts
+++ b/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts
@@ -9,6 +9,7 @@ export default function (server: Server, ctx: AppContext) {
       const {
         subject,
         takendown,
+        appealed,
         reviewState,
         reviewedAfter,
         reviewedBefore,
@@ -28,6 +29,7 @@ export default function (server: Server, ctx: AppContext) {
         reviewState: getReviewState(reviewState),
         subject,
         takendown,
+        appealed,
         reviewedAfter,
         reviewedBefore,
         reportedAfter,
diff --git a/packages/bsky/src/api/com/atproto/moderation/createReport.ts b/packages/bsky/src/api/com/atproto/moderation/createReport.ts
index b247a319..4a98d062 100644
--- a/packages/bsky/src/api/com/atproto/moderation/createReport.ts
+++ b/packages/bsky/src/api/com/atproto/moderation/createReport.ts
@@ -1,8 +1,9 @@
-import { AuthRequiredError } from '@atproto/xrpc-server'
+import { AuthRequiredError, ForbiddenError } from '@atproto/xrpc-server'
 import { Server } from '../../../../lexicon'
 import AppContext from '../../../../context'
 import { getReasonType, getSubject } from './util'
 import { softDeleted } from '../../../../db/util'
+import { REASONAPPEAL } from '../../../../lexicon/types/com/atproto/moderation/defs'
 
 export default function (server: Server, ctx: AppContext) {
   server.com.atproto.moderation.createReport({
@@ -22,12 +23,22 @@ export default function (server: Server, ctx: AppContext) {
         }
       }
 
+      const reportReasonType = getReasonType(reasonType)
+      const reportSubject = getSubject(subject)
+      const subjectDid =
+        'did' in reportSubject ? reportSubject.did : reportSubject.uri.host
+
+      // If the report is an appeal, the requester must be the author of the subject
+      if (reasonType === REASONAPPEAL && requester !== subjectDid) {
+        throw new ForbiddenError('You cannot appeal this report')
+      }
+
       const report = await db.transaction(async (dbTxn) => {
         const moderationTxn = ctx.services.moderation(dbTxn)
         return moderationTxn.report({
-          reasonType: getReasonType(reasonType),
+          reasonType: reportReasonType,
           reason,
-          subject: getSubject(subject),
+          subject: reportSubject,
           reportedBy: requester || ctx.cfg.serverDid,
         })
       })
diff --git a/packages/bsky/src/api/com/atproto/moderation/util.ts b/packages/bsky/src/api/com/atproto/moderation/util.ts
index bc0ece2f..fbb144b1 100644
--- a/packages/bsky/src/api/com/atproto/moderation/util.ts
+++ b/packages/bsky/src/api/com/atproto/moderation/util.ts
@@ -10,6 +10,7 @@ import {
   REASONRUDE,
   REASONSEXUAL,
   REASONVIOLATION,
+  REASONAPPEAL,
 } from '../../../../lexicon/types/com/atproto/moderation/defs'
 import {
   REVIEWCLOSED,
@@ -73,6 +74,7 @@ const reasonTypes = new Set([
   REASONRUDE,
   REASONSEXUAL,
   REASONVIOLATION,
+  REASONAPPEAL,
 ])
 
 const eventTypes = new Set([
diff --git a/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts
new file mode 100644
index 00000000..95662737
--- /dev/null
+++ b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts
@@ -0,0 +1,23 @@
+import { Kysely } from 'kysely'
+
+export async function up(db: Kysely<unknown>): Promise<void> {
+  await db.schema
+    .alterTable('moderation_subject_status')
+    .addColumn('lastAppealedAt', 'varchar')
+    .execute()
+  await db.schema
+    .alterTable('moderation_subject_status')
+    .addColumn('appealed', 'boolean')
+    .execute()
+}
+
+export async function down(db: Kysely<unknown>): Promise<void> {
+  await db.schema
+    .alterTable('moderation_subject_status')
+    .dropColumn('lastAppealedAt')
+    .execute()
+  await db.schema
+    .alterTable('moderation_subject_status')
+    .dropColumn('appealed')
+    .execute()
+}
diff --git a/packages/bsky/src/db/migrations/index.ts b/packages/bsky/src/db/migrations/index.ts
index f3ed5bc4..ea14e775 100644
--- a/packages/bsky/src/db/migrations/index.ts
+++ b/packages/bsky/src/db/migrations/index.ts
@@ -32,3 +32,4 @@ export * as _20230920T213858047Z from './20230920T213858047Z-add-tags-to-post'
 export * as _20230929T192920807Z from './20230929T192920807Z-record-cursor-indexes'
 export * as _20231003T202833377Z from './20231003T202833377Z-create-moderation-subject-status'
 export * as _20231205T000257238Z from './20231205T000257238Z-remove-did-cache'
+export * as _20231213T181744386Z from './20231213T181744386Z-moderation-subject-appeal'
diff --git a/packages/bsky/src/db/tables/moderation.ts b/packages/bsky/src/db/tables/moderation.ts
index f1ac3572..99f5e733 100644
--- a/packages/bsky/src/db/tables/moderation.ts
+++ b/packages/bsky/src/db/tables/moderation.ts
@@ -20,6 +20,7 @@ export interface ModerationEvent {
     | 'com.atproto.admin.defs#modEventMute'
     | 'com.atproto.admin.defs#modEventReverseTakedown'
     | 'com.atproto.admin.defs#modEventEmail'
+    | 'com.atproto.admin.defs#modEventResolveAppeal'
   subjectType: 'com.atproto.admin.defs#repoRef' | 'com.atproto.repo.strongRef'
   subjectDid: string
   subjectUri: string | null
@@ -47,9 +48,11 @@ export interface ModerationSubjectStatus {
   lastReviewedBy: string | null
   lastReviewedAt: string | null
   lastReportedAt: string | null
+  lastAppealedAt: string | null
   muteUntil: string | null
   suspendUntil: string | null
   takendown: boolean
+  appealed: boolean | null
   comment: string | null
 }
 
diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts
index c51998a6..40c50cd1 100644
--- a/packages/bsky/src/lexicon/index.ts
+++ b/packages/bsky/src/lexicon/index.ts
@@ -135,6 +135,7 @@ export const COM_ATPROTO_MODERATION = {
   DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual',
   DefsReasonRude: 'com.atproto.moderation.defs#reasonRude',
   DefsReasonOther: 'com.atproto.moderation.defs#reasonOther',
+  DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal',
 }
 export const APP_BSKY_GRAPH = {
   DefsModlist: 'app.bsky.graph.defs#modlist',
diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts
index 3d6c725e..dbbac6b7 100644
--- a/packages/bsky/src/lexicon/lexicons.ts
+++ b/packages/bsky/src/lexicon/lexicons.ts
@@ -102,6 +102,7 @@ export const schemaDict = {
               'lex:com.atproto.admin.defs#modEventAcknowledge',
               'lex:com.atproto.admin.defs#modEventEscalate',
               'lex:com.atproto.admin.defs#modEventMute',
+              'lex:com.atproto.admin.defs#modEventResolveAppeal',
             ],
           },
           subject: {
@@ -237,9 +238,20 @@ export const schemaDict = {
             type: 'string',
             format: 'datetime',
           },
+          lastAppealedAt: {
+            type: 'string',
+            format: 'datetime',
+            description:
+              'Timestamp referencing when the author of the subject appealed a moderation action',
+          },
           takendown: {
             type: 'boolean',
           },
+          appealed: {
+            type: 'boolean',
+            description:
+              'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.',
+          },
           suspendUntil: {
             type: 'string',
             format: 'datetime',
@@ -717,6 +729,16 @@ export const schemaDict = {
           },
         },
       },
+      modEventResolveAppeal: {
+        type: 'object',
+        description: 'Resolve appeal on a subject',
+        properties: {
+          comment: {
+            type: 'string',
+            description: 'Describe resolution.',
+          },
+        },
+      },
       modEventComment: {
         type: 'object',
         description: 'Add a comment to a subject',
@@ -1361,6 +1383,10 @@ export const schemaDict = {
               type: 'boolean',
               description: 'Get subjects that were taken down',
             },
+            appealed: {
+              type: 'boolean',
+              description: 'Get subjects in unresolved appealed status',
+            },
             limit: {
               type: 'integer',
               minimum: 1,
@@ -1946,6 +1972,7 @@ export const schemaDict = {
           'com.atproto.moderation.defs#reasonSexual',
           'com.atproto.moderation.defs#reasonRude',
           'com.atproto.moderation.defs#reasonOther',
+          'com.atproto.moderation.defs#reasonAppeal',
         ],
       },
       reasonSpam: {
@@ -1973,6 +2000,10 @@ export const schemaDict = {
         type: 'token',
         description: 'Other: reports not falling under another report category',
       },
+      reasonAppeal: {
+        type: 'token',
+        description: 'Appeal: appeal a previously taken moderation action',
+      },
     },
   },
   ComAtprotoRepoApplyWrites: {
diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts
index 33a4ccd1..4be9efb2 100644
--- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts
+++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts
@@ -76,6 +76,7 @@ export interface ModEventViewDetail {
     | ModEventAcknowledge
     | ModEventEscalate
     | ModEventMute
+    | ModEventResolveAppeal
     | { $type: string; [k: string]: unknown }
   subject:
     | RepoView
@@ -147,7 +148,11 @@ export interface SubjectStatusView {
   lastReviewedBy?: string
   lastReviewedAt?: string
   lastReportedAt?: string
+  /** Timestamp referencing when the author of the subject appealed a moderation action */
+  lastAppealedAt?: string
   takendown?: boolean
+  /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */
+  appealed?: boolean
   suspendUntil?: string
   [k: string]: unknown
 }
@@ -538,6 +543,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult {
   return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v)
 }
 
+/** Resolve appeal on a subject */
+export interface ModEventResolveAppeal {
+  /** Describe resolution. */
+  comment?: string
+  [k: string]: unknown
+}
+
+export function isModEventResolveAppeal(
+  v: unknown,
+): v is ModEventResolveAppeal {
+  return (
+    isObj(v) &&
+    hasProp(v, '$type') &&
+    v.$type === 'com.atproto.admin.defs#modEventResolveAppeal'
+  )
+}
+
+export function validateModEventResolveAppeal(v: unknown): ValidationResult {
+  return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v)
+}
+
 /** Add a comment to a subject */
 export interface ModEventComment {
   comment: string
diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts
index d4e55aff..6e1aea1f 100644
--- a/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts
+++ b/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts
@@ -32,6 +32,8 @@ export interface QueryParams {
   sortDirection: 'asc' | 'desc'
   /** Get subjects that were taken down */
   takendown?: boolean
+  /** Get subjects in unresolved appealed status */
+  appealed?: boolean
   limit: number
   cursor?: string
 }
diff --git a/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts
index 81697226..08e555c2 100644
--- a/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts
+++ b/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts
@@ -13,6 +13,7 @@ export type ReasonType =
   | 'com.atproto.moderation.defs#reasonSexual'
   | 'com.atproto.moderation.defs#reasonRude'
   | 'com.atproto.moderation.defs#reasonOther'
+  | 'com.atproto.moderation.defs#reasonAppeal'
   | (string & {})
 
 /** Spam: frequent unwanted promotion, replies, mentions */
@@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
 export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'
 /** Other: reports not falling under another report category */
 export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther'
+/** Appeal: appeal a previously taken moderation action */
+export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal'
diff --git a/packages/bsky/src/services/moderation/index.ts b/packages/bsky/src/services/moderation/index.ts
index 717155d0..84769100 100644
--- a/packages/bsky/src/services/moderation/index.ts
+++ b/packages/bsky/src/services/moderation/index.ts
@@ -539,6 +539,7 @@ export class ModerationService {
     cursor,
     limit = 50,
     takendown,
+    appealed,
     reviewState,
     reviewedAfter,
     reviewedBefore,
@@ -554,6 +555,7 @@ export class ModerationService {
     cursor?: string
     limit?: number
     takendown?: boolean
+    appealed?: boolean | null
     reviewedBefore?: string
     reviewState?: ModerationSubjectStatusRow['reviewState']
     reviewedAfter?: string
@@ -615,6 +617,13 @@ export class ModerationService {
       builder = builder.where('takendown', '=', true)
     }
 
+    if (appealed !== undefined) {
+      builder =
+        appealed === null
+          ? builder.where('appealed', 'is', null)
+          : builder.where('appealed', '=', appealed)
+    }
+
     if (!includeMuted) {
       builder = builder.where((qb) =>
         qb
diff --git a/packages/bsky/src/services/moderation/status.ts b/packages/bsky/src/services/moderation/status.ts
index 2362da5d..151f6137 100644
--- a/packages/bsky/src/services/moderation/status.ts
+++ b/packages/bsky/src/services/moderation/status.ts
@@ -12,6 +12,7 @@ import { ModerationEventRow, ModerationSubjectStatusRow } from './types'
 import { HOUR } from '@atproto/common'
 import { CID } from 'multiformats/cid'
 import { sql } from 'kysely'
+import { REASONAPPEAL } from '../../lexicon/types/com/atproto/moderation/defs'
 
 const getSubjectStatusForModerationEvent = ({
   action,
@@ -82,6 +83,10 @@ const getSubjectStatusForModerationEvent = ({
         lastReviewedBy: createdBy,
         lastReviewedAt: createdAt,
       }
+    case 'com.atproto.admin.defs#modEventResolveAppeal':
+      return {
+        appealed: false,
+      }
     default:
       return null
   }
@@ -106,6 +111,10 @@ export const adjustModerationSubjectStatus = async (
     createdAt,
   } = moderationEvent
 
+  const isAppealEvent =
+    action === 'com.atproto.admin.defs#modEventReport' &&
+    meta?.reportType === REASONAPPEAL
+
   const subjectStatus = getSubjectStatusForModerationEvent({
     action,
     createdBy,
@@ -162,6 +171,21 @@ export const adjustModerationSubjectStatus = async (
     subjectStatus.takendown = false
   }
 
+  if (isAppealEvent) {
+    newStatus.appealed = true
+    subjectStatus.appealed = true
+    newStatus.lastAppealedAt = createdAt
+    subjectStatus.lastAppealedAt = createdAt
+  }
+
+  if (
+    action === 'com.atproto.admin.defs#modEventResolveAppeal' &&
+    subjectStatus.appealed
+  ) {
+    newStatus.appealed = false
+    subjectStatus.appealed = false
+  }
+
   if (action === 'com.atproto.admin.defs#modEventComment' && meta?.sticky) {
     newStatus.comment = comment
     subjectStatus.comment = comment
diff --git a/packages/bsky/src/services/moderation/views.ts b/packages/bsky/src/services/moderation/views.ts
index 2dc9c5ec..654a6e54 100644
--- a/packages/bsky/src/services/moderation/views.ts
+++ b/packages/bsky/src/services/moderation/views.ts
@@ -485,9 +485,11 @@ export class ModerationViews {
       lastReviewedBy: subjectStatus.lastReviewedBy ?? undefined,
       lastReviewedAt: subjectStatus.lastReviewedAt ?? undefined,
       lastReportedAt: subjectStatus.lastReportedAt ?? undefined,
+      lastAppealedAt: subjectStatus.lastAppealedAt ?? undefined,
       muteUntil: subjectStatus.muteUntil ?? undefined,
       suspendUntil: subjectStatus.suspendUntil ?? undefined,
       takendown: subjectStatus.takendown ?? undefined,
+      appealed: subjectStatus.appealed ?? undefined,
       subjectRepoHandle: subjectStatus.handle ?? undefined,
       subjectBlobCids: subjectStatus.blobCids || [],
       subject: !subjectStatus.recordPath
diff --git a/packages/bsky/tests/admin/moderation-appeals.test.ts b/packages/bsky/tests/admin/moderation-appeals.test.ts
new file mode 100644
index 00000000..8b2af9a5
--- /dev/null
+++ b/packages/bsky/tests/admin/moderation-appeals.test.ts
@@ -0,0 +1,269 @@
+import { TestNetwork, SeedClient } from '@atproto/dev-env'
+import AtpAgent, {
+  ComAtprotoAdminDefs,
+  ComAtprotoAdminEmitModerationEvent,
+  ComAtprotoAdminQueryModerationStatuses,
+} from '@atproto/api'
+import basicSeed from '../seeds/basic'
+import {
+  REASONMISLEADING,
+  REASONSPAM,
+} from '../../src/lexicon/types/com/atproto/moderation/defs'
+import {
+  REVIEWCLOSED,
+  REVIEWOPEN,
+} from '@atproto/api/src/client/types/com/atproto/admin/defs'
+import { REASONAPPEAL } from '@atproto/api/src/client/types/com/atproto/moderation/defs'
+import { REVIEWESCALATED } from '../../src/lexicon/types/com/atproto/admin/defs'
+
+describe('moderation-appeals', () => {
+  let network: TestNetwork
+  let agent: AtpAgent
+  let pdsAgent: AtpAgent
+  let sc: SeedClient
+
+  const emitModerationEvent = async (
+    eventData: ComAtprotoAdminEmitModerationEvent.InputSchema,
+  ) => {
+    return pdsAgent.api.com.atproto.admin.emitModerationEvent(eventData, {
+      encoding: 'application/json',
+      headers: network.bsky.adminAuthHeaders('moderator'),
+    })
+  }
+
+  const queryModerationStatuses = (
+    statusQuery: ComAtprotoAdminQueryModerationStatuses.QueryParams,
+  ) =>
+    agent.api.com.atproto.admin.queryModerationStatuses(statusQuery, {
+      headers: network.bsky.adminAuthHeaders('moderator'),
+    })
+
+  beforeAll(async () => {
+    network = await TestNetwork.create({
+      dbPostgresSchema: 'bsky_moderation_statuses',
+    })
+    agent = network.bsky.getClient()
+    pdsAgent = network.pds.getClient()
+    sc = network.getSeedClient()
+    await basicSeed(sc)
+    await network.processAll()
+  })
+
+  afterAll(async () => {
+    await network.close()
+  })
+
+  const assertSubjectStatus = async (
+    subject: string,
+    status: string,
+    appealed: boolean | undefined,
+  ): Promise<ComAtprotoAdminDefs.SubjectStatusView | undefined> => {
+    const { data } = await queryModerationStatuses({
+      subject,
+    })
+    expect(data.subjectStatuses[0]?.reviewState).toEqual(status)
+    expect(data.subjectStatuses[0]?.appealed).toEqual(appealed)
+    return data.subjectStatuses[0]
+  }
+  describe('appeals from users', () => {
+    const getBobsPostSubject = () => ({
+      $type: 'com.atproto.repo.strongRef',
+      uri: sc.posts[sc.dids.bob][1].ref.uriStr,
+      cid: sc.posts[sc.dids.bob][1].ref.cidStr,
+    })
+    const getCarolPostSubject = () => ({
+      $type: 'com.atproto.repo.strongRef',
+      uri: sc.posts[sc.dids.carol][0].ref.uriStr,
+      cid: sc.posts[sc.dids.carol][0].ref.cidStr,
+    })
+    const assertBobsPostStatus = async (
+      status: string,
+      appealed: boolean | undefined,
+    ) => assertSubjectStatus(getBobsPostSubject().uri, status, appealed)
+
+    it('only changes subject status if original author of the content or a moderator is appealing', async () => {
+      // Create a report by alice
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventReport',
+          reportType: REASONMISLEADING,
+        },
+        subject: getBobsPostSubject(),
+        createdBy: sc.dids.alice,
+      })
+
+      await assertBobsPostStatus(REVIEWOPEN, undefined)
+
+      // Create a report as normal user with appeal type
+      expect(
+        sc.createReport({
+          reportedBy: sc.dids.carol,
+          reasonType: REASONAPPEAL,
+          reason: 'appealing',
+          subject: getBobsPostSubject(),
+        }),
+      ).rejects.toThrow('You cannot appeal this report')
+
+      // Verify that the appeal status did not change
+      await assertBobsPostStatus(REVIEWOPEN, undefined)
+
+      // Emit report event as moderator
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventReport',
+          reportType: REASONAPPEAL,
+        },
+        subject: getBobsPostSubject(),
+        createdBy: sc.dids.alice,
+      })
+
+      // Verify that appeal status changed when appeal report was emitted by moderator
+      const status = await assertBobsPostStatus(REVIEWOPEN, true)
+      expect(status?.appealedAt).not.toBeNull()
+
+      // Create a report as normal user for carol's post
+      await sc.createReport({
+        reportedBy: sc.dids.alice,
+        reasonType: REASONMISLEADING,
+        reason: 'lies!',
+        subject: getCarolPostSubject(),
+      })
+
+      // Verify that the appeal status on carol's post is undefined
+      await assertSubjectStatus(
+        getCarolPostSubject().uri,
+        REVIEWOPEN,
+        undefined,
+      )
+
+      await sc.createReport({
+        reportedBy: sc.dids.carol,
+        reasonType: REASONAPPEAL,
+        reason: 'appealing',
+        subject: getCarolPostSubject(),
+      })
+      // Verify that the appeal status on carol's post is true
+      await assertSubjectStatus(getCarolPostSubject().uri, REVIEWOPEN, true)
+    })
+    it('allows multiple appeals and updates last appealed timestamp', async () => {
+      // Resolve appeal with acknowledge
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventResolveAppeal',
+        },
+        subject: getBobsPostSubject(),
+        createdBy: sc.dids.carol,
+      })
+
+      const previousStatus = await assertBobsPostStatus(REVIEWOPEN, false)
+
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventReport',
+          reportType: REASONAPPEAL,
+        },
+        subject: getBobsPostSubject(),
+        createdBy: sc.dids.bob,
+      })
+
+      // Verify that even after the appeal event by bob for his post, the appeal status is true again with new timestamp
+      const newStatus = await assertBobsPostStatus(REVIEWOPEN, true)
+      expect(
+        new Date(`${previousStatus?.lastAppealedAt}`).getTime(),
+      ).toBeLessThan(new Date(`${newStatus?.lastAppealedAt}`).getTime())
+    })
+  })
+
+  describe('appeal resolution', () => {
+    const getAlicesPostSubject = () => ({
+      $type: 'com.atproto.repo.strongRef',
+      uri: sc.posts[sc.dids.alice][1].ref.uriStr,
+      cid: sc.posts[sc.dids.alice][1].ref.cidStr,
+    })
+    it('appeal status is maintained while review state changes based on incoming events', async () => {
+      // Bob reports alice's post
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventReport',
+          reportType: REASONMISLEADING,
+        },
+        subject: getAlicesPostSubject(),
+        createdBy: sc.dids.bob,
+      })
+
+      // Moderator acknowledges the report, assume a label was applied too
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventAcknowledge',
+        },
+        subject: getAlicesPostSubject(),
+        createdBy: sc.dids.carol,
+      })
+
+      // Alice appeals the report
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventReport',
+          reportType: REASONAPPEAL,
+        },
+        subject: getAlicesPostSubject(),
+        createdBy: sc.dids.alice,
+      })
+
+      await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true)
+
+      // Bob reports it again
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventReport',
+          reportType: REASONSPAM,
+        },
+        subject: getAlicesPostSubject(),
+        createdBy: sc.dids.bob,
+      })
+
+      // Assert that the status is still REVIEWOPEN, as report events are meant to do
+      await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true)
+
+      // Emit an escalation event
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventEscalate',
+        },
+        subject: getAlicesPostSubject(),
+        createdBy: sc.dids.carol,
+      })
+
+      await assertSubjectStatus(
+        getAlicesPostSubject().uri,
+        REVIEWESCALATED,
+        true,
+      )
+
+      // Emit an acknowledge event
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventAcknowledge',
+        },
+        subject: getAlicesPostSubject(),
+        createdBy: sc.dids.carol,
+      })
+
+      // Assert that status moved on to reviewClosed while appealed status is still true
+      await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED, true)
+
+      // Emit a resolveAppeal event
+      await emitModerationEvent({
+        event: {
+          $type: 'com.atproto.admin.defs#modEventResolveAppeal',
+          comment: 'lgtm',
+        },
+        subject: getAlicesPostSubject(),
+        createdBy: sc.dids.carol,
+      })
+
+      // Assert that status stayed the same while appealed status is still true
+      await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED, false)
+    })
+  })
+})
diff --git a/packages/dev-env/src/seed-client.ts b/packages/dev-env/src/seed-client.ts
index 71dfebd5..7fc57d52 100644
--- a/packages/dev-env/src/seed-client.ts
+++ b/packages/dev-env/src/seed-client.ts
@@ -448,7 +448,7 @@ export class SeedClient {
     reason?: string
     createdBy?: string
   }) {
-    const { id, subject, reason = 'X', createdBy = 'did:example:admin' } = opts
+    const { subject, reason = 'X', createdBy = 'did:example:admin' } = opts
     const result = await this.agent.api.com.atproto.admin.emitModerationEvent(
       {
         subject,
diff --git a/packages/lex-cli/src/codegen/client.ts b/packages/lex-cli/src/codegen/client.ts
index 33b3a53f..bf7c8892 100644
--- a/packages/lex-cli/src/codegen/client.ts
+++ b/packages/lex-cli/src/codegen/client.ts
@@ -4,13 +4,7 @@ import {
   SourceFile,
   VariableDeclarationKind,
 } from 'ts-morph'
-import {
-  Lexicons,
-  LexiconDoc,
-  LexXrpcProcedure,
-  LexXrpcQuery,
-  LexRecord,
-} from '@atproto/lexicon'
+import { Lexicons, LexiconDoc, LexRecord } from '@atproto/lexicon'
 import { NSID } from '@atproto/syntax'
 import { gen, utilTs, lexiconsTs } from './common'
 import { GeneratedAPI } from '../types'
diff --git a/packages/pds/src/api/com/atproto/moderation/util.ts b/packages/pds/src/api/com/atproto/moderation/util.ts
index 4de1e8cd..e7c33629 100644
--- a/packages/pds/src/api/com/atproto/moderation/util.ts
+++ b/packages/pds/src/api/com/atproto/moderation/util.ts
@@ -10,6 +10,7 @@ import {
   REASONRUDE,
   REASONSEXUAL,
   REASONVIOLATION,
+  REASONAPPEAL,
 } from '../../../../lexicon/types/com/atproto/moderation/defs'
 import { parseCidParam } from '../../../../util/params'
 
@@ -49,4 +50,5 @@ const reasonTypes = new Set([
   REASONRUDE,
   REASONSEXUAL,
   REASONVIOLATION,
+  REASONAPPEAL,
 ])
diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts
index c51998a6..40c50cd1 100644
--- a/packages/pds/src/lexicon/index.ts
+++ b/packages/pds/src/lexicon/index.ts
@@ -135,6 +135,7 @@ export const COM_ATPROTO_MODERATION = {
   DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual',
   DefsReasonRude: 'com.atproto.moderation.defs#reasonRude',
   DefsReasonOther: 'com.atproto.moderation.defs#reasonOther',
+  DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal',
 }
 export const APP_BSKY_GRAPH = {
   DefsModlist: 'app.bsky.graph.defs#modlist',
diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts
index 3d6c725e..dbbac6b7 100644
--- a/packages/pds/src/lexicon/lexicons.ts
+++ b/packages/pds/src/lexicon/lexicons.ts
@@ -102,6 +102,7 @@ export const schemaDict = {
               'lex:com.atproto.admin.defs#modEventAcknowledge',
               'lex:com.atproto.admin.defs#modEventEscalate',
               'lex:com.atproto.admin.defs#modEventMute',
+              'lex:com.atproto.admin.defs#modEventResolveAppeal',
             ],
           },
           subject: {
@@ -237,9 +238,20 @@ export const schemaDict = {
             type: 'string',
             format: 'datetime',
           },
+          lastAppealedAt: {
+            type: 'string',
+            format: 'datetime',
+            description:
+              'Timestamp referencing when the author of the subject appealed a moderation action',
+          },
           takendown: {
             type: 'boolean',
           },
+          appealed: {
+            type: 'boolean',
+            description:
+              'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.',
+          },
           suspendUntil: {
             type: 'string',
             format: 'datetime',
@@ -717,6 +729,16 @@ export const schemaDict = {
           },
         },
       },
+      modEventResolveAppeal: {
+        type: 'object',
+        description: 'Resolve appeal on a subject',
+        properties: {
+          comment: {
+            type: 'string',
+            description: 'Describe resolution.',
+          },
+        },
+      },
       modEventComment: {
         type: 'object',
         description: 'Add a comment to a subject',
@@ -1361,6 +1383,10 @@ export const schemaDict = {
               type: 'boolean',
               description: 'Get subjects that were taken down',
             },
+            appealed: {
+              type: 'boolean',
+              description: 'Get subjects in unresolved appealed status',
+            },
             limit: {
               type: 'integer',
               minimum: 1,
@@ -1946,6 +1972,7 @@ export const schemaDict = {
           'com.atproto.moderation.defs#reasonSexual',
           'com.atproto.moderation.defs#reasonRude',
           'com.atproto.moderation.defs#reasonOther',
+          'com.atproto.moderation.defs#reasonAppeal',
         ],
       },
       reasonSpam: {
@@ -1973,6 +2000,10 @@ export const schemaDict = {
         type: 'token',
         description: 'Other: reports not falling under another report category',
       },
+      reasonAppeal: {
+        type: 'token',
+        description: 'Appeal: appeal a previously taken moderation action',
+      },
     },
   },
   ComAtprotoRepoApplyWrites: {
diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts
index 33a4ccd1..4be9efb2 100644
--- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts
+++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts
@@ -76,6 +76,7 @@ export interface ModEventViewDetail {
     | ModEventAcknowledge
     | ModEventEscalate
     | ModEventMute
+    | ModEventResolveAppeal
     | { $type: string; [k: string]: unknown }
   subject:
     | RepoView
@@ -147,7 +148,11 @@ export interface SubjectStatusView {
   lastReviewedBy?: string
   lastReviewedAt?: string
   lastReportedAt?: string
+  /** Timestamp referencing when the author of the subject appealed a moderation action */
+  lastAppealedAt?: string
   takendown?: boolean
+  /** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */
+  appealed?: boolean
   suspendUntil?: string
   [k: string]: unknown
 }
@@ -538,6 +543,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult {
   return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v)
 }
 
+/** Resolve appeal on a subject */
+export interface ModEventResolveAppeal {
+  /** Describe resolution. */
+  comment?: string
+  [k: string]: unknown
+}
+
+export function isModEventResolveAppeal(
+  v: unknown,
+): v is ModEventResolveAppeal {
+  return (
+    isObj(v) &&
+    hasProp(v, '$type') &&
+    v.$type === 'com.atproto.admin.defs#modEventResolveAppeal'
+  )
+}
+
+export function validateModEventResolveAppeal(v: unknown): ValidationResult {
+  return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v)
+}
+
 /** Add a comment to a subject */
 export interface ModEventComment {
   comment: string
diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts b/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts
index d4e55aff..6e1aea1f 100644
--- a/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts
+++ b/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts
@@ -32,6 +32,8 @@ export interface QueryParams {
   sortDirection: 'asc' | 'desc'
   /** Get subjects that were taken down */
   takendown?: boolean
+  /** Get subjects in unresolved appealed status */
+  appealed?: boolean
   limit: number
   cursor?: string
 }
diff --git a/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts b/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts
index 81697226..08e555c2 100644
--- a/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts
+++ b/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts
@@ -13,6 +13,7 @@ export type ReasonType =
   | 'com.atproto.moderation.defs#reasonSexual'
   | 'com.atproto.moderation.defs#reasonRude'
   | 'com.atproto.moderation.defs#reasonOther'
+  | 'com.atproto.moderation.defs#reasonAppeal'
   | (string & {})
 
 /** Spam: frequent unwanted promotion, replies, mentions */
@@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
 export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'
 /** Other: reports not falling under another report category */
 export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther'
+/** Appeal: appeal a previously taken moderation action */
+export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal'