Hogsend

Conditions

Composable condition engine for enrollment guards, exit rules, and journey branching.

Overview

Hogsend ships a composable condition engine in @hogsend/core that powers three critical systems:

  1. Trigger guards (trigger.where) — filter which events actually enroll a user into a journey
  2. Exit conditions (exitOn[].where) — determine when an active journey should terminate early
  3. In-journey event checks (ctx.history.hasEvent()) — query event history mid-journey for branching logic

The engine supports four condition types that can be nested arbitrarily using composite and/or logic.

Condition Types

Every condition is a discriminated union keyed on the type field:

type ConditionEval =
  | PropertyCondition
  | EventCondition
  | EmailEngagementCondition
  | CompositeCondition;

Property Conditions

Check a value from the event payload or a PostHog person property against an operator.

interface PropertyCondition {
  type: "property";
  source: "posthog" | "context";
  property: string;
  operator:
    | "eq"
    | "neq"
    | "gt"
    | "gte"
    | "lt"
    | "lte"
    | "exists"
    | "not_exists"
    | "contains";
  value?: string | number | boolean;
}

Sources:

  • "context" — reads from the event properties / journey context object passed at enrollment time
  • "posthog" — reads from PostHog person properties (fetched via the PostHog plugin with Redis caching)

Example: Only enroll enterprise plan users

const trigger = {
  event: "user:signed_up",
  where: [
    {
      type: "property",
      source: "context",
      property: "plan",
      operator: "eq",
      value: "enterprise",
    },
  ],
};

Example: Check that a numeric property exceeds a threshold

{
  type: "property",
  source: "context",
  property: "login_count",
  operator: "gte",
  value: 10,
}

Event Conditions

Query whether a user has (or hasn't) performed an event, optionally within a time window or with a count threshold.

interface EventCondition {
  type: "event";
  eventName: string;
  check: "exists" | "not_exists" | "count";
  operator?: "gt" | "gte" | "lt" | "lte" | "eq";
  value?: number;
  within?: DurationObject;
}

The within field accepts a DurationObject — the same format used by days(), hours(), and minutes() helpers:

interface DurationObject {
  readonly hours?: number;
  readonly minutes?: number;
  readonly seconds?: number;
}

Example: User has never completed onboarding

{
  type: "event",
  eventName: "onboarding:completed",
  check: "not_exists",
}

Example: User triggered fewer than 3 purchases in the last 7 days

{
  type: "event",
  eventName: "purchase:completed",
  check: "count",
  operator: "lt",
  value: 3,
  within: { hours: 168 }, // 7 days
}

When check is "count", both operator and value must be provided. If either is missing, the condition falls back to a simple existence check (count > 0).

Email Engagement Conditions

Check whether a user has opened or clicked a specific email template. Looks up the most recent emailSends record for the given template.

interface EmailEngagementCondition {
  type: "email_engagement";
  templateKey: string;
  check: "opened" | "clicked" | "not_opened" | "not_clicked";
}

Example: User didn't open the welcome email

{
  type: "email_engagement",
  templateKey: "welcome",
  check: "not_opened",
}

Example: User clicked through the upgrade prompt

{
  type: "email_engagement",
  templateKey: "trial-expiring",
  check: "clicked",
}

If no matching emailSends record exists for the user + template pair, the condition returns false for all check types.

Composite Conditions

Combine any number of conditions with and or or logic. Composite conditions can nest other composites for arbitrary depth.

interface CompositeCondition {
  type: "composite";
  operator: "and" | "or";
  conditions: ConditionEval[];
}

Evaluation behavior:

  • "and" — short-circuit: returns false on the first failing sub-condition
  • "or" — short-circuit: returns true on the first passing sub-condition

Example: Enterprise user who hasn't completed setup

{
  type: "composite",
  operator: "and",
  conditions: [
    {
      type: "property",
      source: "context",
      property: "plan",
      operator: "eq",
      value: "enterprise",
    },
    {
      type: "event",
      eventName: "setup:completed",
      check: "not_exists",
    },
  ],
}

Example: User opened the email OR clicked any link

{
  type: "composite",
  operator: "or",
  conditions: [
    {
      type: "email_engagement",
      templateKey: "onboarding-guide",
      check: "opened",
    },
    {
      type: "email_engagement",
      templateKey: "onboarding-guide",
      check: "clicked",
    },
  ],
}

The evaluateCondition() Function

The central evaluation function lives in @hogsend/core and handles all four condition types via a type-discriminated switch:

import { evaluateCondition } from "@hogsend/core";

const result = await evaluateCondition({
  condition: {
    type: "property",
    source: "context",
    property: "plan",
    operator: "eq",
    value: "pro",
  },
  ctx: {
    db,                 // Drizzle database instance
    userId: "user_123",
    journeyContext: { plan: "pro", region: "us-east" },
  },
});
// result: true

The ConditionContext provides the data needed for evaluation:

interface ConditionContext {
  db: Database;                           // Drizzle ORM instance
  userId: string;                         // User being evaluated
  journeyContext: Record<string, unknown>; // Event properties / context data
}
  • Property conditions read from journeyContext (for source: "context") or PostHog (for source: "posthog")
  • Event conditions query the userEvents table filtered by user, event name, and optional time window
  • Email engagement conditions look up the latest emailSends record for the user + template
  • Composite conditions recursively call evaluateCondition() for each sub-condition

Zod Schemas

Every condition type has a corresponding Zod schema for runtime validation, useful when accepting conditions from API payloads or configuration:

import {
  propertyConditionSchema,
  eventConditionSchema,
  emailEngagementConditionSchema,
  conditionEvalSchema,       // discriminated union of all types (recursive via z.lazy)
} from "@hogsend/core";

The conditionEvalSchema uses z.lazy() and z.discriminatedUnion() to support recursive composite nesting:

const conditionEvalSchema = z.lazy(() =>
  z.discriminatedUnion("type", [
    propertyConditionSchema,
    eventConditionSchema,
    emailEngagementConditionSchema,
    z.object({
      type: z.literal("composite"),
      operator: z.enum(["and", "or"]),
      conditions: z.array(conditionEvalSchema),
    }),
  ]),
);

Operator Reference

Property Operators

OperatorDescriptionValue RequiredTypes
eqStrict equality (===)Yesstring, number, boolean
neqStrict inequality (!==)Yesstring, number, boolean
gtGreater thanYesnumber
gteGreater than or equalYesnumber
ltLess thanYesnumber
lteLess than or equalYesnumber
existsValue is not null or undefinedNoAny
not_existsValue is null or undefinedNoAny
containsSubstring match (includes())Yesstring

Event Check Modes

CheckDescriptionExtra Fields
existsAt least one matching event foundwithin?
not_existsNo matching events foundwithin?
countCompare event count against a thresholdoperator, value, within?

Email Engagement Checks

CheckDescription
openedMost recent send has openedAt set
clickedMost recent send has clickedAt set
not_openedMost recent send has no openedAt
not_clickedMost recent send has no clickedAt

Where Conditions Are Used

1. Trigger Guards (trigger.where)

Filter which events enroll users into a journey. All conditions in the where array must pass (implicit AND). These are PropertyCondition[] — only property checks are supported at the trigger level.

const journey = defineJourney({
  meta: {
    id: "enterprise-onboarding",
    name: "Enterprise Onboarding",
    enabled: true,
    trigger: {
      event: "user:signed_up",
      where: [
        {
          type: "property",
          source: "context",
          property: "plan",
          operator: "eq",
          value: "enterprise",
        },
        {
          type: "property",
          source: "context",
          property: "company_size",
          operator: "gte",
          value: 50,
        },
      ],
    },
    entryLimit: "once",
    suppress: hours(12),
  },
  run: async (user, ctx) => {
    // Only enterprise users with 50+ employees reach here
  },
});

Trigger conditions are evaluated synchronously against event properties using evaluateTriggerConditions() before any database state is created. If any condition fails, the journey returns { status: "skipped", reason: "trigger_conditions_not_met" }.

2. Exit Conditions (exitOn)

Automatically terminate active journeys when specific events occur. Each exit rule has an event name and an optional where array of property conditions.

exitOn: [
  // Exit unconditionally when user is deleted
  { event: "user:deleted" },

  // Exit only if the payment succeeded for > $100
  {
    event: "payment:succeeded",
    where: [
      {
        type: "property",
        source: "context",
        property: "amount",
        operator: "gt",
        value: 100,
      },
    ],
  },
],

Exit conditions are checked by the ingestion pipeline (ingestEvent()) every time an event is stored. The pipeline queries all active/waiting journey states for the user, then evaluates each journey's exitOn rules against the incoming event. When a match is found, the journey state is set to "exited".

3. In-Journey Event Checks (ctx.history.hasEvent)

Query event history mid-journey to branch logic. This uses evaluateEventCondition() from the condition engine under the hood.

run: async (user, ctx) => {
  await ctx.sleep({ duration: days(2), label: "initial-nudge" });

  // Check if the user used a feature in the last 2 days
  const { found: hasUsedFeature } = await ctx.history.hasEvent({
    userId: user.id,
    event: "feature:first_use",
    within: days(2),
  });

  if (!hasUsedFeature) {
    await sendEmail({
      to: user.email,
      userId: user.id,
      template: "activation-nudge",
      subject: "You haven't tried the key feature yet",
      journeyName: user.journeyName,
    });
  }
},

The hasEvent method returns both a boolean found flag and the raw count, so you can use either for your branching logic:

const { found, count } = await ctx.history.hasEvent({
  userId: user.id,
  event: "purchase:completed",
  within: days(30),
});

if (count >= 5) {
  // Power user path
} else if (found) {
  // Has purchased, but not frequently
} else {
  // Never purchased
}

Evaluation Order in Journey Enrollment

When an event triggers a journey, conditions are checked in this order. The journey short-circuits at the first failure:

  1. meta.enabled — is the journey enabled?
  2. trigger.where — do event properties pass all trigger conditions?
  3. checkEntryLimit() — has the user exceeded the entry limit (once / once_per_period / unlimited)?
  4. checkEmailPreferences() — is the user unsubscribed?
  5. Active state check — is the user already active in this journey?

Only after all guards pass does the journey create state and begin execution.

On this page