event_rail 0.2.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 191255f222c7ea1b8b082d118d7d6c4111c52f822cb320004fbcb0323a07d464
4
- data.tar.gz: 4082d4d03ca75711cf9077d54d7fa1249e04738e29ce5e575fa4fb336d49389b
3
+ metadata.gz: 3367f3d4d8f5eb0841f1cca4021af4faa7a3fac3bf1a94edb328f5206eccd516
4
+ data.tar.gz: 795013406723b9b46fc5e5ca4532a0a2b5f35c0ab9ca3a49240bb63c09b13ead
5
5
  SHA512:
6
- metadata.gz: 2e0b5b49ad4be65fade9e5e9120aed1bcd1a87fd51ab49288b05a3f380d906af1b146d42568d82a83bee34581bfa268ef512b97c2602f533c5eb32168a64afa6
7
- data.tar.gz: 20685b5f579f6d68a3742ca1388e4f52714a25165071d38899ba98bbd78b45c0be8173dee1a587c134902c5128dd9c9dc2ee75d4ce4b135d409994bcef71a3e5
6
+ metadata.gz: 3df1e24c288ab7f89544af2286456e5e2a9f64ed7155a83479794fda04728c86b71cbf9f8486d9dc09873a9a35920efe316acf25211be3eeb6111e3be326ee96
7
+ data.tar.gz: cd069c5dcd53f4195e5171db86cbe75cfece2dfbbf1218c36710cf2fc54e9b5a932a48eaa685d9b5abc8eb6b75ad6cef24fc00bb40bd070032ce5397b1c6a3ed
data/CHANGELOG.md CHANGED
@@ -13,6 +13,77 @@ here unless a release changes how they behave.
13
13
 
14
14
  ## [Unreleased]
15
15
 
16
+ ## [0.4.0] - 2026-09-26
17
+
18
+ ### Changed
19
+
20
+ - **Breaking: an event with declared identity derives its ID from the fact, not from the
21
+ job that publishes it.** With `identity_by` or `identity:`, the ID now comes from the
22
+ event's source, event type and identity only: not the job class, the job's ID, the
23
+ execution or the schema version. The same fact has one ID wherever and however often it
24
+ is published, in a controller or in any job, and in every version of its event type.
25
+ Previously a caller that ran twice enqueued a new job with a new ID and published the same
26
+ fact under a new event ID.
27
+ - **Breaking: `publish(event, key:)` is now `publish(event, identity:)`.** A key used to be
28
+ scoped to one job; an identity names a fact across every job. `key:` raises with
29
+ directions, and `identity:` is refused for a class that declares `identity_by`. An explicit
30
+ identity derives the same ID as a single string `identity_by` attribute with the same value.
31
+ - **Breaking: every derived ID changes.** The derivation has a new namespace and separate,
32
+ documented rules for declared facts and for executions, pinned by vectors published in the
33
+ README so other implementations can reproduce them. Strings are hashed as UTF-8 and invalid
34
+ UTF-8 is refused.
35
+ - **Breaking: random IDs are UUIDv7**, time-ordered, for events without declared identity
36
+ published outside a job. Derived IDs remain UUIDv5.
37
+ - An event without declared identity published by a subscriber derives its ID from the
38
+ handled event's source as well as its ID, so causes from two producers that share an ID are
39
+ scoped apart.
40
+ - The duplicate check within one execution includes the source, so one job can publish the
41
+ same fact for two producers, and relay two events that share an ID from different sources.
42
+ - A relayed event keeps its lineage as it arrived: an absent causation is no longer filled
43
+ from the relaying context. A relay retried with a different payload, occurrence time or
44
+ extensions is refused, as a local retry is.
45
+ - One publication record per `(source, type, version, id)` within an execution: publishing
46
+ the stamped event of a failed local publication, for instance to recover from an
47
+ `EnqueueError`, is its retry, and publishing it after success is a duplicate. A relayed ID
48
+ is compared as opaque bytes, so an ID that is not valid UTF-8 relays as before.
49
+ - **Breaking: a declared identity attribute cannot be an empty string**, as it cannot be nil:
50
+ every blank event would otherwise be one fact.
51
+ - `EventRail.with_context` opened inside a running job keeps the job's execution, so an
52
+ undeclared event published in the block keeps its retry-stable ID and its duplicate check;
53
+ it used to get a random ID.
54
+ - Versioning: every version of an event type keeps its declared identity values; changing
55
+ what an event identifies needs a new event type, not a new version.
56
+
57
+ ### Added
58
+
59
+ - `publish(event, source:)` publishes a locally built event on behalf of another producer,
60
+ such as an inbound webhook; relaying an event under a different source than it carries is
61
+ refused.
62
+
63
+ ### Upgrading
64
+
65
+ - Audit every `identity_by` and every former `key:` before upgrading. A value that was safe
66
+ within one job, such as a line number or a product ID for a fact that recurs, now names one
67
+ fact across every job and would merge distinct facts silently. Identity must name the
68
+ occurrence, not the thing it is about.
69
+ - IDs derived before and after the upgrade differ. A publication retried across the deploy, a
70
+ delayed job, a backfill or a dead-letter redelivery can deliver a fact again under a new ID;
71
+ subscribers that also deduplicate on a business key are unaffected. Draining publishing jobs
72
+ before deploying narrows the window.
73
+
74
+ ## [0.3.0] - 2026-09-21
75
+
76
+ ### Changed
77
+
78
+ - **`subscribes_to` now raises `EventRail::DeclarationError` when the event class it names
79
+ declares neither `event_type` nor `version`**, at the declaration itself. Previously such
80
+ a subscription was accepted and then never fired: publication matches subscribers by exact
81
+ event class, so a concrete subclass never reached the base's registration, and the class
82
+ without a contract could not be constructed to be published on its own. Because neither
83
+ declaration is inherited, this covers a shared abstract base such as `ApplicationEvent`
84
+ and equally a subclass of a concrete event that adds nothing of its own. Subscribe to each
85
+ concrete event class instead.
86
+
16
87
  ## [0.2.0] - 2026-09-17
17
88
 
18
89
  ### Added
@@ -87,6 +158,8 @@ here unless a release changes how they behave.
87
158
  - A typed error hierarchy rooted at `EventRail::Error`, distinguishing declaration,
88
159
  casting, context, serialization, and publication failures.
89
160
 
90
- [Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.2.0...HEAD
161
+ [Unreleased]: https://github.com/alexdmtv/event-rail/compare/v0.4.0...HEAD
162
+ [0.4.0]: https://github.com/alexdmtv/event-rail/compare/v0.3.0...v0.4.0
163
+ [0.3.0]: https://github.com/alexdmtv/event-rail/compare/v0.2.0...v0.3.0
91
164
  [0.2.0]: https://github.com/alexdmtv/event-rail/compare/v0.1.0...v0.2.0
92
165
  [0.1.0]: https://github.com/alexdmtv/event-rail/releases/tag/v0.1.0
data/README.md CHANGED
@@ -2,9 +2,43 @@
2
2
 
3
3
  [![CI](https://github.com/alexdmtv/event-rail/actions/workflows/ci.yml/badge.svg)](https://github.com/alexdmtv/event-rail/actions/workflows/ci.yml)
4
4
 
5
- EventRail is an early-stage Rails library for immutable domain events and durable fanout through ordinary Active Job subscribers. It builds on Rails conventions instead of introducing a transport, command bus, dependency-injection container, or replacement job runtime.
5
+ When a Rails application grows, the code that places an order ends up naming everything that should happen next: send the confirmation email, update the loyalty balance, notify the warehouse, write the analytics row. Each addition is another dependency in the one place that can least afford them.
6
6
 
7
- EventRail is not ready for production use yet. The public API is still being implemented and validated.
7
+ There are two ways for one part of an application to reach another. The synchronous one — call a method, get an answer — Ruby hands you for free, and it is the one above: the caller names the callee. The other inverts it. One part announces a fact, and whoever cares subscribes. The dependency does not vanish: a subscriber still names the event, and the event belongs to the code that publishes it. But it points the other way, and it is far narrower: a dependency on something that happened, not on how the other side works. What matters is the side that accumulates. Code that invokes every interested party learns a new name for each one; code that announces a fact learns nothing.
8
+
9
+ Rails ships no way to express the second. `ActiveSupport::Notifications` is in-process and untyped, Active Record callbacks are the coupling you were trying to escape, and an event store asks you to adopt a storage model in order to send one message across a boundary.
10
+
11
+ EventRail is that second interface. An event is an immutable, versioned value. Every subscriber is an ordinary Active Job that keeps its own queue, retry policy, and concurrency limits, and delivery goes through the queue adapter you already run. The publisher never names a subscriber. There is no transport, no registration API, and no runtime of its own.
12
+
13
+ EventRail is pre-1.0: the public API may change in a minor release, and every change is documented in the [CHANGELOG](CHANGELOG.md). Delivery semantics, safety limits, and the notification contract are settled and documented below.
14
+
15
+ ## When to use EventRail
16
+
17
+ EventRail is narrow on purpose: durable fanout across a boundary inside one application, where each subscriber retries independently and the same fact keeps the same identity across every retry and every hop. Reaching for an event store to get that means adopting its storage model too: entities as streams, state rebuilt from history. That is a large commitment for what is, at the boundary, one message. If you want that commitment, or something else entirely, these fit better.
18
+
19
+ | If you need | Use | Because |
20
+ | --- | --- | --- |
21
+ | An event log: audit history, replay, rebuilding read models | [Rails Event Store](https://railseventstore.org) | It is a store. Events persist in the same transaction as the state that produced them, which also closes the dual-write gap below. |
22
+ | Handlers that run inside the same request | [downstream](https://github.com/palkan/downstream) | Delivery is synchronous by default, async opt-in per subscriber. |
23
+ | In-process decoupling and nothing more | `ActiveSupport::Notifications` | Already in your application. No gem, no contract, no queue. |
24
+ | Events to be the system of record, not a message | [Sequent](https://www.sequent.io) | A full event-sourcing framework: commands, aggregates, projectors. |
25
+
26
+ **Why not call `perform_later` three times?** That is already durable fanout, and for three jobs in one method it is the right answer. EventRail earns its place when the publisher should not know its subscribers, when the payload needs a versioned contract that outlives a worker running older code, and when a retry must not produce a second copy of a fact already recorded.
27
+
28
+ ### What it gives you
29
+
30
+ - **Stable identity.** An event that declares what fact it is has one ID wherever and however often it is published, so a consumer recognises it arriving again; any other event keeps its ID across retries and redeliveries. Idempotency holds across hops, not only across one subscriber's retries.
31
+ - **Subscribers that are the jobs**, each keeping its own queue, retry policy, and concurrency limits rather than sharing the single wrapper job other gems route subscribers through.
32
+ - **Boot-time validation**: a wrong `perform` arity, a missing `EventRail::JobContext`, or a late declaration fails the boot that introduced it rather than the first publication in production.
33
+ - **A typed boundary**: a `Date`, a `BigDecimal`, a record, or a GlobalID is refused rather than serialized into something a worker cannot restore.
34
+ - **Your own layout**: discovery matches a path suffix against the autoload roots Rails already has, covering engines and a packwerk-style `packs/billing/app/jobs` with no configuration.
35
+ - **No Active Record**, table, migration, or initializer.
36
+
37
+ ### What it does not do
38
+
39
+ - **No event log and no synchronous handlers.** No history, replay, read-model rebuild, or browser UI, and every subscriber crosses the queue. A subscriber can append every event to a store of your own, keyed on `(source, id)`, but that store is a downstream log, not event sourcing: publication follows the commit, with no ordering or expected-version guarantees, so the store is a copy of what happened rather than its source of truth.
40
+ - **No outbox, and so a dual-write gap.** Publication is refused inside a transaction, so a publisher commits and then publishes, and a process that dies between the two loses the event. Publishing from a job makes that recoverable, because the retry republishes under the same identity (see [Replay-safe publishers](#replay-safe-publishers)); a controller action has no retry to repeat the publication, though an event with declared identity keeps its ID when anything does. An application that wants recovery beyond that records the intent to publish in the same transaction as its data, and a sweeper or a relay job publishes it; an event with [declared identity](#identity-occurrence-time-and-source) derives the same ID however many times it is republished, so consumers see a repetition rather than a new fact.
41
+ - **Discovery is enforced, not advisory.** A subscriber outside a [discovery root](#where-discovery-looks) fails the boot rather than being quietly ignored: a silent delivery bug traded for a loud startup error, at the price of a layout rule.
8
42
 
9
43
  ## Requirements
10
44
 
@@ -20,7 +54,7 @@ EventRail depends on Active Support, Active Model, Active Job, Railties, and Zei
20
54
  gem "event_rail"
21
55
  ```
22
56
 
23
- Then run `bundle install`. There is no initializer to generate and nothing you have to configure: EventRail initializes itself through a Railtie, every safety limit is a fixed documented constant, and the one option it does expose -- [where it looks for events and subscribers](#where-discovery-looks) -- has a default that most applications never change.
57
+ Then run `bundle install`. There is no initializer to generate and nothing you have to configure: EventRail initializes itself through a Railtie, every safety limit is a fixed documented constant, and the one option it does expose, [where it looks for events and subscribers](#where-discovery-looks), has a default that most applications never change.
24
58
 
25
59
  ## Job integration
26
60
 
@@ -88,13 +122,13 @@ event.metadata # immutable metadata; not yet stamped
88
122
 
89
123
  `attributes` is Active Model's own meaning and is what application code reads. `data` is the single written form that both the queue representation and the public envelope use, so a `Date`, a `BigDecimal`, or a nested Ruby object never reaches a queue adapter or a codec.
90
124
 
91
- Casting refuses to discard information rather than substituting a plausible value. `"abc"` is not `0`, `true` is not `"t"`, a timestamp needs an explicit offset, and a date attribute will not silently drop a time of day. An untyped attribute accepts only recursively JSON-like values — strings, integers, finite floats, booleans, `nil`, arrays, string-keyed hashes — bounded in nesting depth and forbidden from using the `_aj_` prefix Active Job reserves in its own argument encoding. Records, GlobalID values, arbitrary Ruby objects, non-finite numbers, undeclared attributes, and callable defaults are rejected.
125
+ Casting refuses to discard information rather than substituting a plausible value. `"abc"` is not `0`, `true` is not `"t"`, a timestamp needs an explicit offset, and a date attribute will not silently drop a time of day. An untyped attribute accepts only recursively JSON-like values: strings, integers, finite floats, booleans, `nil`, arrays, and string-keyed hashes. Those are bounded in nesting depth and forbidden from using the `_aj_` prefix Active Job reserves in its own argument encoding. Records, GlobalID values, arbitrary Ruby objects, non-finite numbers, undeclared attributes, and callable defaults are rejected.
92
126
 
93
127
  Constructed events, nested data, metadata, and every contained value are recursively immutable. Two events of one class with equal payload and equal metadata are equal values, which is what lets `assert_enqueued_with(args: [event])` match a published event.
94
128
 
95
129
  ### Custom attribute types
96
130
 
97
- A custom Active Model type may be used as an attribute type. Rails' `serialize` is documented as producing a value "usable by the database", and a database driver accepts a `Date` or a `BigDecimal` object -- a queue does not, and Active Job does not recurse into a serializer's output, so such a value would reach the adapter raw. Including `EventRail::PortableType` narrows the promise to a JSON primitive, array, or string-keyed hash, and `portable_examples` makes the promise checkable:
131
+ A custom Active Model type may be used as an attribute type. Rails' `serialize` is documented as producing a value "usable by the database", and a database driver accepts a `Date` or a `BigDecimal` object. A queue does not, and Active Job does not recurse into a serializer's output, so such a value would reach the adapter raw. Including `EventRail::PortableType` narrows the promise to a JSON primitive, array, or string-keyed hash, and `portable_examples` makes the promise checkable:
98
132
 
99
133
  ```ruby
100
134
  module Docs
@@ -124,17 +158,37 @@ Docs::ParcelShipped.new(parcel_id: "P-1", weight: 2500).data
124
158
  # => { "parcel_id" => "P-1", "weight" => 2500 }
125
159
  ```
126
160
 
127
- Every example is round-tripped through JSON when the attribute is declared, so a type that cannot hold up fails at class definition rather than at the first enqueue. A type whose cast value is already a portable scalar -- a plain `:string` subclass, say -- needs none of this.
161
+ Every example is round-tripped through JSON when the attribute is declared, so a type that cannot hold up fails at class definition rather than at the first enqueue. A type whose cast value is already a portable scalar (a plain `:string` subclass, say) needs none of this.
128
162
 
129
163
  ## Publishing
130
164
 
131
165
  ```ruby
132
166
  module Docs
133
- class OnOrderPlacedJob < ApplicationJob
134
- subscribes_to OrderPlaced
167
+ # app/jobs/billing/charge_card_job.rb
168
+ module Billing
169
+ class ChargeCardJob < ApplicationJob
170
+ queue_as :payments
171
+ retry_on Timeout::Error, attempts: 10
135
172
 
136
- def perform(event)
137
- Rails.logger.info("charging #{event.order_id} idempotently on #{event.id}")
173
+ subscribes_to OrderPlaced
174
+
175
+ def perform(event)
176
+ Rails.logger.info("charging #{event.order_id} idempotently on #{event.id}")
177
+ end
178
+ end
179
+ end
180
+
181
+ # packs/analytics/app/jobs/analytics/record_order_job.rb
182
+ module Analytics
183
+ class RecordOrderJob < ApplicationJob
184
+ queue_as :low
185
+ discard_on ActiveJob::DeserializationError
186
+
187
+ subscribes_to OrderPlaced
188
+
189
+ def perform(event)
190
+ Rails.logger.info("recording #{event.order_id}")
191
+ end
138
192
  end
139
193
  end
140
194
  end
@@ -148,6 +202,8 @@ publication.accepted_subscribers # subscribers whose enqueue was accepted
148
202
  publication.skipped_subscribers # subscribers whose own enqueue callback declined
149
203
  ```
150
204
 
205
+ Billing and analytics do not know about each other, and the publishing code names neither. Adding a third subscriber is adding a file; the code that publishes `OrderPlaced` never changes. Each one keeps its own queue and its own failure policy, because each one is just a job. A payment that retries for ten attempts and an analytics write that is discarded when its event can no longer be loaded are the same fanout, configured separately.
206
+
151
207
  Subscribers are discovered from the conventional `app/events` and `app/jobs` roots of the host application and every engine, during Rails preparation. There is no registration API, no initializer, and no registry to query.
152
208
 
153
209
  ### Where discovery looks
@@ -160,21 +216,23 @@ config.event_rail.roots << "app/subscribers" # default: %w[app/events app/jobs
160
216
 
161
217
  Each entry is matched as a path suffix against the autoload roots Rails already has, so one entry covers the host application, every engine, and a packwerk-style `packs/billing/app/jobs` without naming any of them. Appending is additive: the defaults stay in effect.
162
218
 
163
- Two things to know before reaching for it. A configured root is eager-loaded during preparation in **every** environment, so naming something broad like `app/models` loads that directory for the application and every engine on each boot and each reload -- moving the file is usually the better fix. And a root you name that is not an autoload root of the application or any engine fails preparation with `EventRail::ConfigurationError`, so a typo is a boot error rather than a directory that silently discovers nothing. A *default* root the application has not created is simply skipped, so a fresh application with no `app/events` directory boots normally.
219
+ Two things to know before reaching for it. A configured root is eager-loaded during preparation in **every** environment, so naming something broad like `app/models` loads that directory for the application and every engine on each boot and each reload; moving the file is usually the better fix. And a root you name that is not an autoload root of the application or any engine fails preparation with `EventRail::ConfigurationError`, so a typo is a boot error rather than a directory that silently discovers nothing. A *default* root the application has not created is simply skipped, so a fresh application with no `app/events` directory boots normally.
164
220
 
165
- `subscribes_to` is exact and not inherited: a subclass of a subscriber is a different job and receives nothing. A subscriber must define its own `perform` taking exactly one required positional event parameter, must not have subclasses, and must include `EventRail::JobContext`. Each of those is checked during preparation, so a mistake fails the boot that introduced it rather than the first publication.
221
+ `subscribes_to` is exact and not inherited on either side. A subclass of a subscriber is a different job and receives nothing. A subscriber must define its own `perform` taking exactly one required positional event parameter, must not have subclasses, and must include `EventRail::JobContext`. Each of those is checked during preparation, so a mistake fails the boot that introduced it rather than the first publication. The event class a subscription names must declare a contract of its own. Neither `event_type` nor `version` is inherited, so a shared abstract base and a subclass that adds nothing of its own are both refused, and refused at the declaration itself rather than during preparation, because the class is already loaded by the time the macro reads it. A class that declares one of the two and not the other is a broken contract rather than a base, and still fails preparation naming the event class.
166
222
 
167
- A subscriber must also be reachable by name, because Active Job enqueues a job by name. Declaring a subscription on a class that has none raises immediately, so `Foo.const_set(:Bar, Class.new(ApplicationJob) { subscribes_to Baz })` is not supported -- name the class first. The check is on the name the class carries, not on whether a constant resolves to it, so a class with a name nothing resolves to is still dropped from the registry without comment; that is a deliberate limit, not an oversight.
223
+ A subscriber must also be reachable by name, because Active Job enqueues a job by name. Declaring a subscription on a class that has none raises immediately, so `Foo.const_set(:Bar, Class.new(ApplicationJob) { subscribes_to Baz })` is not supported; name the class first. The check is on the name the class carries, not on whether a constant resolves to it, so a class with a name nothing resolves to is still dropped from the registry without comment; that is a deliberate limit, not an oversight.
168
224
 
169
225
  One rule covers both halves of discovery: **an event contract or a subscription declared after preparation has sealed the registry raises**, naming the file and line and the two ways to fix it. EventRail refuses to run with a subscriber that would receive nothing, or with an event class a worker could not reconstruct from the queue.
170
226
 
171
227
  Adding the directory to `config.eager_load_paths` is never enough on its own, and the reason is Rails' own ordering: prepare callbacks run *before* `eager_load!`, so a reloadable class outside a discovery root cannot be loaded in time in any environment. Either move the file under a discovery root, or [add its root](#where-discovery-looks).
172
228
 
173
- Two exceptions. Code that is not reloadable at all -- a gem, or a file plainly required from an initializer -- ran its declaration before preparation, so it is already registered; such a file has to bring its own event class and job base, because initializers run before the main autoloader exists. And a test suite loads after preparation by definition, which is what [`EventRail::TestHelper`](#tests-that-need-their-own-fixtures) is for.
229
+ Two exceptions. Code that is not reloadable at all, such as a gem or a file plainly required from an initializer, ran its declaration before preparation, so it is already registered; such a file has to bring its own event class and job base, because initializers run before the main autoloader exists. And a test suite loads after preparation by definition, which is what [`EventRail::TestHelper`](#tests-that-need-their-own-fixtures) is for.
174
230
 
175
231
  ### At-least-once delivery, and what that means for subscribers
176
232
 
177
- Fanout is individual `perform_later` calls, one per subscriber. Jobs already accepted are never rolled back when a later subscriber's enqueue fails, and the retry repeats complete fanout under the same event ID. A subscriber must therefore be idempotent, and `event.id` is the key to be idempotent on: it is stable across retries of the publishing execution and across redeliveries of a subscriber's own cause.
233
+ Fanout is individual `perform_later` calls, one per subscriber. Jobs already accepted are never rolled back when a later subscriber's enqueue fails, and the retry repeats complete fanout under the same event ID. A subscriber must therefore be idempotent. An event's full identity is `(source, id)`, so a subscriber records `(subscriber, event.source, event.id)` in the same transaction as its effect and does nothing when that record already exists; a business-key constraint (one refund per order) remains worth having beside it. The ID is stable across retries of the publishing execution and redeliveries of a subscriber's own cause, and, for an event with [declared identity](#identity-occurrence-time-and-source), across every publication of the fact.
234
+
235
+ Two copies of one fact, published by different executions, can differ: a later `occurred_at`, another causation, or a payload read from state that has since changed. EventRail stores nothing and orders nothing, so it cannot say which copy is authoritative; each subscriber decides, and publishers keep copies equal by building events from persisted data.
178
236
 
179
237
  No ordering is promised, between subscribers or between events.
180
238
 
@@ -184,7 +242,7 @@ Retries, backoff, discarding, and dead-letter handling stay where they already a
184
242
 
185
243
  Don't. Publication raises `EventRail::TransactionalPublicationError` when a transaction is open, in every environment, and the fix is to publish after the transaction commits.
186
244
 
187
- Both queue deferral settings are wrong inside a transaction, in opposite directions. With `enqueue_after_transaction_commit` on, the enqueue is deferred past the point where its failure can be reported, so a publication that silently enqueued nothing looks successful. With it off, the enqueue announces a fact that a rollback then contradicts. The check is on the open transaction itself, so it does not depend on the setting — or on Active Record being present at all.
245
+ Both queue deferral settings are wrong inside a transaction, in opposite directions. With `enqueue_after_transaction_commit` on, the enqueue is deferred past the point where its failure can be reported, so a publication that silently enqueued nothing looks successful. With it off, the enqueue announces a fact that a rollback then contradicts. The check is on the open transaction itself, so it does not depend on the setting, or on Active Record being present at all.
188
246
 
189
247
  ### Replay-safe publishers
190
248
 
@@ -205,22 +263,113 @@ Guarding publication behind "did I already transition?" is the failure mode to a
205
263
 
206
264
  ## Identity, occurrence time, and source
207
265
 
208
- Inside an opted-in job, an event's ID is derived from a permanent EventRail namespace and the resolved source, executing job class, execution scope, event type, version, and logical identity. The execution scope is the same value the event records as its causation: a regular job's own ID, or, for a subscriber, the ID of the event it is handling. That is what makes identity survive more than one hop — a follow-up event published while handling a redelivered cause derives the ID it derived the first time.
266
+ An event's ID says which fact it is, so a consumer can recognise the same fact arriving again. It is chosen by one of three rules, in order:
267
+
268
+ 1. **An event that already carries an ID** — one reconstructed from an envelope — keeps it, with its source and its lineage.
269
+ 2. **An event with declared identity** — its class's `identity_by` attributes, or an `identity:` passed to `publish` — derives its ID from its source, its event type and that identity, and from nothing else: not the job that published it, not the execution, not the schema version. The same fact has the same ID wherever and however often it is published, inside a job or in a controller, and in version 1 or version 2.
270
+ 3. **Any other event** derives its ID from the execution publishing it: stable across that job's retries, and, for an event a subscriber publishes, across redeliveries of the event it handles. A separate job publishing it again gets a new ID. Outside a job it gets a random, time-ordered ID.
209
271
 
210
- Logical identity is chosen in this order: an explicit event ID (an inbound external event), an explicit `key:` passed to `publish`, the class's `identity_by` attributes, or a singleton marker for the first publication of that type in the execution. Provide an explicit key when neither declared identity nor the singleton default can tell two legitimate publications apart:
272
+ **Identity names the occurrence, not the thing it is about.** This is the rule to get right, because getting it wrong fails silently. `PaymentCaptured identity_by :reference` is right when a payment is captured once. `PriceChanged identity_by :product_id` is wrong: every price change of that product would carry one ID, and consumers would drop all but the first, with no error anywhere. A fact that can happen again for the same thing keys on what distinguishes each occurrence — a persisted change ID, a revision, an import row — never on a hash of the payload or a loop index:
211
273
 
212
274
  ```ruby
213
- EventRail.publish(
214
- Docs::OrderPlaced.new(order_id: "A-1003", total: "5.00", placed_at: Time.now.utc.iso8601),
215
- key: "adjustment-7"
216
- )
217
- ```
275
+ module Docs
276
+ module Inventory
277
+ # One base class per producer, so its source is declared once.
278
+ class Event < EventRail::Event
279
+ default_source "acme.inventory"
280
+ end
281
+
282
+ # Stock is adjusted many times; each adjustment is one fact.
283
+ class StockAdjusted < Event
284
+ event_type "docs.stock_adjusted"
285
+ version 1
286
+ identity_by :adjustment_id
287
+
288
+ attribute :adjustment_id, :string
289
+ attribute :sku, :string
290
+ attribute :quantity, :integer
291
+ end
292
+
293
+ class StockCounted < Event
294
+ event_type "docs.stock_counted"
295
+ version 1
296
+
297
+ attribute :sku, :string
298
+ attribute :counted, :integer
299
+ end
300
+ end
301
+ end
218
302
 
219
- Changing `identity_by`, changing `default_source`, or renaming a subscriber class all change the identities that derive from them, so each is a breaking change. Outside a job execution, events receive random IDs.
303
+ first = EventRail.publish(Docs::Inventory::StockAdjusted.new(adjustment_id: "adj-7", sku: "MUG", quantity: -2)).event
304
+ again = EventRail.publish(Docs::Inventory::StockAdjusted.new(adjustment_id: "adj-7", sku: "MUG", quantity: -2)).event
305
+ raise "one adjustment, one ID" unless first.id == again.id
220
306
 
221
- `occurred_at` is the logical publication time: an explicit timezone-aware value is preserved as the same instant, and otherwise it is the start of the current execution, stable across that execution's retries and never inherited from a cause. Use a persisted domain timestamp when business occurrence time matters. Stored times are UTC at microsecond precision.
307
+ # A class without identity_by can name the fact at the call site instead.
308
+ EventRail.publish(Docs::Inventory::StockCounted.new(sku: "MUG", counted: 38), identity: "count-2026-09-01-MUG")
309
+ ```
222
310
 
223
- `source` identifies a logical producer — not an environment, queue, topic, cluster, or deployment. Any bounded non-empty string is accepted; a stable namespaced value such as `acme.orders` is recommended, and EventRail does no URI parsing.
311
+ Beside that rule:
312
+
313
+ - **`identity:` names a fact across every job**, so it must be unique per source and event type. It is refused for a class that declares `identity_by`, so the two cannot disagree, and it derives the same ID as a single string `identity_by` attribute with the same value, so a class can move from one to the other without changing IDs; an integer `42` and the string `"42"` are different identities.
314
+ - **A tenant belongs in the identity** (or the source) when tenants number their facts independently; an extension is not part of an event's identity.
315
+ - **Never use personal data as identity.** The derivation is a hash, and a hash over an email address or a name is reversible by guessing.
316
+ - **Events that cross an application boundary should declare identity.** An execution-derived ID changes when the publishing code moves, a declared one does not.
317
+ - **Changing `source`, `event_type` or declared identity changes IDs**, so each is a breaking change. Renaming a subscriber class changes the IDs of undeclared events it publishes.
318
+
319
+ Upgrading from a release where `publish` took `key:`: that key was scoped to one job and is now `identity:`, scoped to every job. Before renaming it, and for every `identity_by`, check that the value names one occurrence per source and event type; a value that used to be safe inside one job, such as a line number, now merges facts.
320
+
321
+ `occurred_at` is the logical publication time: an explicit timezone-aware value is preserved as the same instant, and otherwise it is the start of the current execution, stable across that execution's retries and never inherited from a cause. Use a persisted domain timestamp when business occurrence time matters, and build an event from persisted data, so that two publications of one fact carry the same payload. Stored times are UTC at microsecond precision.
322
+
323
+ `source` names the logical producer of an event — `acme.orders` — not an environment, queue, topic, cluster, or deployment. An event ID is unique within its source, so `(source, id)` is an event's full identity, as in CloudEvents, and the source is half of every declared ID. Choose it for the producer rather than the deployment, and a module can move to another application without its events changing IDs. Declare it once, with `default_source` on a module's base event class; an event built on behalf of another producer can be published with `source:` (see [Crossing a network boundary](#crossing-a-network-boundary)). Any bounded non-empty string is accepted, and EventRail does no URI parsing. A follow-up event records its cause's ID as its causation, not its cause's source.
324
+
325
+ ### How an ID is derived
326
+
327
+ Other implementations can derive the same IDs. An ID is a UUIDv5 ([RFC 9562](https://www.rfc-editor.org/rfc/rfc9562.html)) under the namespace `21fedac0-42c6-443f-b01c-6980aab52f32`, over a name that concatenates encoded components:
328
+
329
+ - **Declared identity:** `"fact"`, source, event type, and the identity as a list — the `identity_by` values in declaration order, or the one `identity:` value.
330
+ - **Execution:** `"execution"`, source, job class name, scope, event type, version, and a singleton marker. The scope is a regular job's Active Job ID, or, for a subscriber, the list `[source, id]` of the event it handles.
331
+
332
+ Each component is encoded as a tag, its byte length in decimal, a colon, and its bytes:
333
+
334
+ | Value | Tag | Bytes |
335
+ | --- | --- | --- |
336
+ | String | `s` | its UTF-8 bytes, without Unicode normalization; invalid UTF-8 is refused |
337
+ | Integer | `i` | decimal, with a leading `-` when negative |
338
+ | Decimal | `d` | plain notation: an optional `-`, the integer digits, `.`, and the fractional digits with trailing zeros removed but at least one kept (`12.5`, `100.0`, `0.0`, `-0.0`) |
339
+ | Float | `f` | 17 significant digits with trailing zeros removed, as C's `printf("%.17g")` writes them: positional notation, or exponent notation (`1e+22`, `9.9999999999999995e-08`) when the decimal exponent is below -4 or at least 17, the exponent with a sign and at least two digits; `-0` stays distinct from `0`; non-finite is refused |
340
+ | Boolean | `b` | `true` or `false` |
341
+ | Date | `D` | ISO 8601 (`2026-09-01`) |
342
+ | Timestamp | `T` | converted to UTC, truncated to microseconds, and written as ISO 8601 with six fractional digits (`2026-09-01T10:30:00.123456Z`) |
343
+ | List | `L` | the item count in decimal, a colon, then each item encoded |
344
+ | Singleton marker | `*` | empty |
345
+
346
+ These vectors are part of the contract, and the test suite asserts them. For declared identity the source is `acme.orders` and the event type `orders.order_placed`; for execution, the job class is `Orders::PlaceOrderJob` and the version `1`:
347
+
348
+ | Vector | Encoded name | ID |
349
+ | --- | --- | --- |
350
+ | a string | `s4:facts11:acme.orderss19:orders.order_placedL8:1:s3:o-1` | `13b2cda9-e1f7-57b6-ba1d-bb12a7553655` |
351
+ | a non-ASCII string | `s4:facts11:acme.orderss19:orders.order_placedL12:1:s7:Zürich` | `8ad8bbf8-3e10-5162-97da-53cb6752973f` |
352
+ | an integer | `s4:facts11:acme.orderss19:orders.order_placedL7:1:i2:42` | `43230f76-4e80-55d0-bfaf-beab63ac76b9` |
353
+ | a negative integer | `s4:facts11:acme.orderss19:orders.order_placedL7:1:i2:-7` | `4feffb91-0dec-5a7b-a5a0-67968abbe971` |
354
+ | true | `s4:facts11:acme.orderss19:orders.order_placedL9:1:b4:true` | `b1a75e27-6aa2-5f7e-9159-fb429bddc17f` |
355
+ | false | `s4:facts11:acme.orderss19:orders.order_placedL10:1:b5:false` | `631f1712-795b-59fa-b497-103b5f951306` |
356
+ | a decimal | `s4:facts11:acme.orderss19:orders.order_placedL9:1:d4:12.5` | `fc8e4be7-4912-5702-b22b-92d63d710e01` |
357
+ | an integral decimal | `s4:facts11:acme.orderss19:orders.order_placedL10:1:d5:100.0` | `4ab51db7-7864-591d-8e1a-b1f5d9567169` |
358
+ | a zero decimal | `s4:facts11:acme.orderss19:orders.order_placedL8:1:d3:0.0` | `0a066901-77e5-55e3-99c4-a973b5018500` |
359
+ | a negative zero decimal | `s4:facts11:acme.orderss19:orders.order_placedL9:1:d4:-0.0` | `784aef76-53c9-517c-ba3f-732053cd537d` |
360
+ | a float | `s4:facts11:acme.orderss19:orders.order_placedL25:1:f19:0.10000000000000001` | `cc223477-2cb1-5a6d-8d0f-6b25aaf989b7` |
361
+ | an integral float | `s4:facts11:acme.orderss19:orders.order_placedL6:1:f1:1` | `901836d7-5477-5780-8e6b-dd39ed49d272` |
362
+ | a large float | `s4:facts11:acme.orderss19:orders.order_placedL10:1:f5:1e+22` | `d4e88ebb-fdaa-5cc4-9dbe-c576abae6f36` |
363
+ | a small float | `s4:facts11:acme.orderss19:orders.order_placedL28:1:f22:9.9999999999999995e-08` | `83b232ee-36bc-53dc-a455-5a9e31a003a2` |
364
+ | negative zero | `s4:facts11:acme.orderss19:orders.order_placedL7:1:f2:-0` | `7cab07fc-874c-59ff-a946-76d00585d229` |
365
+ | a date | `s4:facts11:acme.orderss19:orders.order_placedL16:1:D10:2026-09-01` | `84aaf8b3-7dc1-5446-880d-a002bfe8cab6` |
366
+ | a timestamp | `s4:facts11:acme.orderss19:orders.order_placedL33:1:T27:2026-09-01T10:30:00.123456Z` | `874e797f-7b0c-5742-b8e2-6b05ae3f738a` |
367
+ | a timestamp with an offset and sub-microsecond digits | `s4:facts11:acme.orderss19:orders.order_placedL33:1:T27:2026-09-01T10:30:01.234567Z` | `7fcd39f5-1a94-5ad2-b899-22ac849d510d` |
368
+ | several values | `s4:facts11:acme.orderss19:orders.order_placedL12:2:s3:o-1i1:2` | `fe61e6d0-cbb6-5db0-96a4-1fd78d86dab9` |
369
+ | a regular job's scope | `s9:executions11:acme.orderss21:Orders::PlaceOrderJobs5:job-1s19:orders.order_placedi1:1*0:` | `e3c5921d-a9bf-5816-b9a7-f36f426cb998` |
370
+ | a subscriber's scope | `s9:executions11:acme.orderss21:Orders::PlaceOrderJobL59:2:s13:acme.paymentss36:0b1e2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4ds19:orders.order_placedi1:1*0:` | `a3a31754-e302-5c23-a4a7-ea353accfeb6` |
371
+
372
+ Random IDs, for events without declared identity published outside a job, are UUIDv7, so a log indexes them in time order, to the millisecond, and the version digit tells a random ID (7) from a derived one (5).
224
373
 
225
374
  ## Logical context
226
375
 
@@ -234,7 +383,7 @@ end
234
383
 
235
384
  A nested scope inherits lineage and may add extensions or repeat identical values; replacing an inherited identifier, origin time, or extension value fails rather than rewriting the lineage of a flow already in progress.
236
385
 
237
- Context extensions are durable baggage: opted-in child jobs carry them, and published events merge them with event-local extensions, rejecting conflicting values. They are not the place for domain data — that belongs in declared attributes.
386
+ Context extensions are durable baggage: opted-in child jobs carry them, and published events merge them with event-local extensions, rejecting conflicting values. They are not the place for domain data; that belongs in declared attributes.
238
387
 
239
388
  ### Reading context
240
389
 
@@ -268,9 +417,9 @@ class SendReceiptJob < ApplicationJob
268
417
  end
269
418
  ```
270
419
 
271
- Reach for `Current` in a subscriber only to hand lineage to something that does not take the event -- a log line, an outbound request header, an APM tag.
420
+ Reach for `Current` in a subscriber only to hand lineage to something that does not take the event: a log line, an outbound request header, an APM tag.
272
421
 
273
- Lineage is isolated per unit of concurrent execution through `ActiveSupport::IsolatedExecutionState`, which defaults to thread isolation. **A host running fibers -- a fiber-per-request server, or a worker that runs jobs on fibers -- must set `config.active_support.isolation_level = :fiber`**, or lineage will be shared between concurrent fibers. That setting is Rails-wide rather than EventRail's, and it governs both `EventRail::Current` and EventRail's own publication state. EventRail documents the requirement rather than claiming an isolation it cannot provide.
422
+ Lineage is isolated per unit of concurrent execution through `ActiveSupport::IsolatedExecutionState`, which defaults to thread isolation. **A host running fibers, whether a fiber-per-request server or a worker that runs jobs on fibers, must set `config.active_support.isolation_level = :fiber`**, or lineage will be shared between concurrent fibers. That setting is Rails-wide rather than EventRail's, and it governs both `EventRail::Current` and EventRail's own publication state. EventRail documents the requirement rather than claiming an isolation it cannot provide.
274
423
 
275
424
  Leaving it wrong fails loudly rather than silently: under thread isolation two concurrent fibers each establishing context raise `InvalidContext`, because the second inherits the first's identifiers and replacing them is refused. The rule that stops lineage being rewritten mid-flow doubles as a misconfiguration detector.
276
425
 
@@ -280,7 +429,9 @@ OpenTelemetry is not required. When standard Active Job instrumentation is insta
280
429
 
281
430
  ## Versioning events
282
431
 
283
- `event_type` and `version` form the durable, language-neutral contract, independent of Ruby class names. A compatible optional addition may keep the same version: an older worker preserves the unknown field as opaque data with no reader and includes it again on export. Removing, renaming, requiring, or retyping a field, changing its meaning, or changing identity requires a new version and a distinct class, and the old class stays registered while messages that reference it can still arrive.
432
+ `event_type` and `version` form the durable, language-neutral contract, independent of Ruby class names. A compatible optional addition may keep the same version: an older worker preserves the unknown field as opaque data with no reader and includes it again on export. Removing, renaming, requiring, or retyping a field, or changing its meaning requires a new version and a distinct class, and the old class stays registered while messages that reference it can still arrive.
433
+
434
+ A new version is a new representation of the same fact, so every version of a type keeps the same declared identity values, in the same order (an attribute may be renamed), and a fact with declared identity carries one ID in every version; an ID derived from the execution includes the version. A consumer subscribed to both versions while old messages drain deduplicates them on that ID. Keeping both representations is a different need: an archive stores `(source, id, version)`, and it must receive both, because a broker or inbox that deduplicates on `(source, id)` before routing by version passes only one of them on. Changing what an event identifies is not a new version but a new event type.
284
435
 
285
436
  EventRail provides no upcasting and no schema registry.
286
437
 
@@ -288,7 +439,7 @@ EventRail provides no upcasting and no schema registry.
288
439
 
289
440
  | Value | Limit |
290
441
  | --- | ---: |
291
- | Event, correlation, causation, or boundary identifier | 512 bytes |
442
+ | Event, correlation, causation, or boundary identifier, or `identity:` | 512 bytes |
292
443
  | Source | 255 bytes |
293
444
  | Event type | 255 bytes |
294
445
  | Extension entries | 32 |
@@ -307,11 +458,11 @@ Application tests use Active Job's own helpers and nothing from EventRail:
307
458
  # doc:illustrative
308
459
  publication = EventRail.publish(Docs::OrderPlaced.new(order_id: "A-1005", total: "1.00", placed_at: Time.now.utc.iso8601))
309
460
 
310
- assert_enqueued_with(job: Docs::OnOrderPlacedJob, args: [ publication.event ])
461
+ assert_enqueued_with(job: Docs::Billing::ChargeCardJob, args: [ publication.event ])
311
462
  perform_enqueued_jobs
312
463
  ```
313
464
 
314
- `assert_enqueued_with(args:)` works because events are value objects. For observability assertions, subscribe to the notifications below. EventRail ships no assertion library, observer, or contract-test helper -- Active Job's helpers are the whole assertion surface.
465
+ `assert_enqueued_with(args:)` works because events are value objects. For observability assertions, subscribe to the notifications below. EventRail ships no assertion library, observer, or contract-test helper; Active Job's helpers are the whole assertion surface.
315
466
 
316
467
  ### Tests that need their own fixtures
317
468
 
@@ -365,7 +516,7 @@ end
365
516
 
366
517
  The previous registry is restored when the block exits, including when it raises, and nested activations are additive. Activation applies the same validation preparation does, so an abstract fixture fails there rather than at delivery.
367
518
 
368
- Event contracts behave differently from subscribers on purpose: they are registered when the window closes and stay registered, because `assert_enqueued_with` deserializes the job it is comparing and so needs the contract outside any block. A class cannot be unloaded, which has one consequence worth knowing -- **give each fixture a unique `event_type`, and declare it once, at file scope.** Two live classes claiming one type and version fail every later rebuild for the rest of the process, and a declaration inside a test method reopens the same constant and runs the writer again on a sealed registry.
519
+ Event contracts behave differently from subscribers on purpose: they are registered when the window closes and stay registered, because `assert_enqueued_with` deserializes the job it is comparing and so needs the contract outside any block. A class cannot be unloaded, which has one consequence worth knowing: **give each fixture a unique `event_type`, and declare it once, at file scope.** Two live classes claiming one type and version fail every later rebuild for the rest of the process, and a declaration inside a test method reopens the same constant and runs the writer again on a sealed registry.
369
520
 
370
521
  Activation replaces a process-wide registry, so it is not safe under `parallelize(with: :threads)`. Process-based parallelisation, the Rails default, is unaffected.
371
522
 
@@ -402,7 +553,18 @@ event_class = ACCEPTED.fetch([ envelope.event_type, envelope.version ])
402
553
  EventRail.publish(envelope.to_event(event_class))
403
554
  ```
404
555
 
405
- A relayed event keeps its origin's ID, source, occurrence time, correlation, and extensions; the relaying application's own context extensions are not merged into it, and only a missing causation is filled.
556
+ A relayed event keeps its origin's ID, source, occurrence time, correlation, causation (an absent one stays absent), and extensions; the relaying application's own context extensions are not merged into it. Relaying it with a different `source:` is refused rather than re-attributing somebody else's fact.
557
+
558
+ A producer that sends no event ID, a supplier's webhook say, is mapped to a local event built on the producer's behalf. Its declared identity and the producer's source make a retried delivery the same fact:
559
+
560
+ ```ruby
561
+ EventRail.publish(
562
+ Docs::Inventory::StockAdjusted.new(adjustment_id: "delivery-4411", sku: "MUG", quantity: 120),
563
+ source: "supplier.warehouse"
564
+ )
565
+ ```
566
+
567
+ Source is a claim, not a credential. Before relaying, an inbound adapter checks `envelope.source` against the sources that peer may speak for: a declared fact's ID is predictable, so an unchecked envelope claiming a local source could arrive first under a local fact's ID and make consumers drop the genuine one.
406
568
 
407
569
  Loop prevention is application policy: an export policy that refuses to export an event whose source is not the local application stops a relayed event from bouncing back to the system it came from. EventRail has no concept of internal versus external, direction, topic, or transport marker.
408
570
 
@@ -416,7 +578,7 @@ The private Active Job representation and the job context entry each carry their
416
578
  2. Only then deploy a release that writes the new form.
417
579
  3. Remove the old reader only after every queued message in the old form is drained or expired.
418
580
 
419
- Rolling back reverses that: a rollback is safe only to a release that can still read what the newer release wrote. A change to how an individual value is written is a different matter — that encoding is shared with the public envelope, so it is a breaking public change rather than a private format bump.
581
+ Rolling back reverses that: a rollback is safe only to a release that can still read what the newer release wrote. A change to how an individual value is written is a different matter: that encoding is shared with the public envelope, so it is a breaking public change rather than a private format bump.
420
582
 
421
583
  ## Development
422
584
 
@@ -49,7 +49,15 @@ module EventRail
49
49
  inherited[:extensions], extensions, error: InvalidContext
50
50
  )
51
51
 
52
- execution = Internal::Execution.new(scope: resolved_message_id, started_at: resolved_originated_at)
52
+ # A block opened inside a running job stays part of that job's execution: its
53
+ # publications keep the job's identity rules and its duplicate record. Only a
54
+ # block with no job around it is a boundary of its own.
55
+ running = Internal::Execution.current
56
+ execution = if running&.derives_identity?
57
+ running
58
+ else
59
+ Internal::Execution.new(scope: resolved_message_id, started_at: resolved_originated_at)
60
+ end
53
61
 
54
62
  # A nested scope keeps its parent's causation: the message that caused this flow
55
63
  # does not change because the application opened an inner block.
@@ -232,17 +232,19 @@ module EventRail
232
232
  self.class.send(:__event_rail_copy__, self, state: { :@metadata => stamped_metadata })
233
233
  end
234
234
 
235
- # A declared identity attribute that is nil cannot produce a canonical value, so
236
- # the event can never be published. The local constructor is the strict door and
235
+ # A declared identity attribute that is nil cannot produce a canonical value, and
236
+ # one that is empty would make every blank event the same fact, so neither can be
237
+ # published. The local constructor is the strict door and
237
238
  # the only place whose backtrace points at the code that left the field empty.
238
239
  # Trusted reconstruction stays permissive: an external event of this contract may
239
240
  # legitimately omit a field, and it arrives with an identity already assigned.
240
241
  def validate_local_identity!
241
- missing = self.class.identity_by.select { |name| public_send(name).nil? }
242
+ missing = self.class.identity_by.select { |name| public_send(name).then { |value| value.nil? || value == "" } }
242
243
  return if missing.empty?
243
244
 
244
245
  raise InvalidEvent,
245
- "#{self.class} declares #{missing.sort.join(", ")} as logical identity, so it cannot be nil"
246
+ "#{self.class} declares #{missing.sort.join(", ")} as logical identity, so it cannot be nil or empty: " \
247
+ "every event with a blank identity would be one fact"
246
248
  end
247
249
 
248
250
  def unknown_attributes_message(unknown)
@@ -1,38 +1,56 @@
1
1
  require "active_support/core_ext/digest/uuid"
2
+ require "active_support/core_ext/object/blank"
2
3
  require "bigdecimal"
3
4
  require "date"
4
5
 
5
6
  module EventRail
6
7
  module Internal
7
- # Retry-stable publication identity.
8
+ # Publication identity: the ID an event derives, so that the same fact published
9
+ # again derives the same ID and a consumer can collapse the duplicates at-least-once
10
+ # delivery guarantees it will see.
8
11
  #
9
- # The whole point is that the same logical fact, published again by the same
10
- # execution, derives the same event ID -- so a consumer can collapse the
11
- # duplicates at-least-once delivery guarantees it will see. That makes both the
12
- # namespace and the encoding permanent compatibility state: changing either
13
- # silently gives every future publication a different ID for an unchanged fact,
14
- # which is why the golden vectors are asserted rather than merely computed.
12
+ # Two derivations, told apart by a leading tag so their inputs never coincide:
13
+ #
14
+ # fact ["fact", source, event_type, identity_list]
15
+ # for an event with declared identity. Nothing about who published it,
16
+ # when, or in which schema version: one fact, one ID, everywhere.
17
+ # execution ["execution", source, job_class, scope, event_type, version, SINGLETON]
18
+ # for an event without declared identity, published inside a job. Stable
19
+ # across that job's retries and across redeliveries of the event a
20
+ # subscriber handles, and nothing more.
21
+ #
22
+ # The namespace, the tags, the component order and the byte encoding are permanent
23
+ # compatibility state, documented in the README with vectors other implementations
24
+ # can reproduce; changing any of them silently gives every future publication a
25
+ # different ID for an unchanged fact, which is why the vectors are asserted rather
26
+ # than merely computed.
15
27
  module Identity
16
28
  module_function
17
29
 
18
- # Derived from the URL namespace rather than written as a literal, so the input
19
- # that produced it stays visible and a typo cannot masquerade as a deliberate
20
- # value. The golden-vector test pins the result.
21
- NAMESPACE = Digest::UUID.uuid_v5(
22
- Digest::UUID::URL_NAMESPACE, "https://github.com/event_rail/event_rail/identity/v1"
23
- ).freeze
30
+ # A literal, owned by this project and named nowhere else, so no one else's
31
+ # derivation can land in the same space.
32
+ NAMESPACE = "21fedac0-42c6-443f-b01c-6980aab52f32".freeze
24
33
 
25
- # The first publication of an event type in one execution needs no key: there is
26
- # nothing to distinguish it from.
34
+ # Closes the execution rule's name: an undeclared event is identified by the
35
+ # execution and its type alone, which is why one execution publishes it once.
27
36
  SINGLETON = :__event_rail_singleton__
28
37
 
29
- def derive(source:, job_class:, scope:, event_type:, version:, logical_identity:)
30
- Digest::UUID.uuid_v5(
31
- NAMESPACE,
32
- encode([ source, job_class, scope, event_type, version, logical_identity ])
33
- )
38
+ # `identity` is always a list: the declared attributes' values in declaration
39
+ # order, or one explicit publication identity. So an explicit "a" and a single
40
+ # declared attribute whose value is "a" name the same fact.
41
+ def fact(source:, event_type:, identity:)
42
+ raise ArgumentError, "fact identity must be a list; got #{identity.inspect}" unless identity.is_a?(Array)
43
+
44
+ uuid_v5(encode([ "fact", source, event_type, identity ]))
45
+ end
46
+
47
+ def execution(source:, job_class:, scope:, event_type:, version:)
48
+ uuid_v5(encode([ "execution", source, job_class, scope, event_type, version, SINGLETON ]))
34
49
  end
35
50
 
51
+ def uuid_v5(name) = Digest::UUID.uuid_v5(NAMESPACE, name)
52
+ private_class_method :uuid_v5
53
+
36
54
  # Length-delimited and type-tagged. Ruby's own Hash#hash is per-process, inspect
37
55
  # is not a format, and a plain join makes ["ab", "c"] and ["a", "bc"] the same
38
56
  # string. A tag also keeps 1 and "1" apart, so a call site that passes an integer
@@ -44,7 +62,7 @@ module EventRail
44
62
  def encode_component(value)
45
63
  tag, bytes = case value
46
64
  when SINGLETON then [ "*", "" ]
47
- when String then [ "s", value.b ]
65
+ when String then [ "s", utf8_bytes(value) ]
48
66
  when true then [ "b", "true" ]
49
67
  when false then [ "b", "false" ]
50
68
  when Integer then [ "i", value.to_s ]
@@ -67,6 +85,18 @@ module EventRail
67
85
  "#{tag}#{bytes.bytesize}:#{bytes}"
68
86
  end
69
87
 
88
+ # A string is hashed as its UTF-8 bytes, with no Unicode normalization: the same
89
+ # text in another normal form is another identity, so a producer must send the same
90
+ # bytes. Bytes that are not valid UTF-8 have no portable spelling at all.
91
+ def utf8_bytes(value)
92
+ utf8 = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
93
+ reject!(value, "valid UTF-8") unless utf8.valid_encoding?
94
+ utf8.b
95
+ rescue EncodingError
96
+ reject!(value, "valid UTF-8")
97
+ end
98
+ private_class_method :utf8_bytes
99
+
70
100
  # An identity component that is a structure cannot be canonicalized without
71
101
  # choosing an ordering and a separator that would then be permanent, and one
72
102
  # that is null cannot identify anything.
@@ -3,7 +3,7 @@ require "securerandom"
3
3
  module EventRail
4
4
  module Internal
5
5
  # Turns a proposal into a stamped fact: resolves source, selects logical identity,
6
- # derives a retry-stable ID, fixes occurrence time, and installs lineage. Fanout
6
+ # derives its ID, fixes occurrence time, and installs lineage. Fanout
7
7
  # is a separate concern layered on top of this.
8
8
  module Stamping
9
9
  module_function
@@ -14,17 +14,21 @@ module EventRail
14
14
  end
15
15
  end
16
16
 
17
- def prepare(event, key: nil)
17
+ def prepare(event, identity: nil, source: nil)
18
18
  unless event.is_a?(Event)
19
19
  raise InvalidEvent, "#{event.inspect} is not an EventRail::Event"
20
20
  end
21
+ Metadata.validate_source!(source) unless source.nil?
21
22
 
22
- return relay(event, key: key) if event.stamped?
23
+ return relay(event, identity: identity, source: source) if event.stamped?
24
+
25
+ validate_identity!(event, identity)
26
+ source = (source || event.class.default_source)&.dup&.freeze
27
+ raise InvalidMetadata, "source is required to publish #{event.class}" unless source
23
28
 
24
- validate_key!(key)
25
29
  execution = Execution.current
26
- logical_identity = resolve_logical_identity(event, key)
27
- logical_key = [ event.event_type, event.version, Identity.encode_component(logical_identity) ].freeze
30
+ derived_id = derive_id(event, execution, source, declared_identity(event, identity))
31
+ logical_key = record_key(source, event, derived_id)
28
32
 
29
33
  recorded = execution&.derives_identity? ? execution.record(logical_key) : nil
30
34
  if recorded&.succeeded?
@@ -36,8 +40,7 @@ module EventRail
36
40
  extensions = Extensions.merge!(Current.extensions, event.extensions, error: InvalidEvent)
37
41
  detect_retry_mismatch!(event, recorded, extensions) if recorded
38
42
 
39
- source = event.class.default_source
40
- event_id = recorded&.event&.id || derive_id(event, execution, source, logical_identity)
43
+ event_id = recorded&.event&.id || derived_id
41
44
  occurred_at = event.occurred_at || recorded&.event&.occurred_at || default_occurred_at(execution)
42
45
 
43
46
  stamped = event.send(
@@ -62,20 +65,26 @@ module EventRail
62
65
  execution.record!(prepared.logical_key, event: prepared.event, succeeded: true)
63
66
  end
64
67
 
65
- # A relayed fact belongs to its origin. Its ID, source, occurrence time,
66
- # correlation, and extensions are preserved, and the relaying application's own
67
- # context extensions are deliberately not merged in: local baggage is not part of
68
- # somebody else's event. Only a missing causation is filled, because the local
69
- # message genuinely is what caused this relay.
70
- def relay(event, key:)
71
- unless key.nil?
68
+ # A relayed fact belongs to its origin. Its ID, source, occurrence time, lineage
69
+ # -- an absent causation included -- and extensions are preserved, and the relaying
70
+ # application's own context is deliberately not merged in: local baggage is not
71
+ # part of somebody else's event. Two producers may use the same ID, so the source is
72
+ # part of what makes a relay a repetition, and a relay retried with a different
73
+ # payload is refused exactly as a local publication is.
74
+ def relay(event, identity:, source:)
75
+ unless identity.nil?
72
76
  raise PublicationError,
73
77
  "#{event.event_type.inspect} already carries the event ID #{event.id.inspect}, so a publication " \
74
- "key cannot apply to it"
78
+ "identity cannot apply to it"
79
+ end
80
+ if source && source != event.source
81
+ raise InvalidMetadata,
82
+ "#{event.event_type.inspect} #{event.id.inspect} already comes from #{event.source.inspect}; " \
83
+ "publishing it as #{source.inspect} would re-attribute somebody else's fact"
75
84
  end
76
85
 
77
86
  execution = Execution.current
78
- logical_key = [ event.event_type, event.version, Identity.encode_component(event.id) ].freeze
87
+ logical_key = record_key(event.source, event, event.id)
79
88
 
80
89
  if execution&.derives_identity?
81
90
  recorded = execution.record(logical_key)
@@ -84,71 +93,71 @@ module EventRail
84
93
  event_type: event.event_type, version: event.version, event_id: event.id
85
94
  )
86
95
  end
96
+ detect_retry_mismatch!(event, recorded, event.extensions) if recorded
97
+ execution.record!(logical_key, event: event, succeeded: false)
87
98
  end
88
99
 
89
- relayed = if event.causation_id.nil? && Current.message_id
90
- event.send(
91
- :__stamp__,
92
- id: event.id,
93
- source: event.source,
94
- occurred_at: event.occurred_at,
95
- correlation_id: event.correlation_id,
96
- causation_id: Current.message_id,
97
- extensions: event.extensions
98
- )
99
- else
100
- event
101
- end
102
-
103
- execution.record!(logical_key, event: relayed, succeeded: false) if execution&.derives_identity?
104
-
105
- Prepared.new(event: relayed, execution: execution, logical_key: logical_key, relayed: true)
100
+ Prepared.new(event: event, execution: execution, logical_key: logical_key, relayed: true)
106
101
  end
107
102
  private_class_method :relay
108
103
 
109
- # 1. An explicit event ID wins, which is the relay path above.
110
- # 2. An explicit call-site key.
111
- # 3. The event class's declared identity attributes.
112
- # 4. A singleton marker, for the first publication of that type in an execution.
113
- def resolve_logical_identity(event, key)
114
- return key if key
104
+ # One record per published event, whether it was stamped here or arrives stamped:
105
+ # so publishing the stamped result of a failed local publication is a retry of it,
106
+ # and publishing it again after success is a duplicate. Frozen binary copies, so a
107
+ # caller mutating the string it passed cannot move a record, and so a relayed ID is
108
+ # compared as the opaque bytes it is, whatever they hold.
109
+ def record_key(source, event, id)
110
+ [ source.b.freeze, event.event_type, event.version, id.b.freeze ].freeze
111
+ end
112
+ private_class_method :record_key
115
113
 
116
- declared = event.class.identity_by
117
- return Identity::SINGLETON if declared.empty?
114
+ # The fact an event declares itself to be, as a list: an explicit publication
115
+ # identity, or the class's declared identity attributes in declaration order. Nil
116
+ # when it declares none. An explicit identity and a single declared attribute with
117
+ # the same value name the same fact.
118
+ def declared_identity(event, identity)
119
+ return [ identity ] if identity
118
120
 
119
- declared.map { |name| event.public_send(name) }
121
+ declared = event.class.identity_by
122
+ declared.empty? ? nil : declared.map { |name| event.public_send(name) }
120
123
  end
121
- private_class_method :resolve_logical_identity
124
+ private_class_method :declared_identity
122
125
 
123
- # A key spelled as an integer at one call site and as its decimal string at
124
- # another would derive two identities for one fact, and nothing would report it.
125
- def validate_key!(key)
126
- return if key.nil?
126
+ # A publication identity names a fact across every execution, so it is a string --
127
+ # an integer at one call site and its decimal spelling at another would be two
128
+ # facts -- and it cannot contradict an identity the class already declares.
129
+ def validate_identity!(event, identity)
130
+ return if identity.nil?
127
131
 
128
- unless key.is_a?(String) && !key.empty? && key.valid_encoding?
129
- raise PublicationError, "publication key must be a non-empty string; got #{key.inspect}"
132
+ unless event.class.identity_by.empty?
133
+ raise PublicationError,
134
+ "#{event.class} declares #{event.class.identity_by.join(", ")} as its identity, so a publication " \
135
+ "identity cannot apply to it"
136
+ end
137
+ unless identity.is_a?(String) && !identity.empty? && identity.valid_encoding?
138
+ raise PublicationError, "publication identity must be a non-empty string; got #{identity.inspect}"
130
139
  end
131
- if key.bytesize > Limits::MAX_IDENTIFIER_BYTES
132
- raise PublicationError, "publication key exceeds #{Limits::MAX_IDENTIFIER_BYTES} bytes"
140
+ if identity.bytesize > Limits::MAX_IDENTIFIER_BYTES
141
+ raise PublicationError, "publication identity exceeds #{Limits::MAX_IDENTIFIER_BYTES} bytes"
133
142
  end
134
143
  end
135
- private_class_method :validate_key!
136
-
137
- def derive_id(event, execution, source, logical_identity)
138
- return SecureRandom.uuid.freeze unless execution&.derives_identity?
139
-
140
- unless source
141
- raise InvalidMetadata, "source is required to publish #{event.class}"
144
+ private_class_method :validate_identity!
145
+
146
+ # A declared fact's ID depends on nothing but the fact. Without one, an ID derived
147
+ # from the execution is stable across its retries and redeliveries; outside any
148
+ # execution there is nothing stable to derive from, and the ID is random, in time
149
+ # order so a downstream log indexes it well.
150
+ def derive_id(event, execution, source, declared)
151
+ if declared
152
+ Identity.fact(source: source, event_type: event.event_type, identity: declared).freeze
153
+ elsif execution&.derives_identity?
154
+ Identity.execution(
155
+ source: source, job_class: execution.job_class, scope: execution.scope,
156
+ event_type: event.event_type, version: event.version
157
+ ).freeze
158
+ else
159
+ SecureRandom.uuid_v7.freeze
142
160
  end
143
-
144
- Identity.derive(
145
- source: source,
146
- job_class: execution.job_class,
147
- scope: execution.scope,
148
- event_type: event.event_type,
149
- version: event.version,
150
- logical_identity: logical_identity
151
- ).freeze
152
161
  end
153
162
  private_class_method :derive_id
154
163
 
@@ -106,8 +106,11 @@ module EventRail
106
106
  entry["extensions"], error: InvalidContext
107
107
  )
108
108
 
109
+ # The scope an undeclared follow-up's ID derives from. A subscriber's is the event
110
+ # it handles, by source as well as ID, since two producers may use one ID; its
111
+ # logical message and causation stay the event's ID.
109
112
  execution = Internal::Execution.new(
110
- job_class: self.class.name, scope: message_id, started_at: started_at
113
+ job_class: self.class.name, scope: event ? [ event.source, event.id ] : message_id, started_at: started_at
111
114
  )
112
115
 
113
116
  Internal::Context.establish(
@@ -7,6 +7,9 @@ module EventRail
7
7
  #
8
8
  # publication = EventRail.publish(Orders::OrderPlaced.new(order_id: order.id))
9
9
  #
10
+ # `identity:` names the fact for an event whose class declares no `identity_by`;
11
+ # `source:` publishes a locally built event on behalf of another producer.
12
+ #
10
13
  # Individual enqueue calls, not `perform_all_later`. Bulk enqueue skips each job's
11
14
  # own `enqueue` callbacks on several adapters, and those callbacks are exactly where
12
15
  # an application puts uniqueness, concurrency, and feature-flag decisions that this
@@ -15,10 +18,17 @@ module EventRail
15
18
  # Delivery is at-least-once. Jobs already accepted are not rolled back when a later
16
19
  # subscriber's enqueue fails, and the retry repeats complete fanout under the same
17
20
  # event ID, so a subscriber may see the same event more than once.
18
- def publish(event, key: nil)
21
+ def publish(event, identity: nil, source: nil, **unknown)
22
+ if unknown.key?(:key)
23
+ raise ArgumentError,
24
+ "publish's key: is now identity:, and it names a fact across every job rather than within one; " \
25
+ "check the value names one occurrence per source and event type before renaming it"
26
+ end
27
+ raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.keys.map(&:inspect).join(", ")}" if unknown.any?
28
+
19
29
  Internal::Transaction.check!
20
30
 
21
- prepared = Internal::Stamping.prepare(event, key: key)
31
+ prepared = Internal::Stamping.prepare(event, identity: identity, source: source)
22
32
  stamped = prepared.event
23
33
  subscribers = Internal::Registry.subscribers_for(stamped.class)
24
34
 
@@ -16,10 +16,12 @@ module EventRail
16
16
  # missing that inclusion fails application preparation with a precise message
17
17
  # instead of a bare NoMethodError on the macro.
18
18
  module Subscriptions
19
- # Declares the exact event classes this job handles. The relationship is recorded
20
- # for preparation to validate: `perform` is usually defined after this line, and
21
- # whether this class has subclasses is not knowable until everything is loaded, so
22
- # only what can be judged immediately is judged here.
19
+ # Declares the exact event classes this job handles. Only what can be judged
20
+ # immediately is judged here: that each argument is an event class, that it declares an
21
+ # event_type or a version, that it is not a repeat, and that this job has a name. The rest
22
+ # is recorded for preparation to validate, because `perform` is usually defined after
23
+ # this line and whether this class has subclasses is not knowable until everything is
24
+ # loaded.
23
25
  def subscribes_to(*event_classes)
24
26
  if event_classes.empty?
25
27
  raise DeclarationError, "#{self} must name at least one event class to subscribe to"
@@ -32,6 +34,19 @@ module EventRail
32
34
  raise DeclarationError,
33
35
  "#{self} cannot subscribe to #{event_class.inspect}, which is not an EventRail::Event class"
34
36
  end
37
+ # Judged here rather than during preparation because it is already knowable: the
38
+ # constant reference above has loaded the event class, so in any ordinary load order
39
+ # its contract is final. A class that declares neither half is an abstract base, or a
40
+ # subclass that declared none of its own, and either way publication -- which matches
41
+ # subscribers by exact class -- could never reach it.
42
+ unless event_class.concrete?
43
+ raise DeclarationError,
44
+ "#{self} cannot subscribe to #{event_class}, which declares neither event_type nor version and so " \
45
+ "can never be published; the subscription would receive nothing. Neither declaration is inherited, " \
46
+ "so a subclass of a concrete event needs its own. Subscribe to each concrete event class instead, " \
47
+ "or, if #{event_class} declares its contract outside its own class body, make that run before this " \
48
+ "subscription is declared."
49
+ end
35
50
  if own.include?(event_class)
36
51
  raise DeclarationError, "#{self} already declares a subscription to #{event_class}"
37
52
  end
@@ -1,3 +1,3 @@
1
1
  module EventRail
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: event_rail
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Dmitriev
@@ -109,9 +109,9 @@ dependencies:
109
109
  - - "<"
110
110
  - !ruby/object:Gem::Version
111
111
  version: '4'
112
- description: EventRail adds immutable domain events, retry-stable identity, logical
113
- context propagation, and Rails-native durable fanout without replacing Active Job
114
- or its queue adapter.
112
+ description: EventRail adds immutable domain events, stable, fact-derived identity,
113
+ logical context propagation, and Rails-native durable fanout without replacing Active
114
+ Job or its queue adapter.
115
115
  email:
116
116
  - alexey.dmitriev6238@gmail.com
117
117
  executables: []