AI modification moderation
AI participates in the approval workflow by analyzing pending Modification records and casting approve/disapprove votes as a configured system user.
Canonical architecture: Modules/Core/docs/EVENT_ORCHESTRATION.md (section 2).
CMS comment adapter: Modules/CMS/docs/COMMENT_MODERATION.md.
Decoupling rules
| Rule | Detail |
|---|---|
| AI does not import CMS | No Comment, Content, or CMS events |
| Opt-in via registry | ModerationContextBuilderRegistry::supports($modification) |
| Per-entity toggle | ai.features.moderation.entities.{table}, seeded by the AI module for each model with a registered ModerationAdapter |
| Outcome on Core tables | meta JSON on approvals / disapprovals |
Pipeline (AI responsibilities)
Listener: HandleModificationModerationListener
Runs when:
config('ai.features.moderation.enabled')- A user carries the username in
config('permission.users.system')(Core seeds it; envSYSTEM_USER, defaultsystem) Modificationis active- Registry has a builder for
modifiable_type - Modifiable model has AI moderation enabled (settings)
Actions:
addRequiredPreProcessing('ai_approval')- Cache event under
modification_moderation:{id} - Dispatch
ApproveModificationJob markAsHandled()
Job: ApproveModificationJob
ModerationContextBuilderRegistry::build($modification)ModerationService::analyze($context)→ModerationResult. If the analysis fails, the uncertain fallback applies (only when AI votes are enabled).- Apply policy (threshold / dual / uncertain fallback), which sets the quorum: auto approve/reject 1/1, dual 2/2, uncertain fallback 1 approver / 2 disapprovers
- Core
ModificationVoteService::castWithQuorum()as system user: sets that quorum, records the vote with itsmeta, and applies the decision in one transaction. The quorum counts votes already cast (for example an author's approve credit), so a lowered quorum that existing votes reach is applied, never left pending. A failure while voting fails the job (it is retried); it does not fall back to a second vote. The job acts as the system user by setting the guard's user and restoring the previous one afterwards, without logging in or out, so the system user's remember token is never rotated. ModificationPreProcessingCompleted('ai_approval')
Service: ModerationService
- Prompt:
Ai\Prompts\ModerationPrompt - Structured JSON verdict (approve / reject / uncertain)
- Neuron structured output (
ModerationVerdictData): an answer that does not fit the schema is asked again once, then the verdict isuncertain
Approval modes
Configured via ai.features.moderation.approval_mode (ModerationApprovalMode enum):
| Mode | Behaviour |
|---|---|
threshold |
Auto approve/reject when confidence ≥ thresholds; else preliminary disapprove for human review |
dual |
AI casts first vote; approvers_required = 2; human second vote required |
Thresholds:
approve_confidence_threshold(default 0.85)reject_confidence_threshold(default 0.85)safe_to_auto_approvefrom LLM JSON
Configuration
Every value is a setting in Filament > Settings, group ai, read as ai.<name>; none is an env variable:
| Setting | Seeded | Meaning |
|---|---|---|
features.moderation.enabled |
false |
Master switch |
features.moderation.approval_mode |
threshold |
threshold or dual |
features.moderation.votes |
true |
Allow AI votes in the approval workflow |
features.moderation.threshold.approve |
0.85 |
Approval confidence threshold |
features.moderation.threshold.reject |
0.85 |
Rejection confidence threshold |
features.moderation.queue |
default |
Queue of ApproveModificationJob, read when the job is dispatched |
features.moderation.model |
ollama:llama3.2:3b |
The moderation model (see rag/AI_MODEL_SELECTION_USER.md) |
The env variables AI_MODERATION_ENABLED, AI_MODERATION_APPROVAL_MODE, AI_MODERATION_AI_VOTES, AI_MODERATION_APPROVE_THRESHOLD, AI_MODERATION_REJECT_THRESHOLD and the AI_COMMENT_* variables are read by nothing. AI_MODERATION_QUEUE and its fallback AI_COMMENT_MOD_QUEUE were read until 2026-10-07, when the queue became the setting features.moderation.queue. AI_MODERATION_PROVIDER and AI_COMMENT_MOD_PROVIDER were removed on 2026-09-29.
Per-model (Core settings, group moderation):
ai.features.moderation.entities.cms_comments— enable AI for comments (seeded false)
Post-approval translation
HandleModificationApprovedTranslationListener listens to Core ModificationApproved, which ModificationVoteService fires for every model once the approval is committed (ModificationRejected and ModificationWithdrawn are fired the same way and have no AI listener):
Independent from moderation config except shared ai.features.translation.enabled.
Vote metadata (meta)
Example shape written by ApproveModificationJob:
{
"source": "ai",
"status": "auto_approved",
"verdict": "approve",
"confidence": 0.92,
"categories": [],
"reason": "On-topic and respectful.",
"analyzed_at": "2026-05-15T12:00:00+00:00"
}
Read in Filament via Modification::latestAutomatedVoteMeta().
Tests
| Test file | Coverage |
|---|---|
tests/Feature/ModificationModerationListenerTest.php |
Listener gates + queue |
tests/Feature/Jobs/ApproveModificationJobTest.php |
Threshold outcomes + meta |
Modules/Core/tests/Feature/Events/ModificationRequiresModerationEmitTest.php |
Core emitter |
CRUD tools and approvals
The assistant's create, update and delete CRUD tools go through Core's CrudService. When the entity sends the write to approval, the write is not applied and the tool answers status: pending_approval with the request id (modification) and its operation, instead of a record. The tool descriptions tell the model so.