> ## Documentation Index
> Fetch the complete documentation index at: https://docs.presentum.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Guards

> Learn how guards control when and what presentations are shown

## 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.

<Tip>
  Guards are your primary tool for scheduling presentations, removing ineligible
  items, periodic refreshes, and complex eligibility rules.
</Tip>

## Basic guard

Here's a simple guard that sets the highest priority campaign as active:

```dart theme={null}
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

<ParamField path="storage" type="PresentumStorage" required>
  Persistence layer for tracking impressions, dismissals, and conversions.
</ParamField>

<ParamField path="history" type="List<PresentumHistoryEntry>" required>
  Complete history of state changes. Useful for analyzing past decisions.
</ParamField>

<ParamField path="state" type="PresentumState$Mutable" required>
  Mutable state you can modify. Changes you make here will be committed after
  all guards run.
</ParamField>

<ParamField path="candidates" type="List<TItem>" required>
  All potential presentations. These are the items guards evaluate for
  eligibility.
</ParamField>

<ParamField path="context" type="Map<String, Object?>" required>
  Shared data between guards. Pass information from one guard to the next.
</ParamField>

## Production examples

### Scheduling guard

This guard from a production app handles priority, sequencing, impression limits, and cooldowns:

```dart theme={null}
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 ->](https://github.com/itsezlife/presentum/blob/master/example/lib/src/campaigns/presentum/guards/scheduling_guard.dart)

### 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:

```dart theme={null}
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 ->](https://github.com/itsezlife/presentum/blob/master/example/lib/src/common/presentum/remove_ineligible_candidates_guard.dart)

### 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

```dart theme={null}
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 ->](https://github.com/itsezlife/presentum/blob/master/example/lib/src/common/presentum/sync_state_with_candidates_guard.dart)

## Guard execution order

Guards run **in sequence**, with each guard receiving the state mutated by previous guards:

```dart theme={null}
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
  ],
);
```

<Info>
  Order matters! Guards early in the chain prepare data for later guards.
</Info>

## Refresh triggers

Guards can subscribe to external changes and re-run automatically:

```dart theme={null}
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:

<Tabs>
  <Tab title="Set active">
    ```dart theme={null}
    // Set active item for a surface
    state.setActive(surface, item);

    // With intention control
    state.setActive(
      surface, 
      item,
      intention: PresentumStateIntention.replace,
    );
    ```
  </Tab>

  <Tab title="Queue management">
    ```dart theme={null}
    // Add to queue
    state.enqueue(surface, item);

    // Set entire queue
    state.setQueue(surface, [item1, item2, item3]);

    // Remove from queue
    state.dequeue(surface);
    ```
  </Tab>

  <Tab title="Remove items">
    ```dart theme={null}
    // Remove by predicate
    state.removeWhere((item) => item.priority < 50);

    // Remove from specific surface
    state.removeFromSurface(
      surface,
      (item) => item.id == 'expired-campaign',
    );

    // Remove by ID
    state.removeById('campaign-123');

    // Clear entire surface
    state.clearSurface(surface);
    ```
  </Tab>

  <Tab title="Cancel transition">
    ```dart theme={null}
    // Abort state change
    if (userIsOffline) {
      state.intention = PresentumStateIntention.cancel;
      return state;
    }
    ```
  </Tab>
</Tabs>

See full set of short-cut commands in the [Presentum state API](https://github.com/itsezlife/presentum/tree/master/lib/src/state/state.dart).

## Context sharing

Pass data between guards using the `context` parameter:

```dart theme={null}
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:

```dart theme={null}
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 ->](https://github.com/itsezlife/presentum/blob/master/example/lib/src/campaigns/presentum/campaigns_presentum_state_mixin.dart)

<Tip>
  **Order matters!** Early guards prepare data, middle guards apply logic, late
  guards clean up.
</Tip>

## Guard patterns

### Impression limiting

```dart theme={null}
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

```dart theme={null}
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

```dart theme={null}
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

```dart theme={null}
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

<AccordionGroup>
  <Accordion title="Keep guards focused">
    Each guard should have one responsibility. Don't create one giant guard that
    does everything. **Good:** `ImpressionLimitGuard`, `CooldownGuard`,
    `SegmentTargetingGuard` **Bad:** `MegaGuardThatDoesEverything`
  </Accordion>

  <Accordion title="Use eligibility system">
    For complex conditions, use the built-in eligibility system instead of
    manual if/else chains.

    ```dart theme={null}
    final eligible = await eligibilityResolver.isEligible(
      candidate.payload,
      context,
    );
    ```

    [Learn more about eligibility ->](/features/eligibility-system)
  </Accordion>

  <Accordion title="Share data via context">
    Use the `context` parameter to pass data between guards instead of external
    state.

    ```dart theme={null}
    // In first guard
    context['premiumUser'] = true;

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

    ```
  </Accordion>

  <Accordion title="Don't call notifyListeners in guards">
    This creates infinite loops. Guards run automatically when needed.
  </Accordion>
</AccordionGroup>

## Common mistakes

<Warning>
  **Don't mutate state outside guards**

  ```dart theme={null}
  // ❌ Wrong
  state.slots[surface] = newSlot; // Won't compile (immutable)

  // ✅ Correct
  await presentum.setState((state) {
    state.setActive(surface, item);
    return state;
  });
  ```
</Warning>

<Warning>
  **Don't fetch data in guards**

  ```dart theme={null}
  // ❌ 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,
      );
    }
  }
  ```
</Warning>

## Next steps

<CardGroup cols={2}>
  <Card title="Implementing guards" icon="shield" href="/guides/implementing-guards">
    Step-by-step guard building guide
  </Card>

  {" "}

  <Card title="Eligibility system" icon="check-circle" href="/features/eligibility-system">
    Use declarative eligibility rules
  </Card>

  {" "}

  <Card title="Storage" icon="database" href="/core-concepts/storage">
    Understand the storage interface
  </Card>

  <Card title="Production examples" icon="code" href="https://github.com/itsezlife/presentum/tree/master/example/lib/src/campaigns/presentum/guards">
    See real-world guards
  </Card>
</CardGroup>
