A deterministic rule engine with full execution traceability.
Events are evaluated against composable, AST-based rules and produce a complete audit trail — every condition's result, the actual vs. expected values, and the full evaluation path, in order.
An Event carries an arbitrary JSON payload and a timestamp. Rules define composable condition trees (simple comparisons or nested AND/OR logic) and an outcome. When a decision is requested, the engine sorts rules by priority, evaluates each condition tree with short-circuit logic, and returns a Decision containing a DecisionTrace — a recursive structure mirroring the condition AST that captures the full evaluation path.
flowchart LR
subgraph Domain
Decision("Decision")
Event("Event")
Rule("Rule")
end
Client("HTTP Request") --> API("FastAPI Router")
API --> UseCase("UseCase")
API --> Container("Container")
UseCase --> UoWFactory("UoWFactory")
Container --> MemDB("MemDB")
Container --> SQLAlchemyDB("SQLAlchemyDB")
Container --> UseCase
UoWFactory --> UnitOfWorkMem("MemUoW")
UoWFactory --> UnitOfWorkSQL("SQLAlchemyUoW")
UnitOfWorkMem --> RepositoriesMem("Repositories (Memory)")
UnitOfWorkMem --> MemStorage("MemStorage")
RepositoriesMem --> MemStorage
RepositoriesMem --> Decision
RepositoriesMem --> Event
RepositoriesMem --> Rule
UnitOfWorkSQL --> RepositoriesSQL("Repositories (SQLAlchemy)")
UnitOfWorkSQL --> SessionFactory("SessionFactory")
RepositoriesSQL --> PostgreSQL("PostgreSQL")
RepositoriesSQL --> Decision
RepositoriesSQL --> Event
RepositoriesSQL --> Rule
SQLAlchemyDB --> UoWFactory
SQLAlchemyDB --> Engine("Engine")
SQLAlchemyDB --> SessionFactory
SessionFactory --> Session("Session")
Session --> Engine
Engine --> PostgreSQL
MemDB --> UoWFactory
MemDB --> MemStorage
Alembic("Alembic") --> PostgreSQL
Docker("Docker") --> PostgreSQL
- Runtime: Python 3.12, FastAPI, Uvicorn
- Persistence: PostgreSQL, SQLAlchemy, Alembic, Docker
- Observability: structlog, correlation IDs (ContextVar +
X-Correlation-IDheader) - Tooling: uv, mise, Ruff, basedpyright (strict), Commitizen
- Testing: Pytest, GitHub Actions CI
Every HTTP request is assigned a correlation_id — generated by middleware, propagated through the call stack via a
ContextVar, and returned to the client as an X-Correlation-ID response header. All log events are emitted as
structured JSON (or colorized text when attached to a terminal) via structlog,
with correlation_id, timestamp, and log level injected automatically by the processor chain.
| Layer | Event | Key fields |
|---|---|---|
| HTTP | http.request |
method, path |
| HTTP | http.response |
method, path, status_code |
| Use case | event.registered |
event_id, event_type |
| Use case | events.listed |
count |
| Use case | rule.registered |
rule_id, name, outcome |
| Use case | rules.listed |
count |
| Use case | decision.produced |
event_id, rule_id, outcome |
| Use case | decisions.listed |
count |
All events within a single request share the same correlation_id, making it possible to trace a full
request end-to-end from a single log query.
Rules are not flat field/operator/value tuples — they are recursive Abstract Syntax Trees. SimpleCondition evaluates a
single field against a value using a ComparisonOperator. CompositeCondition combines multiple conditions with
AND / OR and short-circuits via a generator, halting evaluation the moment the result is determined. Both types
implement a Visitor pattern that decouples JSON serialization from the domain — conditions serialize to JSONB and
round-trip through the database without loss of structure. DecisionTrace mirrors this AST exactly, capturing
actual vs. expected values at every leaf.
FastAPI's Depends() was removed in favor of an explicit bootstrap.py that builds all dependencies eagerly at
startup. Use cases are plain Python — no HTTP context, no framework coupling — callable from tests or a CLI with zero
server setup. Tests inject a ContainerOverride struct rather than patching app.dependency_overrides, so the test
setup is isolated and the application's wiring remains untouched.
Every integration test runs against both the in-memory backend and PostgreSQL in the same test run. Isolation uses transaction rollback — no table truncation or schema resets between tests. Any behavioral divergence between backends (codec bugs, constraint ordering, query semantics) fails during development, not in production. CI provisions a containerized PostgreSQL on every push and runs the full parameterized suite.
The architectural decisions behind these choices are documented in docs/adr/.
docker compose up -d --no-recreate && uv run decision-engine wait-db
uv run pytest156 tests across unit, integration, and E2E. The same test body is parameterized over both persistence backends
(the test database URL is injected by the test harness, so no .env is needed to run them). Schema lifecycle is
managed by Alembic (upgrade head at session start, downgrade base on teardown).
No database, no Docker — the in-memory backend runs the full API in-process (data is ephemeral):
git clone https://github.com/geldois/decision-engine.git && cd decision-engine
uv sync
PERSISTENCE=mem uv run decision-engine devThe API starts at http://localhost:8000. This is also the deploy shape for a zero-infra demo host.
For the PostgreSQL backend, requires uv and Docker.
git clone https://github.com/geldois/decision-engine.git && cd decision-engine
uv sync
cp .env.example .env
docker compose up -d postgres && uv run decision-engine wait-db
uv run pytest
uv run decision-engine devgit clone https://github.com/geldois/decision-engine.git
cd decision-engine
uv sync
copy .env.example .env
docker compose up -d postgres
uv run decision-engine wait-db
uv run pytest
uv run decision-engine devcurl -X POST http://localhost:8000/events/ \
-H "Content-Type: application/json" \
-d '{"event_type": "PURCHASE", "payload": {"amount": 1500, "country": "BR"}, "occurred_at": 1000000000}'curl http://localhost:8000/events/curl -X POST http://localhost:8000/rules/ \
-H "Content-Type: application/json" \
-d '{
"name": "HIGH_VALUE_PURCHASE",
"condition": {
"type": "composite",
"operator": "and",
"conditions": [
{"type": "simple", "field": "event_type", "operator": "==", "value": "PURCHASE"},
{
"type": "composite",
"operator": "or",
"conditions": [
{"type": "simple", "field": "occurred_at", "operator": ">", "value": 999999999},
{"type": "simple", "field": "payload", "operator": "==", "value": {"country": "US"}}
]
}
]
},
"outcome": "rejected",
"priority": 10
}'curl http://localhost:8000/rules/curl -X POST http://localhost:8000/decisions/ \
-H "Content-Type: application/json" \
-d '{"event_id": "<event-id>"}'curl http://localhost:8000/decisions/