event_engine 0.1.0 → 0.2.1

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 +73 -0
  3. data/README.md +218 -398
  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,337 @@
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 |
19
-
20
- You can run the core gem **by itself** with your own handlers — see
21
- [The handler extension point](#the-handler-extension-point).
22
-
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`.
26
-
27
- ---
28
-
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)
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.
51
21
 
52
22
  ---
53
23
 
54
- ## Quick start
55
-
56
- ```ruby
57
- # Gemfile
58
- gem "event_engine"
59
- ```
24
+ ## Where this gem sits
60
25
 
61
- ```bash
62
- bundle install
63
- ```
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 |
64
33
 
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.
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.
82
37
 
83
38
  ---
84
39
 
85
- ## Mental model
40
+ ## How it fits together
41
+
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.
86
45
 
87
46
  ```
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)
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)
102
62
  ```
103
63
 
104
- Two things are worth internalizing:
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.
105
68
 
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.
69
+ ### The passive wiring
111
70
 
112
- ---
71
+ A host installs the gems and configures **nothing per pack**. Both halves self-wire:
113
72
 
114
- ## Defining events
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).
115
79
 
116
- Put definitions where Rails eager-loads them conventionally
117
- `app/event_definitions/`. Subclass `EventEngine::EventDefinition`:
80
+ What a host still decides for itself is **how** each event is processed, in the
81
+ [rules file](#declaring-how-events-are-processed).
118
82
 
119
- ```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
132
- ```
83
+ ---
133
84
 
134
- ### The DSL reference
85
+ ## A working example
135
86
 
136
- All methods below are **class-level** macros on an `EventDefinition` subclass.
87
+ [**DYB-Development/event_engine_example**](https://github.com/DYB-Development/event_engine_example)
88
+ is a minimal Rails app wired to this pipeline end to end — declaring an event,
89
+ compiling it, declaring how it is processed, and handling it. It installs all three
90
+ gems from RubyGems, so it is what a real host looks like.
137
91
 
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`. |
92
+ ---
147
93
 
148
- A handful of payload field names are **reserved** (they collide with event
149
- envelope/outbox columns) and rejected at dump time:
94
+ ## Getting started
150
95
 
96
+ ```ruby
97
+ # Gemfile
98
+ gem "event_engine"
151
99
  ```
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
100
+
101
+ ```bash
102
+ bundle install
155
103
  ```
156
104
 
157
- ### How payload fields are extracted
105
+ ### 1. Build the catalog
158
106
 
159
- When you emit, `EventBuilder` walks each declared payload field and pulls a value
160
- out of the inputs you passed:
107
+ At boot the engine loads a committed **`db/event_schema.json`** and reconstructs the
108
+ registry from it (no Ruby is evaluated from a schema file; missing in production
109
+ raises). Put the schema there by either:
161
110
 
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).
111
+ - committing `db/event_schema.json` directly, or
112
+ - registering a pack's slice at boot (additive each slice merges in):
167
113
 
168
- ```ruby
169
- required_payload :weight, from: :cow, attr: :weight
170
- # payload[:weight] = cow.weight
114
+ ```ruby
115
+ # in a pack's Rails engine
116
+ initializer "marketing_events.register_events" do
117
+ config.after_initialize do
118
+ EventEngine.register_slice!(schema_path: MarketingEvents.schema_path)
119
+ end
120
+ end
121
+ ```
171
122
 
172
- optional_payload :raw_cow, from: :cow
173
- # payload[:raw_cow] = cow (passthrough; attr omitted)
123
+ To build `db/event_schema.json` from your packs, run the catalog task — it finds
124
+ them without configuration:
174
125
 
175
- optional_payload :farmer_name, from: :farmer, attr: :name
176
- # → only present if `farmer:` was passed and non-nil
126
+ ```bash
127
+ bin/rails event_engine:catalog
177
128
  ```
178
129
 
179
- The resulting `event.payload` is a **symbol-keyed Hash**.
130
+ This writes **two** files: the catalog itself, and the
131
+ [rules file](#declaring-how-events-are-processed) listing every event in it.
180
132
 
181
- ### There is no `type:` casting
133
+ ### Where the catalog's sources come from
182
134
 
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.
135
+ `EventEngine.schema_sources` resolves what the catalog aggregates:
186
136
 
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.
137
+ 1. `config.publisher_schema_paths`, when you have set it an explicit list always wins.
138
+ 2. Otherwise, **every pack that has registered itself**. Packs generated by
139
+ `event_engine-event_definition` call `EventEngine::Definition.register_pack(self)`
140
+ when required, so requiring a pack is enough to make its `schema.json` discoverable.
190
141
 
191
- ### Lifecycle event families
142
+ Discovery is optional and degrades quietly: `event_engine` does not depend on
143
+ `event_engine-event_definition`, so with no packs loaded there is nothing to
144
+ discover and `schema_sources` returns the configured list (empty by default).
192
145
 
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:
146
+ A host with packs in its Gemfile therefore needs no per-pack configuration:
197
147
 
198
148
  ```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
149
+ EventEngine.configuration.publisher_schema_paths # => []
150
+ EventEngine.schema_sources # => ["/…/marketing/schema.json"]
214
151
  ```
215
152
 
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
- ```
153
+ ### 2. Register your processors
242
154
 
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:
155
+ A processor is anything responding to `#call(event)`, registered under a name. Add a
156
+ processor gem (`event_engine-delivery`, `-store`, `-subscribers`, …) **or** your own:
246
157
 
247
158
  ```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
159
+ # config/initializers/event_engine.rb
160
+ EventEngine.register_processor(:subscribers, SubscriberFanout.new)
161
+ EventEngine.register_processor(:audit, ->(event) { AuditLog.record(event) })
270
162
  ```
271
163
 
272
- ### How versioning works
164
+ ### 3. Declare how each event is processed
273
165
 
274
- The dumper is **append-only and additive** it never edits an existing version in
275
- place:
166
+ Name the processor for each event in `config/event_rules.yml` see
167
+ [the rules file](#declaring-how-events-are-processed).
276
168
 
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.
169
+ ### 4. Emit
284
170
 
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.
171
+ ```ruby
172
+ EventEngine.emit(:cow_fed, inputs: { cow: cow }, domain: :sales)
173
+ ```
289
174
 
290
- ### Drift checking in CI
175
+ Or, more usually, call the pack's generated helper — it routes here for you:
291
176
 
292
- ```bash
293
- bin/rails event_engine:schema:verify
177
+ ```ruby
178
+ MarketingEvents.lead_created(lead: lead)
294
179
  ```
295
180
 
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
181
  ---
302
182
 
303
183
  ## Emitting events
304
184
 
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:
185
+ `EventEngine.emit` is the always-available entry point, and the target the publisher
186
+ adapter forwards to. It looks the event up in the catalog, builds its payload from
187
+ the inputs, stamps the envelope, routes it to its processor, and returns the built
188
+ `EventEngine::Event`.
308
189
 
309
190
  ```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
191
+ event = EventEngine.emit(
192
+ :cow_fed,
193
+ inputs: { cow: cow, farmer: farmer }, # the declared inputs, by name
194
+
195
+ domain: :sales, # scopes lookup when a name exists in >1 domain
196
+ event_version: 2, # optional; defaults to the latest version
197
+ occurred_at: Time.current, # optional; defaults to Time.current
198
+ metadata: { source: "import" }, # optional; merged over metadata_defaults
199
+ idempotency_key: "cow-#{cow.id}", # optional; defaults to a UUID
200
+ aggregate_type: "Cow", # optional aggregate envelope fields
318
201
  aggregate_id: cow.id,
319
- aggregate_version: 1
202
+ aggregate_version: 3
320
203
  )
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
204
 
335
- ## Subscribers
336
-
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
205
+ event.payload # => { weight: 500 } (symbol-keyed, built from inputs)
206
+ event.process_type # => :durable (from the rules file)
350
207
  ```
351
208
 
352
- - `subscribes_to(:event_name)` registers the subscriber at load time.
353
- - `handle(event)` is required; the base raises `NotImplementedError` otherwise.
209
+ Input validation (missing required input, unknown input) raises `ArgumentError` at
210
+ build time.
354
211
 
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)`.
212
+ ### Named helpers (`MarketingEvents.lead_created`)
360
213
 
361
- Keep subscribers **idempotent** at levels 3+ they may be retried.
214
+ The ergonomic per-event helpers live in the **domain packs**, generated by
215
+ `event_engine-event_definition`. This runtime does not generate them; at boot it will
216
+ `load db/event_engine_helpers.rb` **if a pack has committed one**, but it never
217
+ creates that file. Once the [publisher adapter](#the-intended-passive-wiring-tbd)
218
+ is wired, calling a pack helper routes through `EventEngine.emit` for you; until then,
219
+ call `EventEngine.emit` directly.
362
220
 
363
221
  ---
364
222
 
365
- ## Event levels
223
+ ## Declaring how events are processed
366
224
 
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.**
225
+ Every event in the catalog gets its processing declared in one file,
226
+ `config/event_rules.yml`. The catalog task writes it for you and keeps it in step
227
+ with the catalog.
370
228
 
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 |
229
+ ```yaml
230
+ default: subscribers # anything not named below
231
+ packs:
232
+ marketing: subscribers # every event in the marketing pack
233
+ billing: ledger
234
+ events:
235
+ invoice_voided: audit # this one event
236
+ lead_converted: # catalogued but undecided
237
+ ```
377
238
 
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:
239
+ A rule names a **registered processor**. Resolution is **event pack default**,
240
+ and the resolved name is both the processor invoked and the value stamped on
241
+ `event.process_type`, so the two cannot disagree.
380
242
 
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**.
243
+ ### Keeping it in step with the catalog
384
244
 
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.
245
+ `bin/rails event_engine:catalog` writes this file alongside the catalog:
389
246
 
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.
247
+ - every catalogued event appears, so a new event is visible rather than silently unrouted
248
+ - rules you have already decided are preserved
249
+ - `default` and `packs` are preserved
393
250
 
394
- ---
251
+ A newly catalogued event appears with no value. It falls through to its pack rule or
252
+ the default; if neither exists, emitting it raises rather than being dropped.
395
253
 
396
- ## The handler extension point
254
+ ### When a rule is wrong
397
255
 
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:
256
+ Both misconfigurations fail loudly and name the fix:
400
257
 
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
258
  ```
259
+ EventEngine::UnroutableEventError:
260
+ no processing rule for event :lead_converted (pack :marketing);
261
+ declare it in the rules file under events, packs, or default
406
262
 
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
263
+ EventEngine::UnregisteredProcessorError:
264
+ the rule for event :lead_created (pack :marketing) names processor :ghost,
265
+ but no processor is registered under that name
424
266
  ```
425
267
 
426
- This is exactly how the companion gems hook in:
268
+ Declaring nothing at all is still valid: with an empty rules file no processor is
269
+ invoked and events are built and returned as before.
427
270
 
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).
271
+ ---
272
+
273
+ ## Configuration
432
274
 
433
- Other primitives on the `EventEngine` module:
275
+ Set via `EventEngine.configure { |config| … }`. All fields are optional.
434
276
 
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).
277
+ | Field | Default | Accepts | What it does |
278
+ |---|---|---|---|
279
+ | `schema_path` | `"db/event_schema.json"` | String / path | The committed catalog the engine loads at boot, and the file the catalog task writes. |
280
+ | `rules_path` | `"config/event_rules.yml"` | String / path | The processing rules the runtime loads, and the file the catalog task keeps in step. |
281
+ | `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. |
282
+ | `logger` | `Rails.logger` | Any Logger | Where the engine logs (missing-schema warning, a raising `metadata_defaults`). |
283
+ | `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
284
 
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.
285
+ Processing is **not** configured here it is declared in the
286
+ [rules file](#declaring-how-events-are-processed).
443
287
 
444
288
  ---
445
289
 
446
- ## Configuration
290
+ ## The `Event`
447
291
 
448
- The **core** gem's configuration is intentionally tiny — just a logger:
292
+ `EventEngine::Event` is a keyword-init `Struct`:
449
293
 
450
- ```ruby
451
- # config/initializers/event_engine.rb
452
- EventEngine.configure do |config|
453
- config.logger = Rails.logger # the only core option
454
- end
294
+ ```
295
+ event_name event_type event_version process_type subject domain
296
+ payload metadata occurred_at idempotency_key
297
+ aggregate_type aggregate_id aggregate_version
455
298
  ```
456
299
 
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.
300
+ `Event.from(record)` rebuilds one from any object exposing those readers (symbolizing
301
+ the payload keys) — handy for processor gems reconstituting a persisted event.
464
302
 
465
303
  ---
466
304
 
467
305
  ## Rake tasks
468
306
 
469
- | Task | Purpose |
307
+ | Task | What it does |
470
308
  |---|---|
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) |
309
+ | `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
310
 
476
- (`event_engine-delivery` adds `dead_letters:*` and `outbox:cleanup` tasks.)
477
311
 
478
312
  ---
479
313
 
480
- ## Installation generator
314
+ ## Known gaps
481
315
 
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`).
316
+ - **Boot requires a committed `db/event_schema.json`.** The engine raises in
317
+ production if it is missing. Building it is one task (`event_engine:catalog`),
318
+ but it does have to be committed.
319
+ - **`the_local` guides.** The AI-assistant reference under
320
+ `lib/event_engine/reference/` and the `the_local` subagents still describe the old
321
+ in-gem DSL. They are generated provider docs (owned by `the_local-develop`) and
322
+ will be regenerated to match this runtime.
493
323
 
494
324
  ---
495
325
 
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.
326
+ ## Development
502
327
 
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.
328
+ ```bash
329
+ bundle install
330
+ bundle exec rake test # Minitest, via the dummy app in test/dummy
331
+ ```
511
332
 
512
333
  ---
513
334
 
514
335
  ## License
515
336
 
516
- Available as open source under the terms of the
517
- [MIT License](https://opensource.org/licenses/MIT).
337
+ Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).