# Rule engine

> The deterministic two-phase pipeline that converts events into effects.

Source: https://docs.hatched.live/docs/concepts/rule-engine

The rule engine is the heart of Hatched. Every event you send goes through
the same pipeline; every effect the buddy accumulates is the output of that
pipeline.

## The two-phase contract

1. **Compute phase** — read-only. Given the current buddy state and the
   incoming event, the engine computes what *would* change: coin
   increments, skill increments, badges newly eligible, token deltas,
   streak ticks.
2. **Apply phase** — transactional. Opens a single database transaction,
   takes a `pessimistic_write` lock on the buddy row, writes every computed
   effect, commits atomically. If any step throws, everything rolls back.
3. **Post-transaction** — non-atomic side effects: progression counters,
   evolution readiness check, webhook emission.

## Why this split

Two properties fall out of the contract:

- **Idempotency** — the same `event_id` produces exactly one effect even if
  retried.
- **Race-freedom** — concurrent events for the same buddy serialise on the
  row lock, so two overlapping lessons don't both award the same badge.

## Two ingestion paths — by design

- `POST /events` — standard ingestion. The rule engine owns the decision.
- `POST /buddies/{buddy_id}/coins`, `POST /buddies/{buddy_id}/coins/spend`,
  `POST /buddies/{buddy_id}/tokens`, `PATCH /buddies/{id}/skills`,
  `POST /buddies/{buddy_id}/badges` (badge to award is `badge_key` in the
  body) — administrative override. Every write still funnels through the same
  transactional services, so ledger invariants are preserved.

This is intentional. Don't try to collapse them into a single endpoint.

## Unknown events

Events not declared on the customer's [config version](/docs/concepts/config-versions):

- Every event type — declared or not — is recorded in
  `buddy_progression_metrics.custom_counters` via a `jsonb_set` update,
  keyed by the event's own name. There are no reserved event names and no
  hardcoded counter columns. Downstream handlers still evaluate, and an
  undeclared type also emits a warning log. **Never dropped.**

## Observability

Every event carries a `requestId` that follows it through the rule engine,
into effect ledger entries, and out into webhooks. Keep the `requestId`
from API responses and webhook payloads when contacting support; Hatched
uses that value to trace a specific event through retries, ledgers, and
webhook delivery without asking you to expose secret keys or raw database
state.

## Related

- [Config versions](/docs/concepts/config-versions) — the immutable rulebook the engine loads per buddy.
- [Coins](/docs/concepts/coins), [Skills](/docs/concepts/skills), [Badges](/docs/concepts/badges) — the effects the engine produces.
- [Send events](/docs/guides/send-events) — what to send, with stable `eventId`s.
