Skip to Content
Part 4: Proactive IntelligenceCh 16: Alert Evaluation

Chapter 15 covered steps 1-5: reception, normalization, persistence, entity resolution, and rule loading. This chapter covers steps 6-9: trigger matching, deduplication, alert persistence, and delivery.

Step 6: Trigger Matching

This is where events become alerts. Each event is tested against trigger rules to see if it matches:

// src/domain/alerts/triggers/dealTriggers.ts, lines 35-50 export function evaluateDealTriggers( event: NormalizedWebhookEvent, context: DealContext, ruleMap: Map<string, TriggerRule> ): TriggerMatch[] { const matches: TriggerMatch[] = []; if (!event.propertyName || event.newValue === null) return matches; // 1. Deal stage regression const regressionRule = ruleMap.get('deal_stage_regression'); if (regressionRule && event.propertyName === 'dealstage') { const newStage = (event.newValue || '').toLowerCase(); const oldStage = (context.pipelineStage || '').toLowerCase(); const newOrder = STAGE_ORDER[newStage]; const oldOrder = STAGE_ORDER[oldStage]; if (newOrder !== undefined && oldOrder !== undefined && newOrder < oldOrder) { matches.push({ triggerType: 'deal_stage_regression', urgencyScore: regressionRule.urgencyOverride ?? 85, title: `Deal regression: ${context.companyName}`, message: `Deal moved backward from ${oldStage} to ${newStage}.`, signalHash: makeSignalHash(event.provider, event.entityId, 'dealstage', 'deal_stage_regression'), dedupKey: makeDedupKey(context.dealId, 'deal_stage_regression'), // ... more fields }); } }

How Stage Regression Detection Works

The trigger doesn’t just check “did the stage change?” — it checks “did it move backward?” This requires knowing the pipeline order:

const STAGE_ORDER = { 'appointmentscheduled': 1, 'qualificationmeeting': 2, 'proposalsent': 3, 'negotiation': 4, 'closedwon': 5, 'closedlost': 5, };

If a deal moves from stage 3 (Proposal Sent) to stage 2 (Qualification Meeting), that’s a regression — newOrder < oldOrder. Moving from stage 2 to stage 3 is progression (not an alert). Moving from stage 3 to stage 5 (Closed Won) is also fine.

The Full Trigger Set

Astrelo evaluates 14 trigger types across three categories:

Deal Triggers (5):

TriggerConditionDefault Urgency
deal_stage_regressionStage moves backward85
deal_value_decreaseValue drops >20%80
deal_value_increaseValue rises >20%60
deal_close_date_pushedClose date pushed >14 days75
deal_competitor_addedCompetitor field set (was empty)90

Engagement Triggers (5):

TriggerConditionDefault Urgency
new_contact_high_fitNew contact at company with Fit >=7065
email_opened_multiple3+ opens on same email55
meeting_completedMeeting marked as completed50
form_submissionForm submitted on website60
page_view_pricingPricing page visited70

Behavioral Triggers (4):

TriggerConditionDefault Urgency
email_reply_sentiment_negativeNegative sentiment in reply85
activity_slumpNo activity for 14+ days on active deal75
champion_title_changeChampion’s title changes (possible departure)80
multi_thread_engagement3+ contacts engage in same week60

Signal Hash and Dedup Key

Every trigger match produces two deduplication identifiers:

signalHash: makeSignalHash('hubspot', '98765', 'dealstage', 'deal_stage_regression'), // → SHA-256 of "hubspot:98765:dealstage:deal_stage_regression" dedupKey: makeDedupKey('deal-uuid-123', 'deal_stage_regression'), // → "deal-uuid-123:deal_stage_regression"

signalHash is a permanent, global unique constraint. The same event from the same provider for the same property and trigger type can never create two alerts. This prevents duplicates from webhook retries.

dedupKey is a time-windowed check. Within 60 minutes, if an alert with the same dedup key already exists, the new one is skipped. This prevents alert spam when a deal stage bounces back and forth multiple times.

Step 7: Deduplication

// src/domain/alerts/services/alertEvaluationService.ts, lines 240-260 const dedupedMatches = []; for (const match of matches) { if (match.dedupKey) { const exists = await hasRecentDedupAlert(userId, match.dedupKey); if (exists) continue; } dedupedMatches.push(match); }

hasRecentDedupAlert runs:

SELECT 1 FROM realtime_alerts WHERE user_id = $1 AND dedup_key = $2 AND created_at > NOW() - INTERVAL '60 minutes' AND dismissed_at IS NULL LIMIT 1

If a match exists within the last hour, the new alert is silently dropped.

Rate Limiting

Beyond deduplication, there’s a global rate limit:

// Max 20 alerts per hour per user const recentCount = await getRecentAlertCount(userId, 60); if (recentCount >= 20) { return { alertsCreated: 0 }; } const allowedCount = Math.max(0, 20 - recentCount); const finalMatches = dedupedMatches.slice(0, allowedCount);

This prevents a runaway CRM sync from flooding the user with hundreds of alerts. If 30 deals all change stage simultaneously, only the first 20 get alerts.

Step 8: Alert Persistence and Action Creation

Alerts are written to realtime_alerts. Simultaneously, the system creates pending autonomous actions — AI-drafted responses that the user can approve:

// src/domain/alerts/services/alertEvaluationService.ts, lines 270-300 if (alerts.length > 0) { for (const alert of alerts) { const actionType = TRIGGER_ACTION_MAP[alert.triggerType]; if (!actionType) continue; const params = await buildActionParams(userId, alert, actionType); await createPendingAction({ userId, triggerType: alert.triggerType, triggerAlertId: alert.id, actionType, actionParams: params, contextSummary: alert.suggestedAction || alert.message, companyId: alert.contextData?.companyId, dealId: alert.contextData?.dealId, }); } }

The TRIGGER_ACTION_MAP maps each trigger type to a suggested action:

deal_stage_regression → create_followup_task deal_value_decrease → draft_email deal_competitor_added → create_followup_task activity_slump → draft_email new_contact_high_fit → draft_email

These actions land in the pending_autonomous_actions table with status = 'pending', waiting for user approval (Chapter 18).

Step 9: AI Content Generation and Delivery

After alerts are persisted, two things happen in parallel:

AI Content Queue:

// Queue AI content generation in chunks of 3 const CHUNK_SIZE = 3; for (let i = 0; i < alertIds.length; i += CHUNK_SIZE) { const chunk = alertIds.slice(i, i + CHUNK_SIZE); await pool.query( `INSERT INTO jobs (user_id, job_type, status, params) VALUES ($1, 'alert_ai_content', 'pending', $2)`, [userId, JSON.stringify({ alertIds: chunk })] ); }

These jobs are processed by a separate cron (every minute) that calls the LLM to generate analysis, recommendations, and action items for each alert.

Slack Delivery:

for (const alert of alerts) { const rule = rules.find(r => r.triggerType === alert.triggerType); if (rule?.deliveryChannels.includes('slack')) { await deliverToSlack(`realtime.${alert.triggerType}`, userId, { alert_id: alert.id, title: alert.title, message: alert.message, suggested_action: alert.suggestedAction, }); } }

Only alerts whose trigger rules include ‘slack’ in delivery_channels get sent to Slack.

Auto-Marking Prediction Outcomes

A bonus step in the pipeline: when a deal closes (won or lost), the system automatically marks any existing predictions for that deal:

// If a deal stage changes to closedwon or closedlost if (event.entityType === 'deal' && event.propertyName === 'dealstage') { const isClosedWon = stageLower.includes('closedwon'); const isClosedLost = stageLower.includes('closedlost'); if (isClosedWon || isClosedLost) { await markPredictionOutcomes(userId, dealId, isClosedWon ? 'won' : 'lost'); } }

This closes the feedback loop — predictions get validated against actual outcomes, improving future prediction accuracy.

Key Takeaways

  1. 14 trigger types across deals, engagement, and behavioral categories. Each has configurable urgency and enable/disable control.

  2. Two-layer deduplication: signalHash (permanent, prevents webhook retries) and dedupKey (60-minute window, prevents alert spam).

  3. Rate limiting caps alerts at 20 per hour per user. Better to miss a low-urgency alert than to flood the feed.

  4. Autonomous actions are created alongside alerts. Each trigger type maps to a suggested action (email, task, notification).

  5. AI content and Slack delivery happen asynchronously after persistence, keeping the main pipeline fast.

Next chapter: deal predictions — classifying which deals are at risk before they stall.

Last updated on