Problem Statement
The moderation system was built as a monolithic handler inside MessageReceivedEvent (bot-discord module). This single class orchestrates the entire pipeline inline: content ingestion, classification (rules + AI), case creation, routing, penalty suggestion, and enforcement execution. This creates:
- Performance degradation — AI classification (1-3s) blocks the Discord websocket event loop, stalling all other bot events.
- Untestable coupling — testing classification requires mocking the entire Discord message context and the full pipeline.
- Platform lock-in — adding Twitch, Telegram, or Web moderation would require duplicating the entire 150-line orchestration.
- Maintenance burden — all moderation logic lives in a single file that mixes domain rules with platform-specific HTTP calls.
- Domain boundary violations —
bot-discord imports and instantiates classifiers, rules, advisors, and jobs from the moderation module directly, knowing every internal detail.
Additionally, all Discord REST HTTP calls are made ad-hoc via Laravel's Http facade with copy-pasted headers, lacking type safety, retry logic, and rate limiting.
Solution
Refactor into a hybrid architecture where:
- The
moderation module owns the entire classification and routing pipeline, exposed via a single SubmitForModeration action.
- Platform modules (bot-discord) only submit content and listen to domain events for enforcement.
- All Discord HTTP communication is centralized in
integration-discord using Saloon typed connectors and requests.
- A
PlatformRegistry provides O(1) lookup of platform adapters by enum.
- Auto-execution policy is owned by the moderation domain — platforms only execute when told to.
User Stories
- As a bot operator, I want the Discord websocket event loop to remain responsive during moderation checks, so that other bot features (commands, greetings, voice) are not delayed.
- As a developer, I want to test the moderation pipeline without needing a Discord connection, so that CI runs fast and reliably.
- As a developer, I want to add a new platform (Twitch, Telegram) by implementing a single contract and registering a listener, so that multi-platform moderation is achievable in hours not days.
- As a moderator, I want rule-based violations (keyword matches) to be caught instantly (<5ms) without waiting for AI, so that obvious spam is removed immediately.
- As a moderator, I want AI-based classification to run asynchronously with retry logic, so that OpenAI outages don't crash the bot or lose flagged content.
- As a moderator, I want auto-execution to only happen for deterministic rule matches (never AI-only), so that false positives from AI always go to human review.
- As an admin, I want immunity from automated punitive actions, so that misconfigured rules cannot accidentally ban staff.
- As a developer, I want all Discord REST API calls to go through typed Saloon request classes, so that endpoints are discoverable, testable with MockClient, and have consistent timeouts/retry.
- As a developer, I want the
ModerationContentDTO to be lightweight (no Eloquent models), so that it serializes cleanly for queue jobs without stale state.
- As a developer, I want identity resolution to use the existing
FindExternalIdentity action from the identity module, so that caching and resolution logic is not duplicated.
- As a developer, I want a
PlatformRegistry with enum-based lookup, so that adapter resolution is O(1) and lazy (only instantiates what's needed).
- As a developer, I want the moderation module to never import from
bot-discord or integration-discord, so that domain logic remains platform-agnostic.
- As a developer, I want embed formatting for Discord notifications to be in a dedicated builder class, so that it's testable without HTTP mocks.
- As a developer, I want the
DiscordRoleResolver in integration-discord to encapsulate role-based protection checks, so that the adapter is thin and the resolution is independently testable.
- As a developer, I want classification and routing in a single job with routing extracted as a composable Action, so that there's no orphan state between steps but each concern is still unit-testable.
Implementation Decisions
Architecture Pattern
- Hybrid pipeline (C2): synchronous rule pre-screening + async AI classification via queue jobs.
- Event-driven enforcement: moderation emits
CaseReadyForEnforcement only when auto-execution policy passes; platform listeners handle the how.
Module Responsibilities
moderation (domain core, platform-agnostic):
SubmitForModeration — single entry point Action. Runs RuleBasedClassifier inline. Dispatches ClassifyAndRoute (rule match) or ScreenContent (no match, AI needed).
ClassifyAndRoute — async job. Classifies with AI, then delegates to RouteCaseAction. Emits CaseQueued and conditionally CaseReadyForEnforcement.
ScreenContent — async job. Calls AI, if flagged creates case + routes directly (self-contained, no second AI call).
RouteCaseAction — Action. Calculates priority, consults HistoryBasedPenaltyAdvisor, suggests action.
PlatformRegistry — singleton, maps Platform enum → adapter instance. Registered via ServiceProviders.
- Auto-execution policy:
classifier_version === 'rules' && suggested_action !== null.
integration-discord (transport layer):
Transport/DiscordConnector — Saloon connector with Bot token auth, 5s connect / 10s request timeout.
Transport/DiscordOAuthConnector — Saloon connector for OAuth flows.
Transport/Requests/ — typed request classes per Discord API endpoint (Members, Bans, Messages, Channels, OAuth).
Transport/DiscordRoleResolver — uses GetMember request, resolves protection tier (admin/mod/none) from configured role IDs.
OAuth/DiscordOAuthClient — refactored to use DiscordOAuthConnector + typed requests instead of Http:: facade.
bot-discord (runtime + platform glue):
MessageReceivedEvent — thin: resolves tenant, tracks activity, calls SubmitForModeration. No classification logic.
DiscordModerationAdapter — implements ModerationPlatformContract. Uses DiscordConnector + DiscordRoleResolver. Delegates HTTP to integration-discord.
Listeners/AutoExecuteAction — listens CaseReadyForEnforcement, creates ModerationAction, dispatches ExecuteAction.
Listeners/NotifyModerationChannel — listens CaseQueued, uses ModerationEmbedBuilder + CreateMessage request.
Moderation/ModerationEmbedBuilder — formats case data into Discord embed arrays.
Identity Resolution
FindExternalIdentity (identity module) gains optional ?string $tenantId parameter for non-HTTP contexts (bot/queue).
ModerationContentDTO carries only authorExternalId (string). SubmitForModeration resolves to author_id via FindExternalIdentity when creating the case.
DTO Changes
ModerationContentDTO: remove ?User $author property. Keep authorExternalId, tenantId, and platform data only. Fully serializable.
Domain Events
| Event |
Emitter |
Consumers |
CaseCreated |
SubmitForModeration / ScreenContent |
Audit log |
CaseQueued |
ClassifyAndRoute / ScreenContent |
NotifyModerationChannel (bot-discord) |
CaseReadyForEnforcement |
ClassifyAndRoute / ScreenContent |
AutoExecuteAction (bot-discord) |
CaseResolved |
ExecuteAction |
Audit log |
ActionExecuted |
ExecuteAction |
Audit log |
Dependency Rules (enforced)
moderation → imports from identity only (via FindExternalIdentity)
integration-discord → imports from identity only (OAuth DTOs)
bot-discord → imports from moderation (events, contracts, models) + integration-discord (Transport)
Testing Decisions
What makes a good test
- Test external behavior through public interfaces, not internal implementation details.
- Use factories and Saloon's
MockClient instead of Http::fake() for transport tests.
- One assertion concept per test (a test name should describe the behavior, not the mechanics).
Modules to test
moderation:
SubmitForModeration — unit test: rule match creates case + dispatches job; no match dispatches ScreenContent; below-threshold returns null.
ClassifyAndRoute ��� feature test: classifies, updates case, emits events. Test auto-execution policy (rules → event emitted; AI → no event).
ScreenContent — feature test: AI flags → case created + routed; AI clears → no case.
RouteCaseAction �� unit test: priority calculation, penalty escalation, suggested action.
PlatformRegistry — unit test: register, resolve, resolve-unknown-throws.
integration-discord:
DiscordConnector — unit test: correct base URL, auth header format Bot <token>.
- Saloon requests — unit test per request: correct endpoint, method, payload shape.
DiscordRoleResolver — unit test with MockClient: admin tier, mod tier, no tier, API failure.
DiscordOAuthClient — feature test: token exchange, user retrieval (via MockClient).
bot-discord:
DiscordModerationAdapter — feature test with MockClient: execute mute/kick/ban/warn, protection checks, failure scenarios. (Existing tests migrated from Http::fake to Saloon MockClient.)
AutoExecuteAction listener — feature test: creates action + dispatches job when event received; ignores non-Discord platforms.
NotifyModerationChannel listener — feature test: sends embed via connector; handles missing config gracefully.
ModerationEmbedBuilder — unit test: correct embed structure, field values, truncation.
Prior art
app-modules/moderation/tests/Feature/PipelineTest.php — existing pipeline integration tests (ingest → classify → route).
app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php — existing adapter tests with Http::fake (to be migrated to MockClient).
app-modules/moderation/tests/Unit/PenaltyAdvisorTest.php — unit test pattern for domain logic.
Out of Scope
- Admin panel (Filament) changes — no UI modifications in this refactor.
- New platform implementations (Twitch, Telegram) — this refactor enables them but doesn't implement them.
- AI model changes — keeping OpenAI Moderation API as-is; no new classifiers.
- Database migrations — no schema changes; existing tables support the new architecture.
- ETL migration to Saloon — the
integration-discord/ETL/ directory stays on Http:: for now; it's legacy import code with different concerns.
- Laracord internal changes — websocket runtime stays as-is.
- Appeal workflow changes — not affected by this refactor.
- Config structure changes —
config/moderation.php stays the same.
Further Notes
- ADR documented:
app-modules/moderation/docs/adr/0001-hybrid-pipeline-with-event-driven-enforcement.md
- Domain glossary:
app-modules/moderation/CONTEXT.md, app-modules/bot-discord/CONTEXT.md, app-modules/integration-discord/CONTEXT.md
- Context map:
CONTEXT-MAP.md at repo root defines dependency rules between modules.
- Saloon dependency:
saloonphp/saloon needs to be added to composer.json before transport implementation begins.
- Suggested implementation order: (1) Add Saloon + Transport layer, (2) Refactor
DiscordOAuthClient to validate Transport works, (3) Create PlatformRegistry + SubmitForModeration, (4) Create ScreenContent + refactor ClassifyAndRoute, (5) Refactor MessageReceivedEvent to be thin, (6) Create listeners (AutoExecuteAction, refactor NotifyModerationChannel), (7) Migrate adapter tests to MockClient.
Problem Statement
The moderation system was built as a monolithic handler inside
MessageReceivedEvent(bot-discord module). This single class orchestrates the entire pipeline inline: content ingestion, classification (rules + AI), case creation, routing, penalty suggestion, and enforcement execution. This creates:bot-discordimports and instantiates classifiers, rules, advisors, and jobs from themoderationmodule directly, knowing every internal detail.Additionally, all Discord REST HTTP calls are made ad-hoc via Laravel's
Httpfacade with copy-pasted headers, lacking type safety, retry logic, and rate limiting.Solution
Refactor into a hybrid architecture where:
moderationmodule owns the entire classification and routing pipeline, exposed via a singleSubmitForModerationaction.integration-discordusing Saloon typed connectors and requests.PlatformRegistryprovides O(1) lookup of platform adapters by enum.User Stories
ModerationContentDTOto be lightweight (no Eloquent models), so that it serializes cleanly for queue jobs without stale state.FindExternalIdentityaction from the identity module, so that caching and resolution logic is not duplicated.PlatformRegistrywith enum-based lookup, so that adapter resolution is O(1) and lazy (only instantiates what's needed).bot-discordorintegration-discord, so that domain logic remains platform-agnostic.DiscordRoleResolverinintegration-discordto encapsulate role-based protection checks, so that the adapter is thin and the resolution is independently testable.Implementation Decisions
Architecture Pattern
CaseReadyForEnforcementonly when auto-execution policy passes; platform listeners handle the how.Module Responsibilities
moderation (domain core, platform-agnostic):
SubmitForModeration— single entry point Action. RunsRuleBasedClassifierinline. DispatchesClassifyAndRoute(rule match) orScreenContent(no match, AI needed).ClassifyAndRoute— async job. Classifies with AI, then delegates toRouteCaseAction. EmitsCaseQueuedand conditionallyCaseReadyForEnforcement.ScreenContent— async job. Calls AI, if flagged creates case + routes directly (self-contained, no second AI call).RouteCaseAction— Action. Calculates priority, consultsHistoryBasedPenaltyAdvisor, suggests action.PlatformRegistry— singleton, mapsPlatformenum → adapter instance. Registered via ServiceProviders.classifier_version === 'rules' && suggested_action !== null.integration-discord (transport layer):
Transport/DiscordConnector— Saloon connector with Bot token auth, 5s connect / 10s request timeout.Transport/DiscordOAuthConnector— Saloon connector for OAuth flows.Transport/Requests/— typed request classes per Discord API endpoint (Members, Bans, Messages, Channels, OAuth).Transport/DiscordRoleResolver— usesGetMemberrequest, resolves protection tier (admin/mod/none) from configured role IDs.OAuth/DiscordOAuthClient— refactored to useDiscordOAuthConnector+ typed requests instead ofHttp::facade.bot-discord (runtime + platform glue):
MessageReceivedEvent— thin: resolves tenant, tracks activity, callsSubmitForModeration. No classification logic.DiscordModerationAdapter— implementsModerationPlatformContract. UsesDiscordConnector+DiscordRoleResolver. Delegates HTTP to integration-discord.Listeners/AutoExecuteAction— listensCaseReadyForEnforcement, createsModerationAction, dispatchesExecuteAction.Listeners/NotifyModerationChannel— listensCaseQueued, usesModerationEmbedBuilder+CreateMessagerequest.Moderation/ModerationEmbedBuilder— formats case data into Discord embed arrays.Identity Resolution
FindExternalIdentity(identity module) gains optional?string $tenantIdparameter for non-HTTP contexts (bot/queue).ModerationContentDTOcarries onlyauthorExternalId(string).SubmitForModerationresolves toauthor_idviaFindExternalIdentitywhen creating the case.DTO Changes
ModerationContentDTO: remove?User $authorproperty. KeepauthorExternalId,tenantId, and platform data only. Fully serializable.Domain Events
CaseCreatedSubmitForModeration/ScreenContentCaseQueuedClassifyAndRoute/ScreenContentNotifyModerationChannel(bot-discord)CaseReadyForEnforcementClassifyAndRoute/ScreenContentAutoExecuteAction(bot-discord)CaseResolvedExecuteActionActionExecutedExecuteActionDependency Rules (enforced)
moderation→ imports fromidentityonly (viaFindExternalIdentity)integration-discord→ imports fromidentityonly (OAuth DTOs)bot-discord→ imports frommoderation(events, contracts, models) +integration-discord(Transport)Testing Decisions
What makes a good test
MockClientinstead ofHttp::fake()for transport tests.Modules to test
moderation:
SubmitForModeration— unit test: rule match creates case + dispatches job; no match dispatchesScreenContent; below-threshold returns null.ClassifyAndRoute��� feature test: classifies, updates case, emits events. Test auto-execution policy (rules → event emitted; AI → no event).ScreenContent— feature test: AI flags → case created + routed; AI clears → no case.RouteCaseAction�� unit test: priority calculation, penalty escalation, suggested action.PlatformRegistry— unit test: register, resolve, resolve-unknown-throws.integration-discord:
DiscordConnector— unit test: correct base URL, auth header formatBot <token>.DiscordRoleResolver— unit test withMockClient: admin tier, mod tier, no tier, API failure.DiscordOAuthClient— feature test: token exchange, user retrieval (viaMockClient).bot-discord:
DiscordModerationAdapter— feature test withMockClient: execute mute/kick/ban/warn, protection checks, failure scenarios. (Existing tests migrated fromHttp::faketo SaloonMockClient.)AutoExecuteActionlistener — feature test: creates action + dispatches job when event received; ignores non-Discord platforms.NotifyModerationChannellistener — feature test: sends embed via connector; handles missing config gracefully.ModerationEmbedBuilder— unit test: correct embed structure, field values, truncation.Prior art
app-modules/moderation/tests/Feature/PipelineTest.php— existing pipeline integration tests (ingest → classify → route).app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php— existing adapter tests with Http::fake (to be migrated to MockClient).app-modules/moderation/tests/Unit/PenaltyAdvisorTest.php— unit test pattern for domain logic.Out of Scope
integration-discord/ETL/directory stays onHttp::for now; it's legacy import code with different concerns.config/moderation.phpstays the same.Further Notes
app-modules/moderation/docs/adr/0001-hybrid-pipeline-with-event-driven-enforcement.mdapp-modules/moderation/CONTEXT.md,app-modules/bot-discord/CONTEXT.md,app-modules/integration-discord/CONTEXT.mdCONTEXT-MAP.mdat repo root defines dependency rules between modules.saloonphp/saloonneeds to be added tocomposer.jsonbefore transport implementation begins.DiscordOAuthClientto validate Transport works, (3) CreatePlatformRegistry+SubmitForModeration, (4) CreateScreenContent+ refactorClassifyAndRoute, (5) RefactorMessageReceivedEventto be thin, (6) Create listeners (AutoExecuteAction, refactorNotifyModerationChannel), (7) Migrate adapter tests to MockClient.