event_engine 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +210 -399
  4. data/Rakefile +0 -1
  5. data/lib/event_engine/catalog_entry.rb +84 -0
  6. data/lib/event_engine/configuration.rb +9 -9
  7. data/lib/event_engine/definition_publisher.rb +22 -0
  8. data/lib/event_engine/event.rb +0 -8
  9. data/lib/event_engine/event_builder.rb +0 -8
  10. data/lib/event_engine/event_schema.rb +32 -57
  11. data/lib/event_engine/event_schema_json_loader.rb +19 -0
  12. data/lib/event_engine/handler_registry.rb +4 -4
  13. data/lib/event_engine/invalid_rules_error.rb +8 -0
  14. data/lib/event_engine/processing_rules.rb +39 -0
  15. data/lib/event_engine/processor_registry.rb +19 -0
  16. data/lib/event_engine/processor_resolver.rb +16 -0
  17. data/lib/event_engine/railtie.rb +54 -4
  18. data/lib/event_engine/rules_file.rb +14 -0
  19. data/lib/event_engine/schema_catalog_builder.rb +18 -0
  20. data/lib/event_engine/schema_registry.rb +7 -58
  21. data/lib/event_engine/unregistered_processor_error.rb +8 -0
  22. data/lib/event_engine/unroutable_event_error.rb +9 -0
  23. data/lib/event_engine/unrouted_events_error.rb +9 -0
  24. data/lib/event_engine/version.rb +1 -1
  25. data/lib/event_engine.rb +117 -123
  26. data/lib/tasks/event_engine_catalog.rake +15 -6
  27. data/lib/tasks/event_engine_rules.rake +10 -0
  28. data/the_local/agents/event_engine-develop.md +154 -0
  29. data/the_local/agents/event_engine-info.md +82 -0
  30. data/the_local/agents/event_engine-install.md +99 -0
  31. data/the_local/interface.yml +27 -0
  32. metadata +45 -48
  33. data/app/assets/config/event_engine_manifest.js +0 -1
  34. data/app/assets/stylesheets/event_engine/application.css +0 -15
  35. data/app/controllers/event_engine/application_controller.rb +0 -4
  36. data/app/helpers/event_engine/application_helper.rb +0 -4
  37. data/app/jobs/event_engine/application_job.rb +0 -4
  38. data/app/mailers/event_engine/application_mailer.rb +0 -6
  39. data/app/models/event_engine/application_record.rb +0 -5
  40. data/app/views/layouts/event_engine/application.html.erb +0 -15
  41. data/config/routes.rb +0 -2
  42. data/lib/event_engine/definition_loader.rb +0 -26
  43. data/lib/event_engine/dsl_compiler.rb +0 -50
  44. data/lib/event_engine/engine.rb +0 -56
  45. data/lib/event_engine/event_definition/inputs.rb +0 -43
  46. data/lib/event_engine/event_definition/payloads.rb +0 -47
  47. data/lib/event_engine/event_definition/schemas.rb +0 -158
  48. data/lib/event_engine/event_definition/validation.rb +0 -18
  49. data/lib/event_engine/event_definition.rb +0 -76
  50. data/lib/event_engine/event_schema_dumper.rb +0 -13
  51. data/lib/event_engine/event_schema_loader.rb +0 -37
  52. data/lib/event_engine/event_schema_merger.rb +0 -62
  53. data/lib/event_engine/event_schema_writer.rb +0 -47
  54. data/lib/event_engine/lifecycle_definition.rb +0 -86
  55. data/lib/event_engine/process_type.rb +0 -26
  56. data/lib/event_engine/reference/guide.md +0 -129
  57. data/lib/event_engine/reference.rb +0 -16
  58. data/lib/event_engine/schema_catalog.rb +0 -50
  59. data/lib/event_engine/schema_compatibility.rb +0 -50
  60. data/lib/event_engine/schema_diff.rb +0 -35
  61. data/lib/event_engine/schema_drift_guard.rb +0 -38
  62. data/lib/event_engine/subject_registry.rb +0 -40
  63. data/lib/event_engine/the_local/agents/event_engine-develop.md +0 -142
  64. data/lib/event_engine/the_local/agents/event_engine-info.md +0 -140
  65. data/lib/event_engine/the_local/agents/event_engine-install.md +0 -140
  66. data/lib/event_engine/the_local.rb +0 -55
  67. data/lib/generators/event_engine/install_generator.rb +0 -31
  68. data/lib/generators/event_engine/templates/event_schema.rb +0 -10
  69. data/lib/generators/event_engine/templates/initializer.rb +0 -4
  70. data/lib/tasks/event_engine_schema.rake +0 -82
  71. data/lib/tasks/event_engine_schema_check.rake +0 -20
  72. data/lib/tasks/event_engine_tasks.rake +0 -4
data/README.md CHANGED
@@ -1,517 +1,328 @@
1
1
  # EventEngine
2
2
 
3
- EventEngine is the schema-first **core** of an event pipeline for Rails:
3
+ The **Rails host runtime** of the [EventEngine](https://github.com/DYB-Development) pipeline.
4
4
 
5
- - **Define** events with a small Ruby DSL.
6
- - **Compile** them into a canonical, committed schema file (`db/event_schema.rb`).
7
- - **Emit** them through generated helpers (`EventEngine.cow_fed(...)`) that build a
8
- validated `Event` and **dispatch** it to registered handlers by *level*.
5
+ EventEngine is a schema-first event pipeline. Domain events are **declared** and
6
+ **compiled** into a committed contract by domain-pack gems (built on
7
+ [`event_engine-event_definition`](https://github.com/DYB-Development/event_engine-event_definition)).
8
+ This gem is the **runtime** a Rails app installs: it holds the schema registry,
9
+ **builds** a validated `EventEngine::Event` from the inputs a pack hands it, and
10
+ hands it to the **processor** its rules name.
9
11
 
10
- The core gem does **not** deliver events anywhere on its own. It builds the event
11
- and hands it to whatever handlers are registered. Durable delivery and durable
12
- storage live in companion gems that register themselves as handlers:
12
+ This gem does **not** author events (that is `event_engine-event_definition` + your
13
+ domain packs) and does **not** deliver them (that is the processor gems below). It is
14
+ the middle of the pipe: inputs in, built event out, routed to a processor.
13
15
 
14
- | Gem | Responsibility | Add it when |
15
- |---|---|---|
16
- | **`event_engine`** (this gem) | Define, compile, emit, dispatch | Always — it's the core |
17
- | [`event_engine-delivery`](https://github.com/DYB-Development/event_engine-delivery) | Transactional outbox, retries, dead-letters, transports (Kafka), dashboard, cloud reporter | You need to deliver events reliably (in-process or to a broker) |
18
- | [`event_engine-store`](https://github.com/DYB-Development/event_engine-store) | Durable, append-only event log + event-sourcing replay & projections | You need a permanent record of every event / event sourcing |
16
+ > **Status.** The authoring layer is fully extracted out of this gem, and the
17
+ > passive wiring is in place: the publisher adapter registers itself at boot and
18
+ > packs are discovered without per-pack host config. A host registers its
19
+ > processors and declares its [rules file](#declaring-how-events-are-processed)
20
+ > nothing else.
19
21
 
20
- You can run the core gem **by itself** with your own handlers — see
21
- [The handler extension point](#the-handler-extension-point).
22
+ ---
22
23
 
23
- > This README documents the core gem **only**. Outbox, transports, dead-letters,
24
- > the dashboard, and the cloud reporter are documented in `event_engine-delivery`.
25
- > The event log, replay, and projections are documented in `event_engine-store`.
24
+ ## Where this gem sits
26
25
 
27
- ---
26
+ | Gem | Responsibility | Add it when |
27
+ |---|---|---|
28
+ | [`event_engine-event_definition`](https://github.com/DYB-Development/event_engine-event_definition) | The DSL, schema value objects, and the build step that turns definitions into a committed helper file + `schema.json`. No Rails. | A gem or app **declares** events |
29
+ | **`event_engine`** (this gem) | Catalog, build a validated event, route it to the processor its rules name | Always — it's the host runtime |
30
+ | `event_engine-delivery` | Transactional outbox, retries, dead-letters, transports (Kafka), dashboard | You need durable/broker delivery |
31
+ | `event_engine-store` | Durable append-only event log + replay & projections | You need a permanent record / event sourcing |
32
+ | `event_engine-subscribers`, `-telemetry`, `-sourced` | Processor gems for inline/background subscribers, telemetry, and event sourcing | You want that processing style |
28
33
 
29
- ## Table of contents
30
-
31
- - [Quick start](#quick-start)
32
- - [Mental model](#mental-model)
33
- - [Defining events](#defining-events)
34
- - [The DSL reference](#the-dsl-reference)
35
- - [How payload fields are extracted](#how-payload-fields-are-extracted)
36
- - [There is no `type:` casting](#there-is-no-type-casting)
37
- - [Lifecycle event families](#lifecycle-event-families)
38
- - [Generating the schema](#generating-the-schema)
39
- - [How versioning works](#how-versioning-works)
40
- - [Drift checking in CI](#drift-checking-in-ci)
41
- - [Emitting events](#emitting-events)
42
- - [Subscribers](#subscribers)
43
- - [Event levels](#event-levels)
44
- - [The handler extension point](#the-handler-extension-point)
45
- - [Configuration](#configuration)
46
- - [Rake tasks](#rake-tasks)
47
- - [Installation generator](#installation-generator)
48
- - [For AI assistants](#for-ai-assistants)
49
- - [Contributing](#contributing)
50
- - [License](#license)
34
+ Domain packs (e.g. `event_engine-marketing_events`, `-sales_events`, `-user_events`)
35
+ declare events against `event_engine-event_definition` and each ships its own
36
+ committed `schema.json` and helper module.
51
37
 
52
38
  ---
53
39
 
54
- ## Quick start
40
+ ## How it fits together
55
41
 
56
- ```ruby
57
- # Gemfile
58
- gem "event_engine"
59
- ```
42
+ A pack's generated helper does **not** emit. It forwards the raw inputs through a
43
+ **publisher port**; this gem is what turns those inputs into a real event and
44
+ routes it.
60
45
 
61
- ```bash
62
- bundle install
46
+ ```
47
+ pack helper (event_engine-event_definition)
48
+ MarketingEvents.lead_created(lead: lead, …envelope)
49
+ │ calls the publisher PORT (not emit):
50
+
51
+ EventEngine::Definition.publisher.publish(:lead_created, domain: :marketing,
52
+ inputs: { lead: lead }, …envelope)
53
+
54
+ EventEngine::DefinitionPublisher ◄── registered at boot, no host wiring
55
+ │ EventEngine.emit(:lead_created, inputs: { lead: lead }, domain: :marketing, …)
56
+
57
+ EventBuilder builds the payload from each field's from:/attr: ◄── catalog entry
58
+ │ the rules file decides which processor handles it
59
+
60
+ the processor named by the rule
61
+ (event_engine-delivery / -store / -subscribers / your own)
63
62
  ```
64
63
 
65
- 1. **Define an event** in `app/event_definitions/cow_fed.rb` (see [Defining events](#defining-events)).
66
- 2. **Dump the schema**:
67
- ```bash
68
- bin/rails event_engine:schema:dump
69
- ```
70
- 3. **Commit `db/event_schema.rb`** — it is authoritative at runtime.
71
- 4. **Register at least one handler** so emitted events do something. Either add a
72
- companion gem (`event_engine-delivery` / `event_engine-store`) or write your own
73
- (see [The handler extension point](#the-handler-extension-point)).
74
- 5. **Emit** from your app code:
75
- ```ruby
76
- EventEngine.cow_fed(cow: cow)
77
- ```
78
-
79
- > With **no** handler registered, the core gem builds and dispatches the event but
80
- > nothing observes it. That's expected — core is the dispatch layer; handlers are
81
- > what *do* something with events.
82
-
83
- ---
64
+ **`EventEngine.emit` is the official emit, and it lives here** not in the pack. The
65
+ pack passes *inputs* (the whole objects you already have); this gem reads them via
66
+ the catalog entry's `from:`/`attr:` mapping to build the flat `payload`, stamps the
67
+ envelope, and routes it.
84
68
 
85
- ## Mental model
69
+ ### The passive wiring
86
70
 
87
- ```
88
- EventDefinition (Ruby DSL)
89
- │ bin/rails event_engine:schema:dump
90
-
91
- db/event_schema.rb ◄── authoritative at runtime; commit it
92
- │ Rails boot (Engine initializer)
93
-
94
- SchemaRegistry ──► installs EventEngine.<event_name> helpers
95
- │ you call EventEngine.cow_fed(cow: cow)
96
-
97
- EventBuilder builds a validated EventEngine::Event
98
- │ EventEngine.dispatch(event)
99
-
100
- HandlerRegistry ──► every registered handler whose `levels:` match event_level
101
- (event_engine-delivery, event_engine-store, or your own)
102
- ```
71
+ A host installs the gems and configures **nothing per pack**. Both halves self-wire:
103
72
 
104
- Two things are worth internalizing:
73
+ 1. **Emit routing** at boot `event_engine` registers `DefinitionPublisher` as
74
+ `EventEngine::Definition.publisher`, so every pack helper routes into
75
+ `EventEngine.emit` automatically.
76
+ 2. **Schema discovery** — a generated pack registers itself when required, so
77
+ `event_engine:catalog` finds every pack's `schema.json` with no
78
+ configuration. See [Building the catalog](#1-build-the-catalog).
105
79
 
106
- 1. **The committed schema file not your definition classes is the source of
107
- truth at runtime.** Definition classes are read only at *dump* time. In
108
- production a missing `db/event_schema.rb` raises at boot.
109
- 2. **Emitting and handling are decoupled.** `EventEngine.dispatch` just fans the
110
- event out to handlers by level. The core gem ships *no* handlers.
80
+ What a host still decides for itself is **how** each event is processed, in the
81
+ [rules file](#declaring-how-events-are-processed).
111
82
 
112
83
  ---
113
84
 
114
- ## Defining events
115
-
116
- Put definitions where Rails eager-loads them — conventionally
117
- `app/event_definitions/`. Subclass `EventEngine::EventDefinition`:
85
+ ## Getting started
118
86
 
119
87
  ```ruby
120
- # app/event_definitions/cow_fed.rb
121
- class CowFed < EventEngine::EventDefinition
122
- input :cow # required input to the emit helper
123
- optional_input :farmer # optional input
124
-
125
- event_name :cow_fed # the event's identity → EventEngine.cow_fed
126
- event_type :domain # free-form classification (:domain, :integration, …)
127
- event_level 3 # how it's dispatched (optional; see Event levels)
128
-
129
- required_payload :weight, from: :cow, attr: :weight
130
- optional_payload :farmer_name, from: :farmer, attr: :name
131
- end
88
+ # Gemfile
89
+ gem "event_engine"
132
90
  ```
133
91
 
134
- ### The DSL reference
135
-
136
- All methods below are **class-level** macros on an `EventDefinition` subclass.
137
-
138
- | Macro | Signature | What it does |
139
- |---|---|---|
140
- | `event_name` | `event_name(:symbol)` | The event's identity. Becomes the `EventEngine.<name>` helper. **Required.** |
141
- | `event_type` | `event_type(:symbol)` | Free-form classification, e.g. `:domain`, `:integration`, `:system`. **Required.** |
142
- | `event_level` | `event_level(Integer)` | Dispatch level `1..4` (see [Event levels](#event-levels)). Optional. |
143
- | `input` | `input(:name)` | Declares a **required** input keyword the emit helper accepts. Duplicate names raise `ArgumentError`. |
144
- | `optional_input` | `optional_input(:name)` | Declares an **optional** input keyword. |
145
- | `required_payload` | `required_payload(name, from:, attr: nil)` | A payload field that must be present. `from:` names the input it reads; `attr:` is the method called on that input. |
146
- | `optional_payload` | `optional_payload(name, from:, attr: nil)` | Same, but **omitted from the payload** when the source input is `nil`. |
147
-
148
- A handful of payload field names are **reserved** (they collide with event
149
- envelope/outbox columns) and rejected at dump time:
150
-
151
- ```
152
- event_name event_type event_version occurred_at created_at updated_at
153
- published_at metadata idempotency_key attempts dead_lettered_at
154
- aggregate_type aggregate_id aggregate_version
92
+ ```bash
93
+ bundle install
155
94
  ```
156
95
 
157
- ### How payload fields are extracted
96
+ ### 1. Build the catalog
158
97
 
159
- When you emit, `EventBuilder` walks each declared payload field and pulls a value
160
- out of the inputs you passed:
98
+ At boot the engine loads a committed **`db/event_schema.json`** and reconstructs the
99
+ registry from it (no Ruby is evaluated from a schema file; missing in production
100
+ raises). Put the schema there by either:
161
101
 
162
- - `from:` selects **which input** to read.
163
- - `attr:` is the **method called on that input**. If `attr:` is `nil`, the input
164
- itself is used (passthrough).
165
- - For an `optional_payload`, if the `from:` input is `nil` the field is simply left
166
- out of the payload (no key, not a `nil` value).
102
+ - committing `db/event_schema.json` directly, or
103
+ - registering a pack's slice at boot (additive each slice merges in):
167
104
 
168
- ```ruby
169
- required_payload :weight, from: :cow, attr: :weight
170
- # payload[:weight] = cow.weight
105
+ ```ruby
106
+ # in a pack's Rails engine
107
+ initializer "marketing_events.register_events" do
108
+ config.after_initialize do
109
+ EventEngine.register_slice!(schema_path: MarketingEvents.schema_path)
110
+ end
111
+ end
112
+ ```
171
113
 
172
- optional_payload :raw_cow, from: :cow
173
- # payload[:raw_cow] = cow (passthrough; attr omitted)
114
+ To build `db/event_schema.json` from your packs, run the catalog task — it finds
115
+ them without configuration:
174
116
 
175
- optional_payload :farmer_name, from: :farmer, attr: :name
176
- # → only present if `farmer:` was passed and non-nil
117
+ ```bash
118
+ bin/rails event_engine:catalog
177
119
  ```
178
120
 
179
- The resulting `event.payload` is a **symbol-keyed Hash**.
121
+ This writes **two** files: the catalog itself, and the
122
+ [rules file](#declaring-how-events-are-processed) listing every event in it.
180
123
 
181
- ### There is no `type:` casting
124
+ ### Where the catalog's sources come from
182
125
 
183
- The complete payload DSL is `required_payload` / `optional_payload` with `from:` and
184
- `attr:` only — there is no `type:` option and no type casting, and no
185
- `entity_class` / `entity_id` / `entity_version` macros.
126
+ `EventEngine.schema_sources` resolves what the catalog aggregates:
186
127
 
187
- Whatever value `attr:` returns is stored as-is. If you need a value coerced to a
188
- specific type, do it on the source object's method (e.g. have `cow.weight` return a
189
- `Float`) or expose a purpose-built reader and point `attr:` at it.
128
+ 1. `config.publisher_schema_paths`, when you have set it an explicit list always wins.
129
+ 2. Otherwise, **every pack that has registered itself**. Packs generated by
130
+ `event_engine-event_definition` call `EventEngine::Definition.register_pack(self)`
131
+ when required, so requiring a pack is enough to make its `schema.json` discoverable.
190
132
 
191
- ### Lifecycle event families
133
+ Discovery is optional and degrades quietly: `event_engine` does not depend on
134
+ `event_engine-event_definition`, so with no packs loaded there is nothing to
135
+ discover and `schema_sources` returns the configured list (empty by default).
192
136
 
193
- Related events that describe one capability `export_csv_started`,
194
- `export_csv_completed`, `export_csv_failed` — share inputs and payload fields. Writing
195
- them as three independent `EventDefinition`s lets their names and shared fields drift.
196
- Subclass `EventEngine::LifecycleDefinition` to stamp the whole family from one template:
137
+ A host with packs in its Gemfile therefore needs no per-pack configuration:
197
138
 
198
139
  ```ruby
199
- # app/event_definitions/export_csv_events.rb
200
- class ExportCsvEvents < EventEngine::LifecycleDefinition
201
- subject :export_csv # validated against the SubjectRegistry
202
- event_type :product
203
-
204
- input :export
205
- required_payload :format, from: :export, attr: :format
206
-
207
- lifecycle :started, :completed, :failed # → export_csv_started / _completed / _failed
208
-
209
- on :failed do
210
- input :error
211
- required_payload :error_class, from: :error, attr: :class
212
- end
213
- end
140
+ EventEngine.configuration.publisher_schema_paths # => []
141
+ EventEngine.schema_sources # => ["/…/marketing/schema.json"]
214
142
  ```
215
143
 
216
- This generates three real `EventDefinition`s named `subject_verb` (flat snake_case, so
217
- each yields a working `EventEngine.export_csv_completed(...)` helper). Shared declarations
218
- apply to every verb; an `on :verb` block layers additional inputs/payloads onto that verb
219
- only. The generated events behave exactly like hand-written ones everywhere — schema dump,
220
- registry, helpers, metadata enricher, catalog, and compatibility checks all apply unchanged.
221
-
222
- | Macro | Signature | What it does |
223
- |---|---|---|
224
- | `subject` | `subject(:symbol)` | The family's subject, carried onto every generated event. Must be registered. |
225
- | `event_type` | `event_type(:symbol)` | Shared across every verb. |
226
- | `process_type` | `process_type(:symbol)` | Shared across every verb. Optional. |
227
- | `lifecycle` | `lifecycle(*verbs)` | Generates one event per verb, named `:"#{subject}_#{verb}"`. |
228
- | `on` | `on(:verb) { … }` | Layers verb-specific `input` / `required_payload` / `optional_payload` onto that verb only. Add-only. |
229
-
230
- Shared `input` / `optional_input` / `required_payload` / `optional_payload` are declared
231
- exactly as on a plain `EventDefinition` and apply to every verb.
232
-
233
- ---
234
-
235
- ## Generating the schema
236
-
237
- After adding or changing definitions:
238
-
239
- ```bash
240
- bin/rails event_engine:schema:dump # compile definitions → db/event_schema.rb
241
- ```
144
+ ### 2. Register your processors
242
145
 
243
- This compiles every `EventDefinition` subclass, merges with the existing committed
244
- file, and rewrites `db/event_schema.rb`. **Commit the result.** The generated file
245
- looks like:
146
+ A processor is anything responding to `#call(event)`, registered under a name. Add a
147
+ processor gem (`event_engine-delivery`, `-store`, `-subscribers`, …) **or** your own:
246
148
 
247
149
  ```ruby
248
- # This file is authoritative in production.
249
- # It is generated from EventDefinitions via:
250
- #
251
- # bin/rails event_engine:schema:dump
252
- #
253
- # Do not edit manually.
254
-
255
- EventEngine::EventSchema.define do |schema|
256
- schema.register(
257
- EventEngine::EventDefinition::Schema.new(
258
- event_name: :cow_fed,
259
- event_version: 1,
260
- event_type: :domain,
261
- event_level: 3,
262
- required_inputs: [:cow],
263
- optional_inputs: [:farmer],
264
- payload_fields: [
265
- { name: :weight, required: true, from: :cow, attr: :weight }
266
- ]
267
- )
268
- )
269
- end
150
+ # config/initializers/event_engine.rb
151
+ EventEngine.register_processor(:subscribers, SubscriberFanout.new)
152
+ EventEngine.register_processor(:audit, ->(event) { AuditLog.record(event) })
270
153
  ```
271
154
 
272
- ### How versioning works
155
+ ### 3. Declare how each event is processed
273
156
 
274
- The dumper is **append-only and additive** it never edits an existing version in
275
- place:
157
+ Name the processor for each event in `config/event_rules.yml` see
158
+ [the rules file](#declaring-how-events-are-processed).
276
159
 
277
- - A brand-new event is written as **version 1**.
278
- - When you change an existing event, the merger compares a **SHA256 fingerprint**
279
- of its `event_name`, `event_type`, inputs, and payload fields against the latest
280
- version in the file. If they differ, it writes a **new version** (`N + 1`); if
281
- they match, nothing changes.
282
- - Version numbers are **monotonic** — reverting a change to a previous shape still
283
- produces a *new* higher version, never reuses an old number.
160
+ ### 4. Emit
284
161
 
285
- > **`event_level` is intentionally excluded from the fingerprint.** Changing only an
286
- > event's level does **not** bump its version — level is treated as operational
287
- > routing metadata, not part of the event contract. This is what lets you "promote"
288
- > an event up the level ladder as a one-line change with no schema churn.
162
+ ```ruby
163
+ EventEngine.emit(:cow_fed, inputs: { cow: cow }, domain: :sales)
164
+ ```
289
165
 
290
- ### Drift checking in CI
166
+ Or, more usually, call the pack's generated helper — it routes here for you:
291
167
 
292
- ```bash
293
- bin/rails event_engine:schema:verify
168
+ ```ruby
169
+ MarketingEvents.lead_created(lead: lead)
294
170
  ```
295
171
 
296
- This fails if your definitions have drifted from the committed `db/event_schema.rb`
297
- (i.e. someone changed a definition but forgot to dump), printing a readable diff of
298
- what changed. Add it to CI to keep the file honest. The older `event_engine:schema`
299
- and `event_engine:schema_check` tasks perform the same check without the diff.
300
-
301
172
  ---
302
173
 
303
174
  ## Emitting events
304
175
 
305
- At boot the engine loads `db/event_schema.rb` and installs a singleton helper on
306
- `EventEngine` for each event. Pass declared inputs by keyword, plus optional
307
- emit-time envelope fields:
176
+ `EventEngine.emit` is the always-available entry point, and the target the publisher
177
+ adapter forwards to. It looks the event up in the catalog, builds its payload from
178
+ the inputs, stamps the envelope, routes it to its processor, and returns the built
179
+ `EventEngine::Event`.
308
180
 
309
181
  ```ruby
310
- EventEngine.cow_fed(
311
- cow: cow, # declared inputs, by name
312
- farmer: farmer,
313
-
314
- occurred_at: Time.current, # optional; defaults to Time.current
315
- metadata: { request_id: "abc" }, # optional contextual hash
316
- idempotency_key: "cow-#{cow.id}-#{Date.current}", # optional; defaults to a UUID
317
- aggregate_type: "Cow", # optional aggregate tracking
182
+ event = EventEngine.emit(
183
+ :cow_fed,
184
+ inputs: { cow: cow, farmer: farmer }, # the declared inputs, by name
185
+
186
+ domain: :sales, # scopes lookup when a name exists in >1 domain
187
+ event_version: 2, # optional; defaults to the latest version
188
+ occurred_at: Time.current, # optional; defaults to Time.current
189
+ metadata: { source: "import" }, # optional; merged over metadata_defaults
190
+ idempotency_key: "cow-#{cow.id}", # optional; defaults to a UUID
191
+ aggregate_type: "Cow", # optional aggregate envelope fields
318
192
  aggregate_id: cow.id,
319
- aggregate_version: 1
193
+ aggregate_version: 3
320
194
  )
321
- ```
322
-
323
- - Missing a required input, or passing an unknown input, raises `ArgumentError`.
324
- - `event_version:` may be passed to pin a specific schema version (defaults to latest).
325
- - The return value is **whatever the handlers return** — there's no canonical return
326
- in core. (`event_engine-delivery`, for example, returns the persisted outbox record
327
- for levels 3+.)
328
-
329
- The built `EventEngine::Event` exposes: `event_name`, `event_type`, `event_version`,
330
- `event_level`, `payload` (symbol-keyed), `metadata`, `occurred_at`,
331
- `idempotency_key`, `aggregate_type`, `aggregate_id`, `aggregate_version`.
332
-
333
- ---
334
-
335
- ## Subscribers
336
195
 
337
- A **subscriber** reacts to an event in-process. Subclass `EventEngine::Subscriber`,
338
- declare what it handles, and implement `handle`:
339
-
340
- ```ruby
341
- # app/subscribers/send_welcome_email.rb
342
- class SendWelcomeEmail < EventEngine::Subscriber
343
- subscribes_to :user_registered
344
-
345
- def handle(event)
346
- # event.payload is symbol-keyed
347
- UserMailer.welcome(event.payload[:user_id]).deliver_later
348
- end
349
- end
196
+ event.payload # => { weight: 500 } (symbol-keyed, built from inputs)
197
+ event.process_type # => :durable (from the rules file)
350
198
  ```
351
199
 
352
- - `subscribes_to(:event_name)` registers the subscriber at load time.
353
- - `handle(event)` is required; the base raises `NotImplementedError` otherwise.
200
+ Input validation (missing required input, unknown input) raises `ArgumentError` at
201
+ build time.
354
202
 
355
- > **Who actually calls subscribers?** The core gem only *registers* subscribers in
356
- > `EventEngine::SubscriberRegistry` — it does not invoke them. Invocation is done by
357
- > a handler. `event_engine-delivery` invokes subscribers for levels 1–3 (see its
358
- > docs). If you run core standalone, your own handler decides when/whether to call
359
- > `EventEngine::SubscriberRegistry.subscribers_for(event.event_name)`.
203
+ ### Named helpers (`MarketingEvents.lead_created`)
360
204
 
361
- Keep subscribers **idempotent** at levels 3+ they may be retried.
205
+ The ergonomic per-event helpers live in the **domain packs**, generated by
206
+ `event_engine-event_definition`. This runtime does not generate them; at boot it will
207
+ `load db/event_engine_helpers.rb` **if a pack has committed one**, but it never
208
+ creates that file. Once the [publisher adapter](#the-intended-passive-wiring-tbd)
209
+ is wired, calling a pack helper routes through `EventEngine.emit` for you; until then,
210
+ call `EventEngine.emit` directly.
362
211
 
363
212
  ---
364
213
 
365
- ## Event levels
214
+ ## Declaring how events are processed
366
215
 
367
- `event_level` is a hint that tells the *delivery* layer how hard to work to get an
368
- event where it's going. **Your producer code never changes when you move an event up
369
- a level — it's a one-line edit to the definition.**
216
+ Every event in the catalog gets its processing declared in one file,
217
+ `config/event_rules.yml`. The catalog task writes it for you and keeps it in step
218
+ with the catalog.
370
219
 
371
- | Level | Durable? | Where it goes | Adopt when | Watch out for |
372
- |---|---|---|---|---|
373
- | **1 sync** | no | in-app subscribers, synchronously in the caller's stack | a cheap in-process reaction that must happen now | a slow/failing subscriber blocks the caller; nothing persists, so it's lost on a crash |
374
- | **2 job** | no | in-app subscribers, via a background job | the reaction can be deferred | still not durable; needs an ActiveJob backend; failures don't surface to the caller |
375
- | **3 outbox** | **yes** | in-app subscribers, when the outbox drains | the reaction must survive a crash and be atomic with your DB write | more moving parts; delivery is eventual |
376
- | **4 outbox + broker** | **yes** | **outside the app**, to a transport (Kafka, …) | an independent service consumes it on its own cycle | it's a cross-service contract — schema/version discipline matters; needs a real transport |
220
+ ```yaml
221
+ default: subscribers # anything not named below
222
+ packs:
223
+ marketing: subscribers # every event in the marketing pack
224
+ billing: ledger
225
+ events:
226
+ invoice_voided: audit # this one event
227
+ lead_converted: # catalogued but undecided
228
+ ```
377
229
 
378
- Guiding principle: **adopt the lowest level that solves your real problem; move up
379
- only when the problem demands it.** Signals to move up:
230
+ A rule names a **registered processor**. Resolution is **event pack default**,
231
+ and the resolved name is both the processor invoked and the value stamped on
232
+ `event.process_type`, so the two cannot disagree.
380
233
 
381
- - A level-1 subscriber is slow / on the request hot path → **1 → 2**.
382
- - Work is lost across crashes/restarts/deploys → **2 → 3**.
383
- - An independent service must consume the event → **3 → 4**.
234
+ ### Keeping it in step with the catalog
384
235
 
385
- > **The level table describes behavior implemented by `event_engine-delivery`.** The
386
- > core gem only stamps `event_level` onto the event and dispatches it. Levels 1–4
387
- > *mean* something only once a handler that interprets them is registered. Level 5
388
- > (event sourcing) is reserved but unsupported by the delivery layer.
236
+ `bin/rails event_engine:catalog` writes this file alongside the catalog:
389
237
 
390
- > **Caveat:** if you omit `event_level`, the event's level is `nil`. Handlers decide
391
- > how to treat `nil` `event_engine-delivery`, for instance, routes `nil` through
392
- > its outbox path (the `else` branch). Set a level explicitly to be unambiguous.
238
+ - every catalogued event appears, so a new event is visible rather than silently unrouted
239
+ - rules you have already decided are preserved
240
+ - `default` and `packs` are preserved
393
241
 
394
- ---
242
+ A newly catalogued event appears with no value. It falls through to its pack rule or
243
+ the default; if neither exists, emitting it raises rather than being dropped.
395
244
 
396
- ## The handler extension point
245
+ ### When a rule is wrong
397
246
 
398
- This is the seam every companion gem (and you) plug into. A **handler** is any
399
- object that responds to `call(event)`. Register it with the levels it cares about:
247
+ Both misconfigurations fail loudly and name the fix:
400
248
 
401
- ```ruby
402
- EventEngine.register_handler(handler, levels: :all) # every event
403
- EventEngine.register_handler(handler, levels: 1..4) # a Range
404
- EventEngine.register_handler(handler, levels: [1, 3]) # an explicit list
405
249
  ```
250
+ EventEngine::UnroutableEventError:
251
+ no processing rule for event :lead_converted (pack :marketing);
252
+ declare it in the rules file under events, packs, or default
406
253
 
407
- On `EventEngine.dispatch(event)`, every handler whose `levels:` include
408
- `event.event_level` (or `:all`) gets `call(event)`, in registration order.
409
-
410
- A minimal standalone handler — no companion gem required:
411
-
412
- ```ruby
413
- # config/initializers/event_engine.rb
414
- class LogEverythingHandler
415
- def call(event)
416
- Rails.logger.info("[event] #{event.event_name} v#{event.event_version} #{event.payload.inspect}")
417
- event
418
- end
419
- end
420
-
421
- Rails.application.config.after_initialize do
422
- EventEngine.register_handler(LogEverythingHandler.new, levels: :all)
423
- end
254
+ EventEngine::UnregisteredProcessorError:
255
+ the rule for event :lead_created (pack :marketing) names processor :ghost,
256
+ but no processor is registered under that name
424
257
  ```
425
258
 
426
- This is exactly how the companion gems hook in:
259
+ Declaring nothing at all is still valid: with an empty rules file no processor is
260
+ invoked and events are built and returned as before.
261
+
262
+ ---
427
263
 
428
- - **`event_engine-delivery`** registers a handler at `levels: :all` that routes by
429
- level (sync subscribers / background job / outbox / broker).
430
- - **`event_engine-store`** registers two handlers at `levels: :all` (a recorder and a
431
- projection dispatcher).
264
+ ## Configuration
432
265
 
433
- Other primitives on the `EventEngine` module:
266
+ Set via `EventEngine.configure { |config| … }`. All fields are optional.
434
267
 
435
- - `EventEngine.dispatch(event)` fan an `Event` out to handlers (helpers call this).
436
- - `EventEngine.reset_handlers!` — clear all handlers (useful in tests, or to fully
437
- take over routing).
268
+ | Field | Default | Accepts | What it does |
269
+ |---|---|---|---|
270
+ | `schema_path` | `"db/event_schema.json"` | String / path | The committed catalog the engine loads at boot, and the file the catalog task writes. |
271
+ | `rules_path` | `"config/event_rules.yml"` | String / path | The processing rules the runtime loads, and the file the catalog task keeps in step. |
272
+ | `metadata_defaults` | `nil` | A callable (`-> { Hash }`) | Called on each emit; its hash is merged **under** any call-site `metadata:` (call-site wins). A raising callable is swallowed and logged, so emission never breaks. |
273
+ | `logger` | `Rails.logger` | Any Logger | Where the engine logs (missing-schema warning, a raising `metadata_defaults`). |
274
+ | `publisher_schema_paths` | `[]` | Array of paths | Pins the catalog task to an explicit list of `schema.json` files. Leave empty to discover registered packs. |
438
275
 
439
- > Handlers run **in-process, in order, synchronously** within `dispatch`. If a
440
- > handler raises, later handlers don't run and the exception propagates to the
441
- > caller. Order matters: register `event_engine-store` before/after `delivery`
442
- > deliberately if both are present.
276
+ Processing is **not** configured here it is declared in the
277
+ [rules file](#declaring-how-events-are-processed).
443
278
 
444
279
  ---
445
280
 
446
- ## Configuration
281
+ ## The `Event`
447
282
 
448
- The **core** gem's configuration is intentionally tiny — just a logger:
283
+ `EventEngine::Event` is a keyword-init `Struct`:
449
284
 
450
- ```ruby
451
- # config/initializers/event_engine.rb
452
- EventEngine.configure do |config|
453
- config.logger = Rails.logger # the only core option
454
- end
285
+ ```
286
+ event_name event_type event_version process_type subject domain
287
+ payload metadata occurred_at idempotency_key
288
+ aggregate_type aggregate_id aggregate_version
455
289
  ```
456
290
 
457
- | Option | Default | Purpose |
458
- |---|---|---|
459
- | `logger` | `Rails.logger` (or `Logger.new($stdout)` outside Rails) | Where core logs |
460
-
461
- > Delivery options (`delivery_adapter`, `transport`, `batch_size`, …) belong to
462
- > `event_engine-delivery` and are set via `EventEngine::Delivery.configure` — see that
463
- > gem's README.
291
+ `Event.from(record)` rebuilds one from any object exposing those readers (symbolizing
292
+ the payload keys) — handy for processor gems reconstituting a persisted event.
464
293
 
465
294
  ---
466
295
 
467
296
  ## Rake tasks
468
297
 
469
- | Task | Purpose |
298
+ | Task | What it does |
470
299
  |---|---|
471
- | `event_engine:schema:dump` | Compile definitions `db/event_schema.rb` (commit it) |
472
- | `event_engine:schema:verify` | Fail with a readable diff if definitions have drifted (use in CI) |
473
- | `event_engine:schema` | Same drift check, no diff |
474
- | `event_engine:schema_check` | Same drift check, no diff (alternate name) |
300
+ | `event_engine:catalog` | Builds the committed catalog at `schema_path` from every discovered pack (or from `publisher_schema_paths` when set), then keeps the rules file at `rules_path` in step with it. |
475
301
 
476
- (`event_engine-delivery` adds `dead_letters:*` and `outbox:cleanup` tasks.)
477
302
 
478
303
  ---
479
304
 
480
- ## Installation generator
305
+ ## Known gaps
481
306
 
482
- ```bash
483
- bin/rails g event_engine:install
484
- ```
485
-
486
- It creates `config/initializers/event_engine.rb`, a stub `db/event_schema.rb`, and
487
- installs Claude Code subagent files under `.claude/agents/` (see
488
- [For AI assistants](#for-ai-assistants)).
489
-
490
- The core gem itself ships no migrations. If you need the outbox or the event log,
491
- install the companion gem you need and run its migrations directly (see
492
- `event_engine-delivery` / `event_engine-store`).
307
+ - **Boot requires a committed `db/event_schema.json`.** The engine raises in
308
+ production if it is missing. Building it is one task (`event_engine:catalog`),
309
+ but it does have to be committed.
310
+ - **`the_local` guides.** The AI-assistant reference under
311
+ `lib/event_engine/reference/` and the `the_local` subagents still describe the old
312
+ in-gem DSL. They are generated provider docs (owned by `the_local-develop`) and
313
+ will be regenerated to match this runtime.
493
314
 
494
315
  ---
495
316
 
496
- ## For AI assistants
497
-
498
- A condensed, authoritative API reference ships inside the gem at
499
- `lib/event_engine/reference/guide.md` and is installed into consuming apps as Claude
500
- Code subagents (`.claude/agents/`). When working in a host app, prefer that
501
- reference and this README over reading gem internals.
317
+ ## Development
502
318
 
503
- ---
504
-
505
- ## Contributing
506
-
507
- 1. Fork and create a feature branch.
508
- 2. Add tests for behavior changes (Minitest; see `test/`).
509
- 3. Run the suite: `bundle exec rake test`.
510
- 4. Open a PR.
319
+ ```bash
320
+ bundle install
321
+ bundle exec rake test # Minitest, via the dummy app in test/dummy
322
+ ```
511
323
 
512
324
  ---
513
325
 
514
326
  ## License
515
327
 
516
- Available as open source under the terms of the
517
- [MIT License](https://opensource.org/licenses/MIT).
328
+ Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).