Trigger sources: normalizing external events into tasks

September 1, 2026 · View on GitHub

core/trigger_sources/ is the package of adapter modules that turn a raw external event — a GitHub webhook, a Slack message, an OData row change, a schedule fire — into a common TriggerEvent shape. Everything downstream (rule matching, task creation, audit anchoring) works against that one normalized shape instead of against N different payload formats.

@dataclass
class TriggerEvent:
    source: str  # "github", "slack", "schedule", ...
    timestamp: float
    raw_payload: dict[str, Any]
    repo: str = ""
    branch: str = ""
    sha: str = ""
    sender: str = ""
    changed_files: tuple[str, ...] = ()
    message: str = ""
    metadata: dict[str, Any] = field(default_factory=dict)

(src/bernstein/core/tasks/models.py)

Two independent paths from event to task

Bernstein does not route every event through one central dispatcher. Two separate mechanisms consume TriggerEvents, and which one applies depends on the source:

1. Direct task creation — a handful of production HTTP routes normalize the incoming payload and create a task immediately, bypassing the generic trigger-rule pipeline entirely:

SourceRouteNormalizer used
Slack Events APIPOST /webhooks/slack/eventstrigger_sources/slack.py (normalize_slack_message, verify_slack_signature) — a message that @-mentions the bot becomes a task directly.
SLA breachinternal (schedule_supervisor.py tick)trigger_sources/sla.py (normalize_sla_violation) — feeds the schedule supervisor's own trigger sink.
Schedule fireinternal (schedule_supervisor.py tick)trigger_sources/schedule.py (normalize_schedule_fire) — see Recurring schedules.
OData row changepoll looptrigger_sources/odata_poll.py — see OData integration.

2. The generic TriggerManager pipeline — operator-authored rules in .sdd/config/triggers.yaml match against TriggerEvent.source (plus filters/conditions) and produce a task from a configurable template. This is what bernstein triggers list/history/fire inspects and drives:

# .sdd/config/triggers.yaml
defaults:
  max_tasks_per_minute: 5     # global rate limit across all triggers

triggers:
  - name: ci-failure-fix
    source: github_push
    enabled: true
    filters:
      branch: main
    conditions:
      cooldown_s: 300          # suppress refires within 5 minutes
    task:
      title: "Fix CI failure"
      role: backend
      priority: 1
      description_template: "Investigate: {message}"

TriggerManager (core/orchestration/trigger_manager.py) loads this file, matches an incoming TriggerEvent against each enabled rule's source and filters (glob matching on branch/file patterns), enforces the global defaults.max_tasks_per_minute rate limit, per-trigger conditions (cooldown_s, max_retries, dedup, and more), and a default excluded-sender list (bernstein[bot], github-actions[bot]), then returns task payloads for the caller to submit.

CLI

bernstein triggers list              # configured triggers + last-fired status
bernstein triggers history [-n N]    # recent fire log (default 20 entries)
bernstein triggers fire NAME         # synthesize a test event and dry-run it

bernstein triggers fire builds a synthetic TriggerEvent for the named rule's source, runs it through TriggerManager.evaluate(), shows the task(s) that would be created, and asks for confirmation before actually posting them to the task server.

(src/bernstein/cli/commands/triggers_cmd.py)

Adapter inventory and wiring status

The Notes on this feature name eight adapters. Not all of them sit on a live event path in this codebase today — this table is the ground truth, not the aspiration:

AdapterModuleNormalizesWired into a live path?
Slacktrigger_sources/slack.pyEvents API message payloadsYesPOST /webhooks/slack/events calls normalize_slack_message directly and creates a task.
Scheduletrigger_sources/schedule.pyAn in-project schedule fireYes — via schedule_supervisor.py. See Recurring schedules.
SLAtrigger_sources/sla.pyA signed SLA-violation receiptYes — via schedule_supervisor.py's SLA monitor.
ODatatrigger_sources/odata_poll.pyA polled system-of-record row changeYes, own poll loop. See OData integration.
Generic webhooktrigger_sources/receipt.py (automation_platforms.py)Inbound payloads from an external automation platformYesPOST /webhook. See Automation bridge.
Discordtrigger_sources/discord.pySlash-command interactionsPartialPOST /webhooks/discord/interactions uses verify_discord_signature from this module for request verification; command handling builds its response directly rather than through normalize_discord_interaction, which is unused in production.
Generic HTTPtrigger_sources/webhook.py (normalize_webhook)Arbitrary path/method/headers/payloadNo — not called from any route in this codebase; the live generic-webhook endpoint (POST /webhook) creates a task from a task-shaped payload instead of normalizing through this function.
File watchtrigger_sources/file_watch.py (FileWatchSource)Debounced filesystem change events (via watchdog)No — the class is defined but never instantiated outside its own module; no orchestrator loop drains its queue. The unrelated bernstein watch CLI command does not use it either.

Limitations

Some of the named adapters (generic HTTP webhook, file watch) are normalization functions with no production caller in this codebase — they are usable if you wire them into a custom route or a TriggerManager rule yourself, but out of the box nothing invokes them. Treat the "wired" column above as authoritative over the module list; it will drift as routes change, so re-check the call sites (grep -rn "from bernstein.core.trigger_sources") before depending on one of the "No" rows.

Source

src/bernstein/core/trigger_sources/ (adapters); src/bernstein/core/tasks/models.py (TriggerEvent, TriggerConfig, TriggerTaskTemplate); src/bernstein/core/orchestration/trigger_manager.py (TriggerManager, .sdd/config/triggers.yaml loader); src/bernstein/cli/commands/triggers_cmd.py (bernstein triggers CLI).