Concepts
Rule engine
The deterministic two-phase pipeline that converts events into effects.
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
- 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.
- Apply phase — transactional. Opens a single database transaction,
takes a
pessimistic_writelock on the buddy row, writes every computed effect, commits atomically. If any step throws, everything rolls back. - 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_idproduces 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 /coins,POST /skills,POST /badges/:id/award,POST /tokens— 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:
- Type in
EVENT_COUNTER_MAP→ increments the canonical column. - Type not in the map → atomically upserts into
progression_metrics.custom_countersvia ajsonb_setupdate. Downstream handlers still evaluate. - Type neither in the map nor declared → same as above, plus 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. See
Observability for the incident triage
workflow.