Skip to main content

What are guards?

Guards are the brain of Presentum. They contain your business logic for deciding what gets shown, when, and in what order. Guards run every time candidates change or when external triggers fire. They inspect:
  • Current state and history
  • Storage (impressions, dismissals, conversions)
  • Candidates (potential presentations)
  • Context (user data, app state)
Then they mutate state by adding, removing, or reordering items in slots.
Guards are your primary tool for scheduling presentations, removing ineligible items, periodic refreshes, and complex eligibility rules.

Basic guard

Here’s a simple guard that sets the highest priority campaign as active:
class CampaignGuard
    extends PresentumGuard<CampaignItem, AppSurface, CampaignVariant> {
  @override
  FutureOr<PresentumState> call(
    PresentumStorage storage,
    List<PresentumHistoryEntry> history,
    PresentumState$Mutable state,
    List<CampaignItem> candidates,
    Map<String, Object?> context,
  ) async {
    for (final candidate in candidates) {
      final existing = state.slots[candidate.surface]?.active;

      if (existing == null || candidate.priority > existing.priority) {
        state.setActive(candidate.surface, candidate);
      }
    }

    return state;
  }
}

Guard parameters

storage
PresentumStorage
required
Persistence layer for tracking impressions, dismissals, and conversions.
history
List<PresentumHistoryEntry>
required
Complete history of state changes. Useful for analyzing past decisions.
state
PresentumState$Mutable
required
Mutable state you can modify. Changes you make here will be committed after all guards run.
candidates
List<TItem>
required
All potential presentations. These are the items guards evaluate for eligibility.
context
Map<String, Object?>
required
Shared data between guards. Pass information from one guard to the next.

Production examples

Scheduling guard

This guard from a production app handles priority, sequencing, impression limits, and cooldowns:
final class CampaignSchedulingGuard extends CampaignGuard {
  CampaignSchedulingGuard({required this.eligibility});

  final EligibilityResolver<HasMetadata> eligibility;

  @override
  FutureOr<CampaignPresentumState> call(
    PresentumStorage storage,
    List<CampaignPresentumHistoryEntry> history,
    CampaignPresentumState$Mutable state,
    List<CampaignPresentumItem> candidates,
    Map<String, Object?> context,
  ) async {
    // 1) eligibility filter per campaign id.
    final eligibleEntries = <CampaignPresentumItem>[];
    for (final entry in candidates) {
      final isEligible = await eligibility.isEligible(entry.payload, context);
      if (isEligible) eligibleEntries.add(entry);
    }

    if (eligibleEntries.isEmpty) return state;

    // Highest priority first (per campaign), then by stage.
    eligibleEntries.sort((a, b) {
      final p = b.priority.compareTo(a.priority);
      if (p != 0) return p;
      final sa = a.stage ?? 0;
      final sb = b.stage ?? 0;
      return sa.compareTo(sb);
    });

    // Compute header-dismissed flag per campaign for sequencing logic.
    var hasHeader = true;
    var headerDismissed = false;
    if (eligibleEntries.any(
      (entry) => entry.surface == CampaignSurface.homeTopBanner,
    )) {
      for (final entry in eligibleEntries) {
        if (entry.surface != CampaignSurface.homeTopBanner) continue;
        final dismissedAt = await storage.getDismissedAt(
          entry.id,
          surface: entry.surface,
          variant: entry.variant,
        );
        if (dismissedAt != null && entry.option.isDismissible) {
          headerDismissed = true;
          break;
        }

        final cooldownMinutes = entry.option.cooldownMinutes;
        final until = dismissedAt != null && cooldownMinutes != null
            ? dismissedAt.add(Duration(minutes: cooldownMinutes))
            : null;
        headerDismissed = until != null && until.isAfter(DateTime.now());
      }
    } else {
      hasHeader = false;
    }

    // 2) always-on inline/banner per surface with header/footer sequencing.
    for (final entry in eligibleEntries) {
      final p = entry.option;
      if (!p.alwaysOnIfEligible) continue;

      final isHeaderDismissed = headerDismissed;

      // Header: show only until dismissed.
      if (entry.surface == CampaignSurface.homeTopBanner &&
          hasHeader &&
          isHeaderDismissed) {
        continue;
      }

      // Footer: enable only after header has been dismissed.
      if (entry.surface == CampaignSurface.homeFooterBanner &&
          !isHeaderDismissed &&
          hasHeader) {
        continue;
      }

      // Do not override higher-priority already set item.
      final slot = state.slots[entry.surface];
      if (slot?.active != null) continue;
      state.setActive(entry.surface, entry);
    }

    // If we had an active watchlist header in the history, don't show a popup.
    final hadAnActiveHomeTopBanner =
        history.isNotEmpty &&
        history.any(
          (entry) => entry.state.slots.values.any(
            (value) =>
                value.active?.option.surface == CampaignSurface.homeTopBanner,
          ),
        );
    if (hadAnActiveHomeTopBanner) return state;

    // 3) popup scheduling: determine order and set active + queue.
    final popupCandidates = await _popupCandidates(
      storage,
      eligibleEntries,
      state,
      context,
      headerDismissed || !hasHeader,
    );
    if (popupCandidates.isEmpty) return state;

    // First candidate becomes active, rest form the queue.
    final active = popupCandidates.first;

    state.setActive(CampaignSurface.popup, active);
    if (popupCandidates.length > 1) {
      state.setQueue(CampaignSurface.popup, popupCandidates.sublist(1));
    }

    return state;
  }

  /// Returns popup-eligible entries in display order.
  Future<List<CampaignPresentumItem>> _popupCandidates(
    PresentumStorage storage,
    List<CampaignPresentumItem> items,
    PresentumState$Mutable<
      CampaignPresentumItem,
      CampaignSurface,
      CampaignVariant
    >
    state,
    Map<String, Object?> context,
    bool headerDismissed,
  ) async {
    ...
  }
}
Full scheduling guard implementation ->

Remove ineligible guard

Removes items that are no longer eligible (e.g., expired campaigns). This guard is generic and can be reused in any presentum:
abstract base class IRemoveIneligibleCandidatesGuard<
  TItem extends PresentumItem<PresentumPayload<S, V>, S, V>,
  S extends PresentumSurface,
  V extends PresentumVisualVariant
>
    extends PresentumGuard<TItem, S, V> {
  /// {@macro remove_ineligible_candidates_guard}
  IRemoveIneligibleCandidatesGuard({required this.eligibility, super.refresh});

  final EligibilityResolver<HasMetadata> eligibility;

  @override
  FutureOr<PresentumState<TItem, S, V>> call(
    PresentumStorage storage,
    List<PresentumHistoryEntry<TItem, S, V>> history,
    PresentumState$Mutable<TItem, S, V> state,
    List<TItem> candidates,
    Map<String, Object?> context,
  ) async {
    final checkedItems = <String>{};
    final ineligibleItems = <String>{};

    // First pass: identify all ineligible items from the current state and
    // current candidates.
    final slots = [...state.slots.entries];

    for (final entry in slots) {
      final slot = entry.value;

      if (slot.active case final active?) {
        final itemId = active.id;

        // Only check eligibility once per distinct campaign
        if (checkedItems.contains(itemId)) continue;
        checkedItems.add(itemId);

        final eligible = await eligibility.isEligible(active, <String, Object?>{
          ...context,
        });

        if (!eligible) {
          ineligibleItems.add(itemId);
        }
      }

      // Also check items in queue
      for (final queuedItem in slot.queue) {
        final itemId = queuedItem.id;

        if (checkedItems.contains(itemId)) continue;
        checkedItems.add(itemId);

        final eligible = await eligibility.isEligible(
          queuedItem,
          <String, Object?>{...context},
        );

        if (!eligible) {
          ineligibleItems.add(itemId);
        }
      }
    }

    // Second pass: remove all ineligible items from all surfaces in the
    // candidate state.
    if (ineligibleItems.isNotEmpty) {
      final slots = [...state.slots.entries];
      for (final entry in slots) {
        final surface = entry.key;
        final slot = entry.value;

        final active = slot.active;
        final queue = slot.queue;

        final activeIneligible =
            active?.payload.id != null && ineligibleItems.contains(active!.id);

        // Filter queue to remove ineligible items.
        final filteredQueue = queue
            .where((queuedItem) => !ineligibleItems.contains(queuedItem.id))
            .toList();

        // If nothing was ineligible for this surface, skip.
        final hadIneligibleInQueue = filteredQueue.length != queue.length;
        if (!activeIneligible && !hadIneligibleInQueue) continue;

        // Rebuild slot in `next` with only eligible items.
        if (activeIneligible) {
          if (filteredQueue.isNotEmpty) {
            // Promote first eligible item to active.
            final nextActive = filteredQueue.first;
            final nextQueue = filteredQueue.length > 1
                ? filteredQueue.sublist(1)
                : <TItem>[];

            state.setActive(surface, nextActive);
            if (nextQueue.isNotEmpty) {
              state.setQueue(surface, nextQueue);
            }
          } else {
            // No eligible items remain on this surface. Touch the surface in
            // state so that follow‑up guards do not restore stale items.
            state.clearSurface(surface);
          }
        } else {
          // Active is still eligible, only the queue changed.
          if (active != null) {
            state.setActive(surface, active);
          }
          state.setQueue(surface, filteredQueue);
        }
      }
    }

    return state;
  }
}
Full remove ineligible guard ->

Sync state with candidates guard

Keeps state synchronized with latest candidate data using diff algorithm. This guard is generic and can be reused in any presentum
abstract base class ISyncStateWithCandidatesGuard<
  TItem extends PresentumItem<PresentumPayload<S, V>, S, V>,
  S extends PresentumSurface,
  V extends PresentumVisualVariant
>
    extends PresentumGuard<TItem, S, V> {
  /// {@macro sync_state_with_candidates_guard}
  ISyncStateWithCandidatesGuard({super.refresh});

  @override
  FutureOr<PresentumState<TItem, S, V>> call(
    PresentumStorage storage,
    List<PresentumHistoryEntry<TItem, S, V>> history,
    PresentumState$Mutable<TItem, S, V> state,
    List<TItem> candidates,
    Map<String, Object?> context,
  ) {
    // Build a map of candidates by their unique key (id + surface + variant)
    // for fast lookup.
    final candidateMap = <String, TItem>{};
    for (final candidate in candidates) {
      final key = candidate.id;
      candidateMap[key] = candidate;
    }

    // Process each surface's slot.
    final slots = [...state.slots.entries];
    for (final surfaceEntry in slots) {
      final surface = surfaceEntry.key;
      final slot = surfaceEntry.value;

      // Collect current items in this slot (active + queue).
      final currentItems = <TItem>[?slot.active, ...slot.queue];

      if (currentItems.isEmpty) continue;

      // Build list of items that should remain, checking against candidates.
      final syncedItems = <TItem>[];
      var itemsChanged = false;

      for (final currentItem in currentItems) {
        final key = currentItem.id;
        final candidateMatch = candidateMap[key];

        if (candidateMatch == null) {
          // Item no longer in candidates - mark for removal.
          itemsChanged = true;
          continue;
        }

        // Check if content has changed using DiffUtil's content comparison.
        final contentsChanged = !areContentsTheSame(
          currentItem,
          candidateMatch,
        );

        if (contentsChanged) {
          // Replace with updated version from candidates.
          syncedItems.add(candidateMatch);
          itemsChanged = true;
        } else {
          // Item unchanged, keep as-is.
          syncedItems.add(currentItem);
        }
      }

      // If anything changed, update the slot.
      if (itemsChanged) {
        if (syncedItems.isEmpty) {
          // All items removed - clear the surface.
          state.clearSurface(surface);
        } else {
          // Update active and queue.
          final newActive = syncedItems.first;
          final newQueue = syncedItems.length > 1
              ? syncedItems.sublist(1)
              : <TItem>[];

          state.setActive(surface, newActive);
          if (newQueue.isNotEmpty) {
            state.setQueue(surface, newQueue);
          } else if (slot.queue.isNotEmpty) {
            // Clear queue if it was previously non-empty.
            state.setQueue(surface, <TItem>[]);
          }
        }
      }
    }

    return state;
  }

  bool areContentsTheSame(TItem oldItem, TItem newItem) {
    ...
  }
Full sync guard implementation ->

Guard execution order

Guards run in sequence, with each guard receiving the state mutated by previous guards:
presentum = Presentum(
  storage: storage,
  guards: [
    AppOpenedCountGuard(),             // 1. Add app context
    AppLifecycleGuard(refresh: ...),   // 2. Handle app lifecycle
    SyncStateWithCandidatesGuard(),    // 3. Sync with latest data
    CampaignSchedulingGuard(),         // 4. Apply scheduling logic
    RemoveIneligibleCandidatesGuard(), // 5. Filter out ineligible
  ],
);
Order matters! Guards early in the chain prepare data for later guards.

Refresh triggers

Guards can subscribe to external changes and re-run automatically:
class MyGuard extends PresentumGuard<Item, Surface, Variant> {
  MyGuard({required Listenable lifecycle})
      : super(refresh: lifecycle);

  @override
  FutureOr<PresentumState> call(...) async {
    // This runs when lifecycle notifies
    return state;
  }
}
When lifecycle.notifyListeners() is called, the engine re-runs all guards with current state.

State mutation methods

Inside guards, you can mutate state freely:
// Set active item for a surface
state.setActive(surface, item);

// With intention control
state.setActive(
  surface, 
  item,
  intention: PresentumStateIntention.replace,
);
See full set of short-cut commands in the Presentum state API.

Context sharing

Pass data between guards using the context parameter:
class FirstGuard extends MyGuard {
  @override
  FutureOr<PresentumState> call(
    storage, history, state, candidates, context,
  ) async {
    // Set context data for next guards
    context['userSegment'] = 'premium';
    context['abTestGroup'] = 'variant_a';
    return state;
  }
}

class SecondGuard extends MyGuard {
  @override
  FutureOr<PresentumState> call(
    storage, history, state, candidates, context,
  ) async {
    // Read context from previous guard
    final segment = context['userSegment'] as String?;

    if (segment == 'premium') {
      // Show premium campaigns
    }

    return state;
  }
}

Production guard chain

Here’s how a production app structures guards:
campaignPresentum = Presentum(
  storage: storage,
  eventHandlers: [
    PresentumStorageEventHandler(storage: storage),
  ],
  guards: [
    // 1. Add app-specific context
    AppOpenedCountGuard(
      appOpenedCount: userRepository.fetchAppOpenedCount,
    ),

    // 2. Handle app lifecycle changes
    AppLifecycleGuard<
      CampaignPresentumItem,
      CampaignSurface,
      CampaignVariant
    >(refresh: AppLifecycleRefresh()),

    // 3. Sync with latest candidate data
    SyncCampaignsStateWithCandidatesGuard(),

    // 4. Apply scheduling logic (priority, sequencing, rules)
    CampaignSchedulingGuard(eligibility: _eligibility),

    // 5. Remove items that became ineligible
    RemoveIneligibleCampaignsGuard(eligibility: _eligibility),
  ],
);
See full production initialization ->
Order matters! Early guards prepare data, middle guards apply logic, late guards clean up.

Guard patterns

Impression limiting

class ImpressionLimitGuard extends MyGuard {
  @override
  FutureOr<PresentumState> call(
    storage, history, state, candidates, context,
  ) async {
    for (final candidate in candidates) {
      if (candidate.option.maxImpressions case final max?) {
        final count = await storage.getShownCount(
          candidate.id,
          period: const Duration(days: 365),
          surface: candidate.surface,
          variant: candidate.variant,
        );

        if (count >= max) continue; // Skip
      }

      state.setActive(candidate.surface, candidate);
    }

    return state;
  }
}

Cooldown management

class CooldownGuard extends MyGuard {
  @override
  FutureOr<PresentumState> call(
    storage, history, state, candidates, context,
  ) async {
    final now = DateTime.now();

    for (final candidate in candidates) {
      if (candidate.option.cooldownMinutes case final cooldown?) {
        final lastShown = await storage.getLastShown(
          candidate.id,
          surface: candidate.surface,
          variant: candidate.variant,
        );

        if (lastShown case final last?) {
          final minutesSince = now.difference(last).inMinutes;
          if (minutesSince < cooldown) continue; // Still in cooldown
        }
      }

      state.setActive(candidate.surface, candidate);
    }

    return state;
  }
}

User targeting

class UserTargetingGuard extends MyGuard {
  UserTargetingGuard(this.userService);

  final UserService userService;

  @override
  FutureOr<PresentumState> call(
    storage, history, state, candidates, context,
  ) async {
    final userSegment = userService.currentSegment;

    for (final candidate in candidates) {
      final requiredSegments = candidate.metadata['required_segments']
          as List<String>?;

      if (requiredSegments != null &&
          !requiredSegments.contains(userSegment)) {
        continue; // User not in required segment
      }

      state.setActive(candidate.surface, candidate);
    }

    return state;
  }
}

Sequencing logic

class SequencingGuard extends MyGuard {
  @override
  FutureOr<PresentumState> call(
    storage, history, state, candidates, context,
  ) async {
    // Group by surface
    final bySurface = <Surface, List<Item>>{};
    for (final candidate in candidates) {
      bySurface.putIfAbsent(candidate.surface, () => []).add(candidate);
    }

    // For each surface, set highest priority as active, rest in queue
    for (final entry in bySurface.entries) {
      final surface = entry.key;
      final items = entry.value;

      // Sort by priority
      items.sort((a, b) => b.priority.compareTo(a.priority));

      state.setActive(surface, items.first);
      if (items.length > 1) {
        state.setQueue(surface, items.sublist(1));
      }
    }

    return state;
  }
}

Best practices

Each guard should have one responsibility. Don’t create one giant guard that does everything. Good: ImpressionLimitGuard, CooldownGuard, SegmentTargetingGuard Bad: MegaGuardThatDoesEverything
For complex conditions, use the built-in eligibility system instead of manual if/else chains.
final eligible = await eligibilityResolver.isEligible(
  candidate.payload,
  context,
);
Learn more about eligibility ->
Use the context parameter to pass data between guards instead of external state.
// In first guard
context['premiumUser'] = true;

// In later guard
if (context['premiumUser'] == true) {/* ... */}

This creates infinite loops. Guards run automatically when needed.

Common mistakes

Don’t mutate state outside guards
// ❌ Wrong
state.slots[surface] = newSlot; // Won't compile (immutable)

// βœ… Correct
await presentum.setState((state) {
  state.setActive(surface, item);
  return state;
});
Don’t fetch data in guards
// ❌ Wrong - slow, unreliable
@override
FutureOr<PresentumState> call(...) async {
  final campaigns = await api.fetchCampaigns(); // Don't do this
  // ...
}

// βœ… Correct - feed candidates from provider
class CampaignProvider {
  Future<void> fetchAndUpdate() async {
    final campaigns = await api.fetchCampaigns();
    await presentum.config.engine.setCandidates(
      (state, current) => campaigns,
    );
  }
}

Next steps