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):
| Trigger | Condition | Default Urgency |
|---|---|---|
deal_stage_regression | Stage moves backward | 85 |
deal_value_decrease | Value drops >20% | 80 |
deal_value_increase | Value rises >20% | 60 |
deal_close_date_pushed | Close date pushed >14 days | 75 |
deal_competitor_added | Competitor field set (was empty) | 90 |
Engagement Triggers (5):
| Trigger | Condition | Default Urgency |
|---|---|---|
new_contact_high_fit | New contact at company with Fit >=70 | 65 |
email_opened_multiple | 3+ opens on same email | 55 |
meeting_completed | Meeting marked as completed | 50 |
form_submission | Form submitted on website | 60 |
page_view_pricing | Pricing page visited | 70 |
Behavioral Triggers (4):
| Trigger | Condition | Default Urgency |
|---|---|---|
email_reply_sentiment_negative | Negative sentiment in reply | 85 |
activity_slump | No activity for 14+ days on active deal | 75 |
champion_title_change | Champion’s title changes (possible departure) | 80 |
multi_thread_engagement | 3+ contacts engage in same week | 60 |
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 1If 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_emailThese 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
-
14 trigger types across deals, engagement, and behavioral categories. Each has configurable urgency and enable/disable control.
-
Two-layer deduplication:
signalHash(permanent, prevents webhook retries) anddedupKey(60-minute window, prevents alert spam). -
Rate limiting caps alerts at 20 per hour per user. Better to miss a low-urgency alert than to flood the feed.
-
Autonomous actions are created alongside alerts. Each trigger type maps to a suggested action (email, task, notification).
-
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.