orfeas_lyra 0.6.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +851 -0
  3. data/README.md +196 -987
  4. data/app/controllers/lyra/application_controller.rb +48 -0
  5. data/app/controllers/lyra/dashboard_controller.rb +0 -1
  6. data/app/views/lyra/dashboard/projections.html.erb +0 -4
  7. data/app/views/lyra/dashboard/verification.html.erb +8 -2
  8. data/app/workflows/es_async_mode_workflow.rb +24 -60
  9. data/app/workflows/es_sync_mode_workflow.rb +25 -50
  10. data/app/workflows/hijack_mode_workflow.rb +18 -33
  11. data/app/workflows/lifecycle_workflow.rb +1 -1
  12. data/app/workflows/monitor_mode_workflow.rb +12 -22
  13. data/config/privacy_policies.rb +2 -1
  14. data/config/routes.rb +4 -2
  15. data/lib/lyra/access_log.rb +121 -0
  16. data/lib/lyra/advisory_lock.rb +45 -0
  17. data/lib/lyra/aggregate.rb +25 -13
  18. data/lib/lyra/associations/event_aware.rb +3 -4
  19. data/lib/lyra/associations/habtm_links.rb +255 -0
  20. data/lib/lyra/bypass_events.rb +208 -0
  21. data/lib/lyra/command.rb +3 -0
  22. data/lib/lyra/command_handler.rb +164 -42
  23. data/lib/lyra/configuration.rb +217 -8
  24. data/lib/lyra/consistency/read_your_writes.rb +74 -23
  25. data/lib/lyra/domain_events.rb +157 -0
  26. data/lib/lyra/dual_view.rb +27 -6
  27. data/lib/lyra/dual_view_sampler.rb +33 -0
  28. data/lib/lyra/encrypted_attributes.rb +101 -0
  29. data/lib/lyra/engine.rb +67 -4
  30. data/lib/lyra/erasure.rb +316 -0
  31. data/lib/lyra/event.rb +120 -2
  32. data/lib/lyra/event_flow.rb +16 -6
  33. data/lib/lyra/event_serializer.rb +45 -0
  34. data/lib/lyra/event_store_adapter.rb +58 -62
  35. data/lib/lyra/genesis.rb +189 -0
  36. data/lib/lyra/id_generator.rb +42 -10
  37. data/lib/lyra/interceptors/association_interceptor.rb +123 -6
  38. data/lib/lyra/interceptors/crud_interceptor.rb +400 -74
  39. data/lib/lyra/interceptors/lazy_reads.rb +46 -0
  40. data/lib/lyra/mode_sync.rb +125 -0
  41. data/lib/lyra/mode_transition.rb +386 -0
  42. data/lib/lyra/model_selection.rb +160 -0
  43. data/lib/lyra/optimistic_locking.rb +88 -0
  44. data/lib/lyra/optional_dependency.rb +24 -0
  45. data/lib/lyra/own_connection.rb +54 -0
  46. data/lib/lyra/privacy/adapters/pam.rb +151 -0
  47. data/lib/lyra/privacy/gdpr_compliance.rb +51 -1
  48. data/lib/lyra/privacy/interface.rb +262 -0
  49. data/lib/lyra/privacy/pii_detector.rb +8 -10
  50. data/lib/lyra/privacy/pii_masker.rb +30 -13
  51. data/lib/lyra/privacy/policy_integration.rb +39 -148
  52. data/lib/lyra/projection.rb +30 -7
  53. data/lib/lyra/projections/association_conditions.rb +81 -0
  54. data/lib/lyra/projections/async_checkpoint.rb +57 -0
  55. data/lib/lyra/projections/async_projection_job.rb +68 -15
  56. data/lib/lyra/projections/cached_joins.rb +192 -0
  57. data/lib/lyra/projections/cached_projection.rb +279 -163
  58. data/lib/lyra/projections/cached_relation.rb +666 -85
  59. data/lib/lyra/projections/deferred_relation.rb +120 -0
  60. data/lib/lyra/projections/event_store_reader.rb +27 -4
  61. data/lib/lyra/projections/lazy_projection.rb +259 -0
  62. data/lib/lyra/projections/model_projection.rb +32 -34
  63. data/lib/lyra/projections/rebuild.rb +170 -0
  64. data/lib/lyra/projections/reference_links.rb +309 -0
  65. data/lib/lyra/purpose_bound_reads.rb +197 -0
  66. data/lib/lyra/repair.rb +114 -0
  67. data/lib/lyra/retention.rb +180 -0
  68. data/lib/lyra/schema/event_class_registrar.rb +2 -1
  69. data/lib/lyra/schema/validator.rb +14 -5
  70. data/lib/lyra/sti_stream_migration.rb +70 -0
  71. data/lib/lyra/strict_data_access.rb +225 -175
  72. data/lib/lyra/temporal.rb +128 -0
  73. data/lib/lyra/verification/bypass_workflow.rb +170 -0
  74. data/lib/lyra/verification/crud_lifecycle_workflow.rb +32 -6
  75. data/lib/lyra/verification/workflow_generator.rb +73 -179
  76. data/lib/lyra/version.rb +1 -1
  77. data/lib/lyra.rb +114 -17
  78. data/lib/tasks/lyra_mode.rake +89 -0
  79. data/lib/tasks/lyra_projections.rake +78 -0
  80. data/lib/tasks/lyra_schema.rake +4 -0
  81. data/lib/tasks/lyra_workflows.rake +28 -27
  82. metadata +39 -11
  83. data/lib/lyra/event_mapper.rb +0 -106
  84. data/lib/tasks/dist.rake +0 -391
  85. data/lib/tasks/gems.rake +0 -185
  86. data/lib/tasks/public_release.rake +0 -351
  87. data/lib/tasks/stats.rake +0 -175
  88. data/lib/tasks/testbed.rake +0 -479
data/CHANGELOG.md CHANGED
@@ -7,6 +7,857 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-10-14
11
+
12
+ Everything since 0.8.0, the version the PAM article describes.
13
+
14
+ ### Added
15
+ - **`config.models` takes namespace patterns, `:all` and a denylist** (`Lyra::ModelSelection`):
16
+ `config.models = %w[Spree::* Order]`, `config.models = :all`,
17
+ `config.models = :all, except: %w[Session]`. Patterns and `:all` enrol only eligible models
18
+ (concrete, named, with a table and a single-column primary key, base classes of an STI
19
+ hierarchy), after eager-loading the application's code. The event store's tables, Lyra's own and
20
+ those of Solid Cache/Queue/Cable, Active Storage, Action Text, Action Mailbox and PaperTrail are
21
+ never enrolled; naming one fails the boot. A pattern matching nothing warns; `:all` outside
22
+ Monitor mode warns. On a Solidus application `:all` enrols 90 models, excludes 28 and leaves 48
23
+ abstract classes and STI subclasses to their bases (`test/model_selection_test.rb`).
24
+ - **`ReadYourWrites.with_guaranteed_read(each_write: true)`**: each write in the block is projected
25
+ as it is made, so reads later in the same block see it (by default the block projects when it
26
+ ends). For code that writes and reads back in one unit of work, as Solidus's checkout does
27
+ (a replay of 2,000 Olist orders through Solidus under ES-Async matched the oracle for 135 orders,
28
+ and for 1,992, as plain ActiveRecord does, with each step in such a block).
29
+ `test/projections/read_your_writes_async_test.rb`. The controller concern takes it per
30
+ controller, inherited by subclasses, so an application is adapted from an initializer:
31
+ `Spree::BaseController.include Lyra::Consistency::ControllerConcern` and
32
+ `Spree::BaseController.lyra_read_your_writes each_write: true`
33
+ (`test/consistency/controller_concern_test.rb`). `docs/API_REFERENCE.md` named the concern
34
+ `ReadYourWrites::ControllerConcern`, which does not exist; it is `Consistency::ControllerConcern`.
35
+ - **`config.lock_aggregate_before_append`** (default `true`): Hijack, ES-Sync and ES-Async lock
36
+ the aggregate before appending. Two writers of one record could append in one order and write the
37
+ row in the other, leaving the row at a value its log does not replay to; seen in the wild, 2
38
+ rounds of 120 with 8 threads on one record, and reproduced deterministically
39
+ (`test/concurrent_same_record_test.rb`). An update or destroy now locks the record's row before
40
+ its event is appended, held to the end of the transaction (assumption L of Theorem 5), in the
41
+ strength the write takes anyway (PostgreSQL `FOR NO KEY UPDATE` for an update, `FOR UPDATE` for
42
+ a destroy), so a write takes the locks plain ActiveRecord takes, in the same order, and adds no
43
+ deadlock. A first version took an advisory lock on the stream before the row lock; it deadlocked
44
+ against a writer that holds the row first (`record.lock!`, `with_lock`) and then updates it,
45
+ where plain ActiveRecord only makes the update wait. The advisory lock remains for a record with
46
+ no row. `test/aggregate_lock_deadlock_test.rb`: writes to several records in one order never
47
+ deadlock, opposite orders deadlock exactly as plain ActiveRecord does, a row lock held first
48
+ does not deadlock a plain writer. SQLite needs no lock: it serialises writers from the first
49
+ write, which is the append (`test/sqlite_same_record_test.rb`). ES-Async writers take the
50
+ advisory lock on the stream (they do not write the row, the projection job does, so they never
51
+ wait for the row's lock); it makes a record's events commit in append order, which the job's
52
+ checkpoint relies on (below). Creates, Monitor, ES-Lazy and ES-NoProj are unchanged
53
+ (`test/aggregate_lock_statements_test.rb`). Cost: an update issues three more SQL statements
54
+ (the lock, then the savepoint pair the append now needs), so Hijack and ES-Sync updates go from
55
+ 5 to 8 statements and ES-Async's from 4 to 7, and a destroy one more; on one contended record
56
+ Hijack's throughput falls from about 90 to about 52 ops/s. Set `false` for the previous
57
+ behaviour and its numbers.
58
+ - **ES-Async's projection job applies only the events after the stream's checkpoint**
59
+ (`Lyra::Projections::AsyncCheckpoint`, table `lyra_async_checkpoints`, created on first use)
60
+ instead of replaying the record's whole stream, so a record's jobs cost what its history is
61
+ long, not its square. Exact because the lock above makes a record's commits follow its
62
+ appends; with the lock off, or without a usable checkpoint (its event gone), the job replays
63
+ the whole stream. A checkpoint older than the row only re-applies events the row already has,
64
+ which is harmless (`test/projections/async_checkpoint_test.rb`).
65
+ - `test/projections/cached_projection_concurrency_test.rb`: readers and writers of one record under
66
+ ES-NoProj see only states the stream had, never go back, and converge.
67
+ - **Reference links for ES-NoProj** (`Lyra::Projections::ReferenceLinks`, `config.reference_links`,
68
+ default `:auto`). An event that sets a `belongs_to` foreign key is also linked into the stream
69
+ `Model.foreign_key$value`, the way Rails Event Store links events by metadata. A lookup by that
70
+ key, including the dependent-association lookups Active Record makes on destroy, reads the
71
+ stream for candidates and keeps those whose current state still matches, so moved and destroyed
72
+ children drop out and the answer is exact, without rebuilding every record of the model.
73
+ `:auto` writes links in ES-NoProj only, so no other mode pays; `true` and `false` force it.
74
+ Events stored while linking was off are linked before a model's first lookup in a process, from
75
+ a checkpoint kept in the store (`$lyra_reference_links$Model`). On the Aegean testbed this made
76
+ an ES-NoProj delete about 30 times faster at 16 threads and about 250 times at 64.
77
+ - **`config.monitor_append_failure`** (opt-in; default `:log`, unchanged). With `:fail_write`,
78
+ Monitor fails a write whose event cannot be stored, raising `EventStoreUnavailableError` and
79
+ rolling the write back (bulk writes included), as Hijack and the event-sourcing modes always
80
+ do. Row and event already commit together; the setting decides only what a failed append
81
+ does. Validated on assignment.
82
+ - **Trace conformance test** (`test/verification/trace_conformance_test.rb`). It records the
83
+ steps Lyra takes on real writes in every mode (event built, applied to the aggregate, stored;
84
+ row written; commit) and replays each trace token by token on a PetriFlow net: the paper's
85
+ core net with the row write placed as each mode places it. Every trace must be a firing
86
+ sequence ending in the net's final place, each operation must build exactly one event of its
87
+ own type, and a trace in another order is rejected. It found that Hijack and the
88
+ event-sourcing modes apply the event to the aggregate before storing it, the order the formal
89
+ model now uses.
90
+ - **`LYRA_DISABLE_PETRI_FLOW=true`** — runs Lyra as without petri_flow, as
91
+ `LYRA_DISABLE_PAM_DSL=true` does for PAM DSL.
92
+ - **`lyra:workflows:generate OUTPUT_DIR=... REPORTS_DIR=...`** — write the workflow files and
93
+ the reports to other directories than `app/workflows/` and `reports/`.
94
+ - **Dashboard authorization** (`config.dashboard_authorization`) — a proc run on the engine's
95
+ controller before every action (`instance_exec`, and given the controller when it takes an
96
+ argument); truthy allows, falsy answers 403. Unset, the dashboard is open in development and
97
+ test only and refused everywhere else with a log line, since its privacy pages show a data
98
+ subject's personal data. Example: `->(controller) { controller.current_user&.admin? }`.
99
+ - **`mode=` and `projection_mode=` validate** — one of `Configuration::MODES` and the new
100
+ `Configuration::PROJECTION_MODES`; a string is taken as its symbol, anything else raises
101
+ `ArgumentError` naming the valid values.
102
+ - **`lyra:erase MAX_COPIES=n`** — passed to `Lyra::Erasure.erase!` as `max_copies:`; the task
103
+ also prints how many values were left as shared.
104
+ - **The blog example runs again** (`examples/blog_app`) — rebuilt for Rails 8.1,
105
+ RailsEventStore 3 and PostgreSQL, with `bin/setup` and a passing test suite.
106
+ - **ES-NoProj runs Solidus** — the Olist replay passes under `projection_mode :disabled`
107
+ (6 of 6 orders end as Olist records them). Association queries (`variant.prices.find_by`,
108
+ `.where`, `.find_or_create_by!`) run on the records the event store holds for the owner, and
109
+ `:through`, polymorphic (`as:`) and scoped associations are read correctly: a `:through`
110
+ association was filtered on a foreign key its target does not have. Calculations and plucks on
111
+ an association (`payment.refunds.sum(:amount)`) read the events instead of the empty table.
112
+ Building through an association or a `where` takes its conditions, as ActiveRecord's
113
+ `scope_for_create` does. `CachedRelation` gains `group` with grouped `count`/`sum`/`average`/
114
+ `minimum`/`maximum`, `extending`, class attributes (`discard_column`), records as condition
115
+ values (mapped to their ids) and the relation readers ActiveRecord's calculations ask for.
116
+ - **Erasure driven by the policy, opt-in** (`Lyra::Retention`, `config.retention_executor`,
117
+ `config.retention_anchors`, `bin/rails lyra:retention:apply [DRY_RUN=1] [MODELS=]`,
118
+ `Lyra::RetentionJob`) — applies the policy's retention rules: for each monitored model with a
119
+ rule, a record past its period gets the rule's `on_expiry` — `:anonymize` (Lyra::Erasure),
120
+ `:hard_delete` (erase, then destroy through the write path), `:soft_delete` (`deleted_at` /
121
+ `discarded_at` through the write path; skipped without one), `:archive` (skipped: no archive
122
+ is named). Attributes with a shorter period of their own are erased first; rule conditions get
123
+ the record; already-erased attributes are skipped, so runs are idempotent. The period runs
124
+ from `created_at` or the column named per model. Off by default; a dry run works either way.
125
+ The privacy interface gains `Policy#retention_rule`.
126
+ - **Erasure finds copies by value, and optionally everywhere** — `Lyra::Erasure` now also
127
+ replaces an erased value wherever the record's events copy it under another name (a payload's
128
+ `payer_email`). `everywhere: true` (`EVERYWHERE=1`) searches the whole log for the values:
129
+ other records' events are scrubbed, their rows get the value replaced where they hold it, and
130
+ each gets an `ErasureApplied`; `Result#copies` lists them. A value held by more than
131
+ `max_copies` other records (default 10) is shared by many people, not a copy, and is left
132
+ (`Result#shared_values` counts them): on the Olist replay a placeholder address shared by
133
+ every customer took all 1,954 addresses with it. Only direct identifiers (the policy's email,
134
+ phone, identifier, card, payment-token, IP, credential types) are searched for in other
135
+ records: a name, city or postal code can be another customer's by coincidence (erasing one
136
+ Olist customer's postal-code prefix took two other customers' addresses). Erasure writes rows with one `UPDATE` by id,
137
+ so a record the application marks read-only (a Solidus address once an order uses it) is
138
+ erased too.
139
+ - **Purpose-bound `pluck` and `pick`** — checked by the declared attributes they name (also
140
+ inside SQL fragments), with the model (`"Registration$*"`) as subject, so a purpose whose
141
+ consent the policy requires refuses them.
142
+ - **Purpose-bound reads** (`Lyra::PurposeBoundReads`, `Lyra.with_purpose`, `lyra_purpose` for
143
+ controllers and jobs, `config.reads_without_purpose`) — a read of a monitored model with a
144
+ privacy policy, made within a declared purpose, is checked through the policy's
145
+ `validate_access!` with the record as subject: every declared attribute the query loaded must be
146
+ allowed for the purpose, so loading more than the purpose needs is refused (data minimisation).
147
+ No setting turns it on: the purpose does. Reads with no purpose follow
148
+ `config.reads_without_purpose`: `:allow` (default), `:audit` (logged, and recorded as an audited
149
+ access with purpose `none` when the access log is on) or `:deny` (`PurposeRequiredError`).
150
+ Lyra's own reads (projections, bypass snapshots, Genesis, DualView, mode checks, repair,
151
+ erasure) are exempt; reads that load no model are not checked. Every monitored model gains an
152
+ `after_find`, which returns at once when no purpose is in scope and reads without one are
153
+ allowed.
154
+ - **Erasure executor** (`Lyra::Erasure.erase!`, `bin/rails lyra:erase MODEL= ID= REASON=
155
+ [FIELDS=]`, `Lyra::Events::ErasureApplied`) — erases one record's personal attributes (declared
156
+ by its policy or listed by its events' privacy stamps, or exactly `fields:`) from the row and
157
+ from every event in its stream, overwriting each event in place (same id, position, time):
158
+ attributes, both sides of each change, and payload keys with the attribute's name. Appends
159
+ `ErasureApplied` (fields, reason, who; never a value), which replay skips; the stream still
160
+ replays to the anonymized row. Replacement is nil where allowed, `"erased:<id>"` for a NOT NULL
161
+ string, else the column default. Works in every mode, with strict data access on and inside a
162
+ purpose. On request only; not policy-driven, and crypto-shredding remains future work.
163
+ - **Event store failure policies, named** (`Lyra::EventStoreUnavailableError`, `Lyra.append_events`;
164
+ FEATURE_GAP_PLAN F1) — every event Lyra writes now goes through `Lyra.append_events`, which
165
+ raises `EventStoreUnavailableError` (the store's error as its `cause`, the stream in its message)
166
+ when the store fails. The policy is fixed by the mode: **fail-closed** in Hijack and every
167
+ event-sourcing mode (the write fails with the error and rolls back), **log-and-continue** in
168
+ Monitor (the write stands; the log line names the stream and points to `lyra:repair`). No
169
+ in-memory retry queue: the store shares the application's database, a queue dies with its
170
+ process, and a retried event could land behind a later one in its stream.
171
+ - **Repair after lost events** (`Lyra::Repair`, `bin/rails lyra:repair [DRY_RUN=1] [MODELS=...]`) —
172
+ brings the event log back in line with the tables after Monitor lost events: an `Imported` event
173
+ for a row with no events (or one its stream says was destroyed), an `Updated` event with the
174
+ differing columns for a row that differs from its events, a `Destroyed` event for events with no
175
+ row. Each record is re-checked and repaired under a lock on its row; repair events carry
176
+ metadata `source: "lyra_repair"` and the problem repaired. Refused in Hijack and event sourcing,
177
+ where the events are authoritative (rebuild the tables instead). `ModeTransition.record_ids` and
178
+ `ModeTransition.discrepancy` expose the check it uses.
179
+ - **Access log** (`config.record_access_events`, default false; `Lyra::AccessLog`,
180
+ `Lyra::Events::DataAccessed`, `Lyra::Events::DataAccessDenied`; FEATURE_GAP_PLAN F6) — when on,
181
+ every access the privacy policy validates (`validate_access!`) is recorded in the subject's
182
+ access stream (`Lyra::DataAccess$<Model>$<id>`): `DataAccessed` when it went ahead (outcome
183
+ `granted`, or `audited` with the violations audit mode let through), `DataAccessDenied` when
184
+ strict mode refused it. Each carries the policy, purpose, legal basis, field names (never
185
+ values) and outcome. Who accessed goes in the metadata: `user_id` and `ip_address` from the
186
+ application's `Current.user` / `Current.ip_address`, request and correlation ids, and whatever
187
+ `config.access_metadata_proc` returns for the access (for consoles, jobs and API tokens that
188
+ `Current` does not know; a failing proc is logged and skipped). Every access is
189
+ recorded, with no sampling; an access the store cannot record does not go ahead. Nothing is
190
+ recorded in disabled mode, and the record's own stream is untouched. `AccessLog.for(subject)`
191
+ reads them back. Off by default because reads can outnumber writes by orders of magnitude; the
192
+ Aegean testbed turns it on with `LYRA_RECORD_ACCESS=1`, which also makes the benchmark's reads
193
+ validate access, and records it in its benchmark reports. The Article 30 register stays
194
+ PAM's `article_30_report`, generated from the declared policy.
195
+ - **Privacy stamps on events** (`config.annotate_privacy`, default false; `Lyra::Privacy.stamp`,
196
+ `Lyra::Privacy.stamp_of`; FEATURE_GAP_PLAN F5) — when on, every event that carries an
197
+ attribute the model's privacy policy declares is stamped as it is built with
198
+ `metadata[:privacy]`: the policy's name and, per attribute, its type, sensitivity, allowed
199
+ purposes, retention period (ISO 8601) and the contexts it has a transformation for — never a
200
+ value. Every declared attribute the event carries is annotated: a create's, import's or
201
+ destroy's attributes, and an update's changes together with the whole row Monitor also records
202
+ (annotating only the changes left 28,374 of an Olist replay's events carrying personal data
203
+ unannotated). All four places events are built stamp them: Monitor, the command handler (Hijack,
204
+ event sourcing, and so domain events), bypass events and Genesis imports. The stamp records the
205
+ policy as it was when the data was written, unlike `annotations_for`, which reads today's
206
+ policy; `stamp_of(event)` reads it back with string keys. Off by default because it adds work
207
+ to every write (no SQL statements); the Aegean testbed turns it on with
208
+ `LYRA_ANNOTATE_PRIVACY=1` and records it in its benchmark reports. The privacy `Annotation`
209
+ gains a `transformations` member.
210
+ - **ModeSync: one mode per application** (`Lyra::ModeSync`, `config.mode_sync`,
211
+ `config.mode_sync_interval`) — the latest switch recorded in `lyra_mode_transitions` is the
212
+ application's mode; every process adopts a newer one within `mode_sync_interval` seconds
213
+ (default 5), checking before each web request (Rack middleware), each background job and each
214
+ write to a monitored model, never inside an open transaction. A runtime switch
215
+ (`ModeTransition.to!`) used to change only the process that made it. `to!` now logs what the
216
+ switch reaches. On wherever the Mode Transition Safety gate is (not in the test environment);
217
+ the raw `config.mode =` setter is left alone. the migration guide (`docs/MIGRATION_GUIDE.md`, Switching modes) documents the
218
+ deploy-time rule.
219
+ - **Mode Transition Safety** (`Lyra::ModeTransition`, `rake lyra:mode:check`; FEATURE_GAP_PLAN F3)
220
+ — a mode switch that changes the authoritative store is allowed only when rows and events
221
+ agree for every monitored record: escalating from Disabled or Monitor to Hijack or event
222
+ sourcing (rows that predate Lyra are imported by Genesis first), and leaving ES-NoProj,
223
+ ES-Lazy or ES-Async for a mode that reads tables (`rebuild: true` rebuilds them first). The
224
+ check compares every row and every stream in batches, reading the real table, then
225
+ re-checks what changed while it ran, and stores a clean result as a certificate.
226
+ `ModeTransition.to!` and the `enable_*!` helpers accept a fresh certificate after
227
+ re-checking only what changed since, else run the full check, and raise `Refused` with the
228
+ discrepancies; `force: true` overrides. At boot, a switch from the mode the application last
229
+ ran in needs a certificate (`LYRA_FORCE_MODE_TRANSITION=1` overrides; rake processes are
230
+ exempt). `config.mode_transition_gate`: nil (default) gates everywhere but the test
231
+ environment; `config.mode = ...` stays the raw, ungated setter.
232
+ - **Sampled DualView verification** (`config.dual_view_sample_rate`, default 0.0, off;
233
+ `config.dual_view_discrepancy_handler`) — after a write commits, a sampled share of writes
234
+ is compared with its events; a discrepancy is logged and passed to the handler, and the write
235
+ is never failed. Skipped in ES-NoProj, ES-Lazy and ES-Async, whose tables lag by design.
236
+ - **Domain events** (`monitor_with_lyra domain_events: [...]`, `Lyra::DomainEvents`;
237
+ FEATURE_GAP_PLAN F8) — rules name a write's event after what it means instead of the CRUD
238
+ operation: `{ name: "PaymentCompleted", on: %i[create update], if: ->(payment, changes) { ... } }`.
239
+ The first matching rule names the write's own event, which keeps Lyra's envelope (model, id,
240
+ operation, attributes, changes), so replay, projections, DualView and `Lyra.state_at` work
241
+ unchanged; a write no rule matches keeps its CRUD event. `payload:` adds fields for the
242
+ event's consumers under `data[:payload]`; `class:` uses an event class of your own (a
243
+ `RubyEventStore::Event` subclass) instead of a generated `Lyra::Events::<name>`;
244
+ `also: true` emits an additional event from the same write, in the same stream and
245
+ transaction, marked `replay: false` and pointing to the write's own event through its
246
+ `causation_id`. Works in Monitor, Hijack and every event-sourcing mode; writes that skip
247
+ callbacks are still recorded as CRUD bypass events. Generated event classes are registered
248
+ at boot. A rule whose block raises fails the write where events are the record (Hijack,
249
+ event sourcing) and is logged in Monitor. Commands now carry their `record`.
250
+ - **Configuration surface** (FEATURE_GAP_PLAN F2): `config.models = %w[Order Payment]` (or a
251
+ hash of name => options) declares the monitored models by name in the initializer; once the
252
+ application's code has loaded, each is given `monitor_with_lyra`, with no model file edited,
253
+ and a name that does not resolve fails the boot. `config.verify_mapping!` verifies the
254
+ CRUD-to-event mapping at boot (`Lyra.verify_mapping!`: the PetriFlow lifecycle, mode and
255
+ bypass nets through `CrudVerifier`, and a table and primary key for every monitored model)
256
+ and stops the boot with `Lyra::MappingVerificationError` naming each failed check; called
257
+ after boot it runs at once. `config.privacy_policy = :name` is the default policy for
258
+ monitored models that name none of their own.
259
+ - **Point-in-time reconstruction** (`Lyra.state_at(model, id, time)`, `Model.as_of(time)`,
260
+ `Lyra::Temporal`) — a record's attributes at a given time, rebuilt by replaying its stream up
261
+ to the last event stored at or before then: `nil` if it did not exist yet or had been
262
+ destroyed. `Model.as_of(time).find(id)` / `.find_by_id(id)` return read-only records and
263
+ `.all` every record that existed then. For a row imported by Genesis, a time before its
264
+ import raises `Lyra::Temporal::HistoryNotRecorded` instead of guessing (a time before its own
265
+ `created_at` is `nil`). Works in every mode that records events, Monitor included
266
+ (FEATURE_GAP_PLAN F7).
267
+ - **ES-NoProj joins, evaluated in memory** (`Lyra::Projections::CachedJoins`) — `joins`,
268
+ `left_joins`/`left_outer_joins`, `where.missing` and `where.associated` on `belongs_to`,
269
+ `has_many` and `has_one` associations, with hash conditions on the joined table
270
+ (`where(programs: { available: true })`, by table or association name), used to raise
271
+ `UnsupportedQuery`. They are now evaluated as SQL would: a joined relation holds rows of a
272
+ record and its partners; an inner join drops records without a partner; a `has_many` join
273
+ repeats the record per partner (so `count` agrees, and `distinct` removes the repeats); a
274
+ missing left-join partner behaves like NULL, including under `where.not`. Partners come from
275
+ the joined model's streams if it is an ES-NoProj model, otherwise from one SQL query on its
276
+ table. Still refused: SQL-string, nested, `:through`, polymorphic and scoped joins, a
277
+ condition on a table not joined first, and `merge` with an arbitrary relation (so Solidus
278
+ still needs ES-Lazy). `where` with no arguments now returns the where-chain, as in
279
+ ActiveRecord.
280
+ - **Privacy provider interface** (`Lyra::Privacy`) — `Policy` (declared attributes and their
281
+ `Annotation`s), `Detector` (name-based PII heuristic) and `Provider`, with null defaults when
282
+ no provider is installed. PAM is an adapter (`Lyra::Privacy::Adapters::Pam`), loaded when
283
+ `pam_dsl` is present. `Lyra::Privacy.annotations_for(event)` returns the annotation of every
284
+ declared attribute an event touched. The foundation for `config.privacy_policy=` and PII
285
+ annotation in event metadata (`FEATURE_GAP_PLAN.md`, F2 and F5). From `thesis-restructure`
286
+ (1adadfe); the testbed binds `Registration` and `PaymentTransaction` to its `:epay_data`
287
+ policy (d0df542), which adds nothing to the write path.
288
+ - **`Lyra::IdGenerator.reserves_safely?(model)`** — true for UUID keys and for integer keys
289
+ backed by a PostgreSQL sequence. It uses the cached sequence lookup, so it adds no query per
290
+ write once a table's sequence is known.
291
+ - **Bypass events for bulk writes** (`Lyra::BypassEvents`) — `update_all`, `delete_all`,
292
+ `insert`/`insert_all(!)`, `upsert`/`upsert_all` and `dependent: :nullify` on a monitored model
293
+ now publish one event per affected row, whatever the `strict_data_access` setting
294
+ (`update_column(s)`, `touch` and `delete` already did). `update_all` publishes "updated" per
295
+ row that changed; `delete_all` "destroyed" per row; inserts "created" per row; `upsert_all`
296
+ finds existing rows by its conflict key (primary key, `unique_by:` columns or index name) and
297
+ publishes "updated" for those, "created" for the rest. The caller's `returning:` result is
298
+ unchanged. Brought over from the `thesis-restructure` branch (3ba39bb, 975dc6e), keeping
299
+ master's instance methods (events built after the write).
300
+ - **`Lyra.projection_write { ... }`** — Lyra's own read-model writes: skips strict data access
301
+ and publishes no bypass events. `ModelProjection` and `Rebuild` use it; use it for writes that
302
+ must not reach the event stream, such as seeding or wiping a table.
303
+ - **Genesis: event streams for rows that predate Lyra** (`Lyra::Genesis`,
304
+ `config.genesis`, `rake lyra:genesis`) — a row written before Lyra was enabled had no
305
+ stream, so its first `Updated` event started a stream with no head, and DualView,
306
+ `Rebuild` and ES-NoProj reads saw the record wrongly or not at all. On a model's first
307
+ use in a process (its first write, or in ES-NoProj its first read or aggregate), every
308
+ row without a stream now gets one `Imported` event holding the row as it is. `Imported`
309
+ is replayed like `Created` everywhere state is rebuilt. Later uses cost nothing; when
310
+ every row already has a stream the check is one query and takes no lock. The import
311
+ runs under a per-model advisory lock on a connection of its own, so a long caller
312
+ transaction does not block other first uses and a caller's rollback does not undo it.
313
+ `config.genesis`: `:auto` (default, event-sourcing mode only), `true` (also Monitor and
314
+ Hijack) or `false`. `rake lyra:genesis MODEL=...` imports ahead of time for large tables.
315
+ - **ES-NoProj SQL aggregates computed from streams only, with SQL semantics** —
316
+ `count(column)`, `sum`, `average`, `minimum`, `maximum` and `calculate` on an ES-NoProj
317
+ relation run over the records rebuilt from streams (rows that predate Lyra included, via
318
+ Genesis). `sum` used to go through `to_f` (losing decimal precision) and now keeps the
319
+ column's type, returning 0 over no rows; `average` returns a `BigDecimal` for integer and
320
+ decimal columns, as ActiveRecord does; `count(column)` counts non-NULL values, where it
321
+ used `present?` and dropped `""` and `false`. An SQL expression (`sum("price * qty")`)
322
+ raises `UnsupportedQuery` instead of being guessed. Known difference: `average` keeps
323
+ every digit of the quotient, while PostgreSQL rounds a numeric `AVG` to about 16
324
+ significant digits.
325
+ - **ES-Lazy: event sourcing that projects on read** (`projection_mode :lazy`, in
326
+ event-sourcing mode; `Lyra::Projections::LazyProjection`, `Lyra::Interceptors::LazyReads`)
327
+ — writes store events only, as in ES-NoProj; before any database read, the tables are
328
+ brought up to date from the event log, and the read runs as real SQL, so joins, merged
329
+ relations, SQL fragments and aggregates all work. The log stays the only source of
330
+ truth; the tables are a cache that `Rebuild` can recreate. Events are applied in the
331
+ log's global order under a PostgreSQL advisory lock, against a checkpoint that also
332
+ tracks ids still in flight (filled by replaying the record when they commit, forgotten
333
+ after 5 minutes). Read-your-writes consistent. Needs no migration: the checkpoint table
334
+ is created on first use. It runs Solidus, which ES-NoProj cannot: on 20 real Olist
335
+ orders it matches plain ActiveRecord with DualView clean. Cost: every read first checks
336
+ the log (about half ES-Sync's throughput in the replays).
337
+ - **`Lyra::EventSerializer`** — a JSON serializer for RailsEventStore that keeps time
338
+ precision. Ruby's `JSON` writes `Time` with `Time#to_s`, dropping fractional seconds, so
339
+ apps passing `serializer: JSON` stored `09:51:15.304` as `"09:51:15 UTC"`, and an update
340
+ from `.304` to `.352` as no change at all. `Lyra::EventSerializer` writes times as ISO 8601
341
+ with microseconds and `BigDecimal` as its exact decimal string; `load` is plain
342
+ `JSON.parse`. Opt in with
343
+ `EventRepository.new(serializer: Lyra::EventSerializer)`. Lyra's default client (RES's
344
+ YAML serializer) was not affected. Found by replaying the BPI Challenge 2017 log
345
+ (`examples/bpi2017_loan_app`), whose timestamps carry milliseconds.
346
+ ### Changed
347
+ - **ES-NoProj reads**: a stale cache entry is brought up to date from the events after its stamp
348
+ instead of a full replay; cache entries have a process-local copy in front of the shared cache;
349
+ an association's records are read when first needed, and a reference-link lookup intersects
350
+ every indexed key and takes candidate ids from the stream table; `Model.new` reads no record.
351
+ A replay of 640 Olist orders through Solidus took 855 s where it took 2,229 s, with the same
352
+ end state.
353
+ - **`ModeTransition.to!` links before switching into ES-NoProj.** Entering `event_sourcing`
354
+ with `projection_mode: :disabled` first runs a full reference-link pass over the monitored
355
+ models, so lookups are complete from the first one.
356
+ - **Documentation regrouped.** `docs/ADOPTION.md` and `docs/MODE_TRANSITIONS.md` are now
357
+ sections of `docs/MIGRATION_GUIDE.md` (Adopting Lyra in an existing application; Switching
358
+ modes); `docs/ORFEAS_FRAMEWORK_OVERVIEW.md` is the opening of `docs/ARCHITECTURE.md` (About
359
+ ORFEAS); `docs/PROJECTIONS_DATA_ACCESS_MONITORING.md` is split between `ARCHITECTURE.md`
360
+ (projections, write-side monitoring) and `docs/PRIVACY_COMPLIANCE.md` (read-side monitoring).
361
+ Claims were checked against the code on the way. `docs/PERFORMANCE.md` no longer presents the
362
+ withdrawn July 2026 figures: it explains each mode's cost in SQL statements per write, and
363
+ the throughput is being re-measured.
364
+ - **The workflow generator's mode nets name what Lyra does.** Their steps were labelled with
365
+ methods that do not exist (`CommandHandler.validate`, `CommandHandler.execute`,
366
+ `Projection.apply`, `EventStore.append_to_stream`) or with `Aggregate.apply(command)`, and
367
+ the Monitor net called the event store through `Lyra::Event.publish`. Each step now names the
368
+ method Lyra calls, in the order the trace conformance test observes; the generator no longer
369
+ claims to introspect the implementation (the mode nets are written by hand). The shipped
370
+ `app/workflows/*_mode_workflow.rb` files are regenerated.
371
+ - **Ruby 4.0 or later is required** (`required_ruby_version >= 4.0`, was `>= 3.4.5`); CI tests
372
+ Ruby 4.0. The project runs 4.0.7.
373
+ - **Article 30 register (PAM)** — `pam_dsl:report:article_30` states only what the policy declares (data subjects, recipients, transfers, security measures) and lists the rest as not declared; see `gems/pam_dsl/CHANGELOG.md`.
374
+ - **PII detection (PAM)** — Lyra's detector fallback (`Lyra::Privacy::PIIDetector`, `PolicyIntegration`) uses PAM's merged dictionary: more personal columns found (street, postal code, IP columns, card digits, account tokens), foreign keys and generic `*_name` columns no longer flagged. See `gems/pam_dsl/CHANGELOG.md`.
375
+ - **Hijack fails a write whose event cannot be stored with `EventStoreUnavailableError`**, as event
376
+ sourcing does, instead of a refused save (`save` returning false with the store's message on
377
+ `errors[:base]`). A store failure is not a validation error; `save` now raises it, as it raises
378
+ database errors.
379
+ - **ES-Async can run out of line in tests** — `async_projections_inline` now decides: unset
380
+ (the new default, `nil`) projects inline in the test environment only, as before; `true`
381
+ always; `false` never, so a test with an `:async` job adapter exercises the real background
382
+ path. The test environment used to force inline projection whatever the setting said, so no
383
+ test could run ES-Async as it runs in production. `Configuration#async_projections_inline?`
384
+ gives the effective value.
385
+ - **ES-NoProj cache: entries stamped with their stream's last event** (`CachedProjection`) — each
386
+ record's cached rebuild carries the id of the last event it was built from and is used only
387
+ while that is still the stream's last event, so it is exactly right or detectably out of date.
388
+ Collection reads (`all`, `where`, `find_by` on non-key attributes, `count`) are assembled from
389
+ those entries: one query for every stream's last event, one bulk cache read, and a replay of
390
+ only the streams that changed. They used to be cached as whole collections that every write
391
+ threw away, so after any write the next collection read replayed every stream of the model.
392
+ A `dependent: :nullify` delete, which must find the children by attribute, cost 1.7-3.5 s in
393
+ the Aegean smoke runs; it now costs 0.1-0.4 s (that cell: 10-16 → 49-101 ops/s). ES-NoProj
394
+ SQL statements per write: 12 → 11 per create, 9 → 8 per update (a write no longer deletes
395
+ collection keys; it reads its stream's last event). Cache entries are versioned `v2`; old
396
+ ones are ignored.
397
+ - **`Lyra.privacy_features_available?`** asks the installed privacy provider instead of
398
+ checking for `pam_dsl` directly. `PIIDetector` and `PolicyIntegration` load always and work
399
+ through the interface; `PIIMasker` and `GDPRCompliance` stay PAM-only.
400
+ - **Hijack creates reserve an id only where it cannot collide** (`reserves_safely?`). Elsewhere
401
+ (an integer key without a PostgreSQL sequence, e.g. SQLite or MySQL) hijack falls back to the
402
+ `pending-<hex>` placeholder with a one-time warning, rather than guessing an id the database
403
+ may also hand out. On PostgreSQL nothing changes, and SQL statements per write are unchanged
404
+ in every mode.
405
+ - **Bulk writes on monitored models publish events** (see Added). Code that relied on
406
+ `update_all`/`delete_all`/`insert_all` leaving the event stream untouched should wrap them in
407
+ `Lyra.projection_write`. Bulk-write guards are on `ActiveRecord::Relation`
408
+ (`Lyra::StrictDataAccessRelation`); `Lyra::StrictDataAccessClassMethods` is removed.
409
+ - **ES-NoProj bulk writes are events.** With projections disabled the records exist only in
410
+ the stream, so `CachedRelation#delete_all`/`update_all` publish the events that are the write
411
+ (a `delete_all`d record used to reappear on the next read). `upsert_all` raises
412
+ `ArgumentError` there: its `ON CONFLICT` would check the table, not the stream. Cost: a
413
+ `dependent: :nullify` delete must find the children, which in ES-NoProj rebuilds every stream
414
+ of the child model.
415
+ - Instance bypass events (`update_column(s)`, `touch`, `delete`) go through
416
+ `BypassEvents.publish`, which also invalidates the ES-NoProj read cache.
417
+ - **ES-NoProj answers exactly or raises** (`Lyra::Projections::CachedRelation`, new
418
+ `Lyra::Projections::UnsupportedQuery`) — reading from the event store, `CachedRelation`
419
+ used to answer queries it could not evaluate anyway, with the wrong records: a
420
+ SQL-string `where` kept every record, unrecognised conditions (`!=`, ranges written as
421
+ SQL, ORs) were dropped, `joins` and `where.missing`/`where.associated` were ignored, a
422
+ condition on an unknown column matched nothing, and a scope that failed, carried
423
+ `order`/`limit`/`group` and the like, or was not a scope at all, returned the whole set.
424
+ Each now raises `UnsupportedQuery`, naming the query and the alternatives. Hash
425
+ conditions, ordering, limits, loading hints and scopes made of hash conditions work as
426
+ before. This found the Aegean benchmark's own cleanup, `where("email LIKE ?")` under
427
+ ES-NoProj, silently matching every registration.
428
+ - **RailsEventStore 3** — `lyra.gemspec` now requires `rails_event_store ~> 3.0` (3.2.0 in the
429
+ lockfile), on Ruby 4.0.7 and Rails 8.1.4 (8.1.3.1 and RES 3.1.0 until 2026-10-19). RES 3 moved
430
+ the ActiveRecord repository to
431
+ `RubyEventStore::ActiveRecord::EventRepository` and the event base class and errors to
432
+ `RubyEventStore`; `Lyra::Event`, `Lyra::Aggregate` and the default client follow. Apps
433
+ that build their own client must switch from `RailsEventStoreActiveRecord::EventRepository`.
434
+ - **`IdGenerator` caches the PostgreSQL sequence name** — `pg_get_serial_sequence` ran on
435
+ every reserved ID, a round trip whose answer never changes. Since hijack mode now reserves
436
+ its IDs here too, that was one extra SQL statement on every hijack-mode create (two
437
+ instead of one). The name is now looked up once per table and cached; only `nextval`
438
+ runs per ID.
439
+
440
+ ### Removed
441
+ Dead code, none of it on the write path. Initializers that set `config.event_backend`, or models
442
+ that pass `command_handler:`, must drop it: the former now raises `NoMethodError`, the latter is
443
+ ignored like any unknown option.
444
+ - **`Lyra::EventMapper` and `Lyra::AuditMapper`** (`lib/lyra/event_mapper.rb`). The interceptor
445
+ and the command handler build their events themselves (`CrudInterceptor#publish_event`,
446
+ `CommandHandler#create_event`, both through `Lyra::DomainEvents.build`); nothing called the
447
+ mappers.
448
+ - **`Lyra::EventStoreAdapter`, `RailsEventStoreAdapter`, `CustomEventStoreAdapter` and
449
+ `config.event_backend`**, read only by `EventStoreAdapter.build`. Lyra uses the RailsEventStore
450
+ client in `config.event_store` directly; `Lyra.append_events` and `EventStoreUnavailableError`
451
+ stay.
452
+ - **The `command_handler` model option** (`monitor_with_lyra command_handler:`,
453
+ `ModelConfiguration#command_handler`) and its dashboard line. It was stored and shown, but the
454
+ write path always uses `Lyra::CommandHandler`.
455
+
456
+ ### Fixed
457
+ - **A thread that wrote through Lyra kept a database connection until it ended (mpantel/lyra#37).** Lyra
458
+ called `Model.connection` (Rails' `lease_connection`) in its write, lock and read paths: the
459
+ aggregate lock, the id generator, Genesis, ES-Async's job, ES-NoProj's cache and reference links,
460
+ ES-Lazy's catch-up, and its maintenance calls (DualView, Rebuild, mode checks, erasure). Since
461
+ Rails 7.2 that keeps the connection for the thread, even inside `with_connection`, which then does
462
+ not return it. So in Hijack and every event-sourcing mode, a thread of the application's own (a
463
+ job runner, a worker pool) ended still holding one, and the pool got it back only by reaping: the
464
+ first thread to find the pool full reset every dead thread's connection, one by one, while the
465
+ others waited. In the benchmark's 64-thread cells such a wait now and then passed the 5 s checkout
466
+ timeout (0.3 to 3 operations per trial of 12,800 in Hijack, Hijack+PT and ES-Sync; part of
467
+ ES-NoProj's 14 to 18), and the contention benchmark reset each round's connections inside its
468
+ timed window. Lyra now uses `with_connection` throughout: inside a transaction it is the
469
+ transaction's own connection, outside one it is returned after use. `OwnConnection`'s thread
470
+ returns whatever its work leased. `test/connection_release_test.rb`: in every mode, a thread that
471
+ writes and reads, bare or in `with_connection`, ends without a connection, and `lib/` has no
472
+ `Model.connection` call left.
473
+ - **Genesis and the reference-link catch-up assumed a second connection (#37).** Inside an open
474
+ transaction both ran their work on a thread that checked out a connection of its own and waited
475
+ for it. Under Rails' transactional tests the pool's connection is pinned, every checkout returns
476
+ the caller's own, locked connection, and the wait never ended: the Aegean testbed suite in
477
+ ES-NoProj deadlocked at its first indexed lookup. Under load each caller took a second
478
+ connection, and at 64 threads the pool ran out (5 s checkout timeouts, about 0.1% of ES-NoProj's
479
+ operations in the benchmark's 64-thread cells). `Lyra::OwnConnection` now runs such work: on a
480
+ connection of its own when one can be had, otherwise inline in the caller's transaction, with
481
+ the model recorded as linked or imported only once that transaction commits (after a rollback
482
+ the work runs again on the next use). The catch-up runs once per model and process; concurrent
483
+ first lookups wait for it without a second connection. `test/projections/reference_links_test.rb`:
484
+ a pinned connection, and concurrent first lookups inside transactions.
485
+ - **`has_and_belongs_to_many` links were not recorded.** The join table has no model of its own
486
+ (Active Record writes it through an anonymous class), so `item.tags << tag` wrote the join row
487
+ and no event recorded it: a rebuild lost the association and DualView did not compare it. Every
488
+ insert into and delete from a HABTM join table is now an `AssociationLinked` or
489
+ `AssociationUnlinked` event on the owner's stream, linked into each monitored associated
490
+ record's stream (`Lyra::Associations::HabtmLinks`), in every mode. DualView compares each HABTM
491
+ association's links with the join table, `Rebuild` restores join tables from the events, and
492
+ Genesis records the links pre-existing rows had. Under ES-NoProj, HABTM writes failed before
493
+ reaching Lyra (`CachedRelation` lacked `where_values_hash`); they now work and read back
494
+ (`test/habtm_links_test.rb`, every mode; mpantel/lyra#21).
495
+ - **Optimistic locking: a stale update silently won under event sourcing.** Active Record checks
496
+ `lock_version` in the UPDATE or DELETE that writes the row; ES-Sync, ES-Async, ES-NoProj and
497
+ ES-Lazy skip that statement, so a stale `update` or `destroy` overwrote the newer write (a lost
498
+ update), and `touch` raised `StaleObjectError` on every call under ES-NoProj, where no row
499
+ matches. No mode's events recorded the version's increment, so the log replayed to a
500
+ `lock_version` the row did not have (DualView: `lock_version: { crud: 1, event_sourced: 0 }`).
501
+ Every write's event now records the increment, `touch` included; the event-sourcing modes check
502
+ the version against the log before appending, under the lock on the aggregate (taken for such a
503
+ model in every event-sourcing mode), and raise `ActiveRecord::StaleObjectError` as Active Record
504
+ does (`Lyra::OptimisticLocking`). A record that expects a newer version than the log's passes,
505
+ so streams written before the change keep working (`test/optimistic_locking_test.rb`, every
506
+ mode, concurrent writers included; mpantel/lyra#20).
507
+ - **Encrypted attributes (Active Record `encrypts`) reached the event log in clear** while their
508
+ row held ciphertext: events are built from attributes Active Record has already decrypted.
509
+ `Lyra.append_events`, through which every event passes, now stores those attributes' values (in
510
+ `attributes` and `changes`) as the ciphertext Active Record writes to the column
511
+ (`Lyra::EncryptedAttributes`); projections, rebuilds and DualView decrypt them, and records
512
+ instantiated from events are decrypted by Active Record. Events written before keep their clear
513
+ values and still read; rows written from them are encrypted. No mode leaks the value, no value is
514
+ encrypted twice (`test/encrypted_attributes_test.rb`; mpantel/lyra#19).
515
+ - **ES-Lazy stopped projecting after the event store was emptied or restored without its
516
+ checkpoint table**: the checkpoint then lay past the newest event, and every new event looked
517
+ applied. A checkpoint past the newest event is now recognised, warned about and reset (applying
518
+ is idempotent). It made two tests fail in some orders, since the test suite reloads the event
519
+ tables but not the checkpoint table (`test/projections/lazy_projection_test.rb`).
520
+ - **Single-table inheritance: a record's events were filed under its concrete class's stream**
521
+ (`Spree::Calculator::Shipping::FlatRate$1`), and anything given the base class read
522
+ `Spree::Calculator$1` and found none: DualView reported the row as missing from the log, and
523
+ under ES-NoProj, ES-Lazy or a rebuild the base class saw an empty stream. A record's stream is
524
+ now named after the class that owns the table (`Lyra.stream_name`, `Lyra.stream_prefix`), at
525
+ every one of the 21 places that built it; reference links and Lyra's caches follow the table.
526
+ Under ES-NoProj a subclass reads only its own records and its subclasses', as ActiveRecord's
527
+ type condition does; events naming a subclass of a monitored model resolve to it
528
+ (`Configuration#monitored_model_named`, used by ES-Lazy's catch-up and erasure). Event types
529
+ still name the concrete class. **Upgrade**: `bin/rails lyra:streams:migrate_sti`
530
+ (`Lyra::StiStreamMigration`) links events written before into the base streams, once; it
531
+ reports a base stream that already holds newer events instead of linking out of order
532
+ (`test/sti_streams_test.rb`, every mode).
533
+ - **ES-Async projected inline could leave the row behind the log.** With
534
+ `async_projections_inline` (the test environment's default) a write applied only its own event,
535
+ inside its transaction and under no lock: of two writers of one record, the one that projected
536
+ last could leave the row at its older value. Inline now runs what `AsyncProjectionJob` runs, in
537
+ the writer's thread once its transaction has committed, under the per-stream lock. The job path
538
+ was not affected (`test/concurrent_same_record_test.rb`).
539
+ - **ES-NoProj: a named scope called on an association was ignored** (`order.payments.completed`
540
+ returned every payment) and its body loaded every record of the model; records an association
541
+ loaded did not point back at their owner instance.
542
+ - **Documentation: `ReadYourWrites` projects a block's writes when the block ends**, so a read
543
+ after the block sees them and a read inside it does not; `docs/API_REFERENCE.md` said or
544
+ implied the latter.
545
+ - **ES-Lazy: concurrent first reads no longer fail creating the checkpoint table.** Two threads
546
+ that both found `lyra_projection_checkpoints` missing both created it, and the second `CREATE`
547
+ failed the write that triggered it (one write of the full BPI 2017 replay at two threads).
548
+ `Lyra.create_own_table` now defines the table in one transaction and ignores a failed create
549
+ once the table exists.
550
+ - **Leaving ES-Lazy skipped its catch-up** — `ModeTransition.to!` from `event_sourcing/lazy`
551
+ calls `LazyProjection.catch_up!(force: true)`. Without `force:`, `catch_up!` returns 0 outside
552
+ ES-Lazy, and the mode had already changed by the time the transition ran it, so the tables
553
+ could be left behind the log.
554
+ - **Locks on databases other than PostgreSQL** — Genesis, ES-Lazy catch-up and ES-Async
555
+ projection take their transaction-level advisory lock through `Lyra::AdvisoryLock.xact_lock`.
556
+ On SQLite (which serialises writers) it does nothing; on other adapters it logs once per
557
+ purpose that the step runs unlocked and is safe in a single process only. Before, those
558
+ adapters ran unlocked silently.
559
+ - **`upsert_all` under ES-NoProj raised `ArgumentError`**; it now raises
560
+ `Lyra::Projections::UnsupportedQuery`, like every other query ES-NoProj cannot answer, naming
561
+ the reason (the `ON CONFLICT` check runs against the table, not the event stream) and the
562
+ per-record alternative.
563
+ - **`DualView` reported a record for a stream of only `replay: false` events** — such a stream
564
+ describes no record, so `exists` is now false.
565
+ - **`lyra.rb` required ActiveRecord and ActiveJob implicitly**, relying on the host to load them
566
+ first; loading the gem after only `require "rails"` raised `NameError`. It now requires both.
567
+ - **`Lyra::Projection.subscribe_to` raised `RubyEventStore::InvalidHandler`** — RailsEventStore
568
+ 3.1 subscribers must respond to `call`. A projection class now does (`call(event)` hands the
569
+ event to a new instance's `handle`), and `subscribe_to` returns the unsubscribe procs.
570
+ `handle` takes the `apply_<name>` method from `event.event_type`, so an event read back as a
571
+ plain `RubyEventStore::Event` reaches its handler too.
572
+ - **Nested `with_guaranteed_read` lost the outer block's writes** — the inner block reset the
573
+ list of pending writes, then cleared it. An inner block now projects its own writes when it
574
+ ends and restores the outer list; if it raises, its unprojected writes pass to the outer
575
+ block. Every write is projected by the time the outermost block ends.
576
+ - **A process that only reads got plain `RubyEventStore::Event`s** — in a lazily loaded process
577
+ (console, rake, development) no event class was registered, so events came back without
578
+ Lyra's readers (`operation`, `attributes`, `changes`, ...). `Lyra::Events` now defines the
579
+ class on first reference for a name a monitored model or the stored schema writes, loading
580
+ the model from the name's stem (`PostCreated` → `Post`, `SpreeOrderCreated` →
581
+ `Spree::Order`); other names still raise `NameError`. A model with a custom `event_prefix` or
582
+ `event_mapping` that is not loaded resolves only through the schema store.
583
+ - **petri_flow was not optional** — `app/workflows/*.rb` subclass `PetriFlow::Workflow`, so
584
+ without the gem eager loading (production boot, `lyra:mode:*`, `lyra:repair`,
585
+ `lyra:schema:*`) raised `NameError`. Without petri_flow the engine neither autoloads nor
586
+ eager-loads `app/workflows`.
587
+ - **`lyra:workflows:generate MODE=...` wrote a class its file did not name** — `MODE=monitor`
588
+ defined `MonitorModeWorkflowWorkflow`, which Zeitwerk could not load. A mode's file and class
589
+ are now the same whether one mode or all are generated (`monitor_mode_workflow.rb` defines
590
+ `MonitorModeWorkflow`, `es_sync_mode_workflow.rb` `EsSyncModeWorkflow`;
591
+ `WorkflowGenerator.workflow_file_basename(mode)`, `.workflow_class_name(mode)`).
592
+ - **`already initialized constant Lyra::VERSION` in the monorepo** — Bundler's default gemspec
593
+ search also loaded the nested `lyra-engine/` checkout's `lyra.gemspec`. The root `Gemfile`
594
+ uses `gemspec glob: "lyra.gemspec"`, and the example apps' Gemfiles `glob: "lyra.gemspec"`.
595
+ - **Hijack and event-sourcing events lost who wrote them** — the command handler wrote only
596
+ the causal chain, so `user_id`, `request_id`, the user action and `config.metadata_proc`
597
+ reached Monitor events alone. Every mode now carries the same attribution metadata
598
+ (`record.lyra_event_metadata(operation)`), plus `source: "lyra_command_handler"`.
599
+ - **`ReadYourWrites` had no effect under ES-Async** — only the ES-Sync path recorded writes
600
+ for it. Writes inside `with_guaranteed_read` are now projected when the block ends; the job
601
+ is still enqueued and converges on the same row.
602
+ - **Aggregates never loaded their history** — `GenericAggregate.load` raised `NoMethodError`
603
+ (no model class for its stream name) and the command handler swallowed it. A model's own
604
+ `aggregate_class` is now loaded with its stream's history for updates and destroys; the
605
+ default `GenericAggregate`, which decides nothing from history, starts empty and reads no
606
+ stream, so its writes issue no extra SQL. A programming error while loading now fails the
607
+ command instead of being swallowed.
608
+ - **`AuditProjection.audit_trail` always gave `user_id: nil`** — it read the user from the
609
+ event data, which no longer holds the metadata; it reads the event's metadata now.
610
+ - **DualView reported every destroyed record as `exists_mismatch`** — a destroyed record
611
+ whose row is gone now compares clean (`event_sourced_state` gains `destroyed:`).
612
+ - **The engine's root redirect was hard-coded to `/lyra/dashboard`** — it is now relative
613
+ to wherever the engine is mounted.
614
+ - **`strict_schema` stopped the boot on any schema drift** — including info-level changes
615
+ such as an added column. It now refuses to boot only on a breaking change (model or column
616
+ removed, column type or event name changed) and logs the rest.
617
+ - **`PIIMasker.mask_events` was broken** — it built events with keywords
618
+ `RubyEventStore::Event` does not accept. It returns masked copies now (same class, id and
619
+ metadata; attributes and changes masked).
620
+ - **`Erasure::Result#shared_values` was sometimes `[]`, sometimes an Integer** — always an
621
+ Integer count now.
622
+ - **`EventFlow#crud_to_event_mapping` found nothing for a class** — it accepts a class or its
623
+ name, and string operations and ids (as the dashboard passes them); `data_lineage` likewise.
624
+ - **A test left a guard on `Article#body=`** (`multi_mode_integration_test`), failing 11 later
625
+ tests under some seeds; it now removes it.
626
+ - **ES-NoProj answers more queries exactly instead of refusing them** — scopes with `order` or
627
+ `limit` (their conditions, then the order and limit); `relation.or(other)` over the same model
628
+ (the union); and `where` fragments of `col OP ?` terms joined all by AND or all by OR, with OP
629
+ one of `= != <> < <= > >= LIKE ILIKE` (values cast to the column type, NULL never matching, LIKE
630
+ patterns with `%`, `_` and `\` escapes). Anything else is still refused. `order("col DESC")`
631
+ strings and Arel orderings are read, and an ordering it cannot read now raises: it used to
632
+ compare as equal and leave the result silently unordered. The testbed suite under ES-NoProj
633
+ goes from 20 failures to none (the domain-events tests also created their records before
634
+ switching mode, so under ES-NoProj the parents existed only as events).
635
+ - **ES-NoProj lost `update_columns` and `touch`** — in the events-only store the `UPDATE` matched no
636
+ row and returned false, so no event was published and the change was lost, against Bypass
637
+ Coverage ("the events are the write"). The event is now published whatever the row count.
638
+ - **ES-Lazy lost a callback-bypassing write made right after a write** — a write appends events
639
+ only, so the record's row did not exist until the next read; `update_columns`, `update_all`
640
+ or `delete_all` on it matched nothing, recorded no event, and the change was lost.
641
+ `BypassEvents.atomically` now brings the tables up to date first, as a read does.
642
+ - **Lyra's own tables vanished with a rolled-back transaction** — `lyra_projection_checkpoints`
643
+ and `lyra_mode_transitions` were created inside whatever transaction was open, and remembered
644
+ as created; when it rolled back (a failed request, every transactional test) the table was
645
+ gone and every later use failed. Under ES-Lazy the testbed suite failed 395 tests this way.
646
+ They are now created on a connection of their own when a transaction is open
647
+ (`Lyra.create_own_table`).
648
+ - **ES-NoProj ignored `select` and hid purpose refusals as "not found"** — `CachedRelation#select`
649
+ returned whole records, and the record builder swallowed a policy error, dropping the record.
650
+ Query results are now narrowed to the selected columns and checked when returned
651
+ (`CachedRelationDelivery`), as are the class-level `find`, `find_by` and `find_by!`.
652
+ - **The all-modes runner skipped ES-Lazy** — `lyra:test:all_modes` and the PAM-less run now cover
653
+ all seven configurations.
654
+ - **Lyra's rake tasks ran twice, and host apps got the monorepo's maintenance tasks** — Rails
655
+ loads every `lib/tasks/*.rake` under an engine's root, and the engine also loaded its three
656
+ task files explicitly, so each `lyra:mode:*`, `lyra:projections:*` and `lyra:schema:*` task
657
+ ran twice (`lyra:mode:check` ran its full check twice), and `gems:*`, `public:*`, `stats:*` and
658
+ the other monorepo tasks appeared in every host app. The engine now loads `lyra_*.rake` only,
659
+ once.
660
+ - **`lyra:mode:check` could certify a switch having checked nothing** — monitored models register
661
+ as their classes load, which a rake task in development does not do, so the check saw no
662
+ models, found no discrepancy and certified the switch. The mode, repair, projection and schema
663
+ tasks now load the application's models first; check and repair refuse when none are monitored.
664
+ - **Renamed events were skipped on replay** — `Rebuild`, the ES-NoProj cache and the aggregate
665
+ decided what an event did from the end of its name (`…Created`, `…Updated`, `…Destroyed`).
666
+ An event renamed with `event_mapping` (or, now, a domain event) was skipped: `Rebuild` left
667
+ the record's row empty and ES-NoProj could not read it. Every replay site now dispatches on
668
+ the operation each event records in its data (`Lyra::Event.operation_of`); only events too
669
+ old to carry one fall back to the name.
670
+ - **Event sourcing lost writes whose event could not be stored** — `lyra_store_events` logged a
671
+ failed append and carried on (unless `strict_projections`, a setting about projections, was
672
+ on): ES-NoProj reported a write that existed nowhere, and ES-Sync projected a row from an
673
+ event that was never stored. In Hijack and the event-sourcing modes a write whose event
674
+ cannot be stored now fails and is rolled back, bypass writes included
675
+ (`BypassEvents.required?`, `BypassEvents.atomically`: the write and its events share a
676
+ transaction). Monitor, where the table stays authoritative, still logs the failure and keeps
677
+ the write.
678
+ - **A failed event-sourced create made the next insert on its thread vanish** — the
679
+ skip-insert signal was cleared only at the end of a successful finalize, so after any
680
+ failure the next insert of any model on that thread (a Puma thread, say) skipped its row while
681
+ reporting success. It is now cleared as soon as the record's own INSERT has run, and
682
+ finalize clears its state on every exit.
683
+ - **ES-NoProj evaluated association conditions record by record, and some wrongly** —
684
+ `where(program: x)` loaded each cached record's association (one query per record: about
685
+ 10,000 for one `count` in the mode-comparison benchmark) and compared objects;
686
+ `find_by(program: x)` compared against a `program` attribute the record does not have and
687
+ silently found nothing; `where(program_id: record)` matched nothing. Conditions are now
688
+ rewritten as ActiveRecord does (`Lyra::Projections::AssociationConditions`): a `belongs_to`
689
+ becomes its foreign key (polymorphic: type and id), and a record given as a column value
690
+ becomes its id. A `has_many`/`has_one` condition, which ActiveRecord accepts only with a
691
+ join, raises `UnsupportedQuery`, as does a polymorphic condition over several types.
692
+ - **ES-NoProj could serve out-of-date records from its cache** — a write warmed its record's
693
+ entry inside the write's transaction, so a rollback left a record cached that never existed;
694
+ two processes filling one key could leave a stale entry for up to an hour; and
695
+ `where`/`find_by` results were cached for five minutes and never invalidated, so they could
696
+ return records a write had already changed. Stamped entries (see Changed) make all three
697
+ impossible: a stale entry is never used.
698
+ - **An explicit id was replaced** — `CommandHandler#handle_create` generated an id even when the
699
+ application set one; it now keeps it, in the event and in the row. From `thesis-restructure`
700
+ (526908e), applied to master's own Hijack fix (cd3ea89).
701
+ - **ES-NoProj `insert_all` ran, then raised** — through `CachedRelation`'s scope branch the
702
+ insert executed and the caller then got `UnsupportedQuery` ("not a scope"). Inserts and
703
+ upserts now go to the table relation and return its result.
704
+ - **Genesis left a cached "not found" in place on Solid Cache** — an ES-NoProj lookup
705
+ made before a record was imported (in an earlier process, or before the event store
706
+ was reset) caches `nil` for it. Genesis cleared the cache with
707
+ `CachedProjection.invalidate_all`, which cannot delete per-record entries on a store
708
+ without prefix deletion (Solid Cache, the Rails 8 default), so the imported record stayed
709
+ "not found" until the entry expired, up to an hour. Genesis now drops each imported
710
+ record's entry by id. Found by the Aegean benchmark harness, which empties the event
711
+ store between modes.
712
+ - **`CachedRelation#delete_all`/`destroy_all`/`update_all` wiped the whole table**
713
+ (`Lyra::Projections::CachedRelation`, from `stack/latest-ruby-rails-res`) — the
714
+ `method_missing` scope fallback rebuilt them from `model_class.unscoped` and ran them
715
+ before checking the result, so `Model.where(id: ids).delete_all` under ES-NoProj issued
716
+ a bare `DELETE FROM` the whole table (found when the Aegean benchmark's ES-NoProj
717
+ cleanup emptied `registrations` mid-sweep). They now act only on the relation's own
718
+ records. `where(column: [a, b])` (an `IN` with several values) is no longer silently
719
+ dropped.
720
+ - **Projection wrote through the read-from-events override** (`Lyra::Projections::ModelProjection`)
721
+ — with `projection_mode :disabled` (ES-NoProj) a monitored model's `where`/`all` answer
722
+ from the event store. Projection's update and destroy go through
723
+ `model_class.where(...)`, so they acted on event-store records instead of rows: a
724
+ record destroyed in the log is absent there, so its row was never deleted.
725
+ `Rebuild` (`rake lyra:projections:rebuild`) and `AsyncProjectionJob` both project
726
+ through this path in any mode. Projection now always addresses the table.
727
+ - **`invalidate_all` raised on Solid Cache, and keyed `find_by` replayed every stream**
728
+ (`Lyra::Projections::CachedProjection`) — `invalidate_all` guarded prefix deletion with
729
+ `respond_to?(:delete_matched)`, which every cache store answers true; Solid Cache, the
730
+ Rails 8 default, then raised `NotImplementedError`. The raise is now rescued and the
731
+ fallback used. `find_by` took its single-stream path only when the primary key was the
732
+ sole condition, so `find_by(id: 5, email: x)` replayed every stream of the model; any
733
+ lookup that pins the primary key now reads that one stream and checks the remaining
734
+ conditions against the record it yields.
735
+ - **ES-Async projected a record's events out of order** (`Lyra::Projections::AsyncProjectionJob`)
736
+ — each job applied its one event, and a worker pool runs jobs concurrently, so an
737
+ earlier update's job could land after a later one's and roll the row back; a late
738
+ create job could even resurrect a destroyed record. The Olist replay through Solidus
739
+ left 2 of 100 orders at `confirm` whose events ended at `complete`. Each job now
740
+ brings its record up to date with the whole stream (`Rebuild.replay_record`, in place),
741
+ under a PostgreSQL advisory lock held per stream, so jobs converge in any order. Cost:
742
+ each job re-reads its stream, so projections lag more and ES-Async's read-after-write
743
+ window widens (Olist, 100 orders: 151 -> 183 such failures).
744
+ - **Hijack and event-sourcing modes built events before the model's own callbacks**
745
+ (`Lyra::Interceptors::CrudInterceptor`, new `WriteHooks`) — the create/update/destroy
746
+ commands ran in `before_*` callbacks registered on `ActiveRecord::Base`, so ahead of
747
+ every model's own `before_*` callbacks. Values those set were missing from the event:
748
+ in Hijack mode the event disagreed with the row; under event sourcing, where the row is
749
+ projected from the event, they were lost from the table, and DualView could not tell
750
+ (both sides agreed). Replaying Olist orders through Solidus 4.7 under ES-Sync lost every
751
+ order's `guest_token` and every payment's `number`. The commands now run in
752
+ `WriteHooks`, prepended to `ActiveRecord::Persistence`: after all `before_*` callbacks,
753
+ just before the SQL write. A failed command returns false (save returns false, the
754
+ transaction rolls back) instead of throwing `:abort`. PaperTrail is still switched off
755
+ first. SQL statements per write are unchanged in every mode, in order and count.
756
+ - **Hijack creates re-assigned every attribute** (`Lyra::Interceptors::CrudInterceptor`) —
757
+ after the create command, the interceptor called `assign_attributes` with all of the
758
+ record's attributes, although only the reserved ID had changed. That fails on models
759
+ that guard a writer: Solidus makes `StockItem#count_on_hand=` unusable so stock moves
760
+ only through `set_count_on_hand`, and Hijack mode could not create a variant. Only the
761
+ ID is assigned now.
762
+ - **Writes that bypass callbacks went missing from the event log** (`Lyra::StrictDataAccess`)
763
+ — three holes, found by the Olist replay through Solidus 4.7, where DualView then
764
+ disagreed with the table for 17 of 20 orders and every shipment:
765
+ - *Assign, then `update_columns`* published nothing. The old value was read from the
766
+ in-memory attribute, which already held the new value, so no change was seen
767
+ (Solidus's `Shipment#persist_amounts`). The old value now comes from
768
+ `attribute_in_database` and the new one from the record after the write, which also
769
+ covers the timestamps `touch: true` sets.
770
+ - *`touch`* was not intercepted at all (Solidus records completion with
771
+ `touch(:completed_at)`). It now publishes a bypass event; it is not a strict-mode
772
+ violation, since Rails itself calls it for `belongs_to ... touch: true`.
773
+ - *`update_column`* published its event twice (Lyra's override and Rails'
774
+ `update_columns` underneath both published), and the override dropped Rails 8's
775
+ `touch:` keyword, so callers passing it raised `ArgumentError`.
776
+ - **Hijack and event-sourcing modes failed on namespaced models** (`Lyra::CommandHandler`) —
777
+ the event class name was built from the model name without stripping `::`, so
778
+ `Spree::Price` produced the invalid constant `Spree::PriceCreated` and every create
779
+ failed. The failure surfaced only as a parent record left unsaved, with no exception
780
+ (`Spree::Product.create!` returned an unsaved product). Every other event path already
781
+ stripped the separator; this one now does too. Found by the Olist replay through
782
+ Solidus 4.7, where it, not STI or polymorphism, kept Solidus out of these modes.
783
+ - **Optional gems that failed to load were reported as missing** (`lyra.rb`,
784
+ `Lyra::OptionalDependency`) — PAM DSL and PetriFlow were loaded inside a bare
785
+ `rescue LoadError`, so a gem that was installed but could not load (PetriFlow without
786
+ `rexml`) was treated as absent and left half-defined; eager-loading `app/workflows`
787
+ then failed with `uninitialized constant PetriFlow::Workflow`. Only a gem that is itself
788
+ not installed now counts as unavailable; any other load failure raises. PetriFlow now
789
+ declares `rexml` (see `gems/petri_flow/CHANGELOG.md`).
790
+ - **DualView compared times at whole seconds** (`Lyra::DualView`) — `normalize_value` turned
791
+ every time into `iso8601` with no fraction, so `15:49:11.420` in the table and
792
+ `15:49:11 UTC` in a lossy event compared equal. That is how the sub-second loss fixed by
793
+ `Lyra::EventSerializer` passed dual-view checks under Monitor mode. Times now compare at
794
+ microseconds, the resolution of a PostgreSQL timestamp.
795
+ - **Hijack mode filed `Created` under a placeholder stream** (`Lyra::CommandHandler`) — for
796
+ integer primary keys, hijack mode stored the Created event under `"pending-<hex>"` and let
797
+ the database assign the ID afterwards. Nothing linked the two: later events went to the
798
+ real `Model$<id>` stream, so every record's history began with an update and rebuilding it
799
+ from events (dual view, projection rebuild) found no attributes. Hijack now reserves the
800
+ ID up front through `IdGenerator`, as event-sourcing mode does, and the row is inserted
801
+ under the ID the event carries. Hijack is now covered by the event-correctness
802
+ integration tests it had been left out of.
803
+ - **ES-Async lost projections silently** (`Lyra::Projections::AsyncProjectionJob`) — the job
804
+ was enqueued inside the transaction that stores its event, so a worker could run it
805
+ before the commit, find no event, complete, and never project the row (no error, no
806
+ retry). Replaying 40 BPI 2017 applications left 37 missing. The job now sets
807
+ `enqueue_after_transaction_commit`, so it runs only once its event is visible and is
808
+ never enqueued after a rollback. Eventual consistency itself is unchanged. The test dummy
809
+ app now loads `active_job/railtie`.
810
+
811
+ ## [0.8.0] - 2026-08-09
812
+
813
+ The version the PAM article (Requirements Engineering, 2026) describes, released from
814
+ lyra-engine commit `b863d2e`. Not published to RubyGems until 2026-10-10.
815
+
816
+ ### Added
817
+ - **Projection rebuild from the event log** (`Lyra::Projections::Rebuild`) - Reconstruct
818
+ read-model tables from the event streams alone, the load-bearing invariant of event
819
+ sourcing (log is source of truth; tables are a derived projection):
820
+ - `Lyra::Projections::Rebuild.rebuild(Model)` clears the read model and replays each
821
+ stream through the same `ModelProjection.project` path used by live sync/async
822
+ projection, so a rebuilt table matches what live projection would have produced.
823
+ - `Lyra::Projections::Rebuild.rebuild_all` rebuilds every monitored model (or an
824
+ explicit list); `truncate: false` reconstructs streamed rows in place.
825
+ - `rake lyra:projections:rebuild [MODEL=Namespace::Model[,Other]] [TRUNCATE=false]`
826
+ rake task wrapping the above.
827
+ - Invalidates ES-NoProj cached reconstructions so reads reflect the rebuild.
828
+ - Integration tests (`test/projections/rebuild_test.rb`) covering surviving-record
829
+ reconstruction, destroyed-record omission, dual-view consistency after rebuild, and
830
+ `rebuild_all` over monitored models.
831
+ - **PAM DSL** aligned with its formal foundations — see `gems/pam_dsl/CHANGELOG.md`.
832
+ - **Performance characterization** (`docs/PERFORMANCE.md`) — measured overhead of every
833
+ Lyra mode against a plain-ORM baseline: throughput and P95 latency across 1–64 threads,
834
+ the measurement controls used, and a SQL-level account of why Hijack mode outperforms
835
+ Monitor mode. The benchmark harness itself is published on acceptance of the
836
+ accompanying papers.
837
+
838
+ ### Fixed
839
+ - **`:disabled` mode emitted events** (`Lyra::Interceptors::CrudInterceptor`) — the monitor
840
+ callbacks were gated on `lyra_monitored?`, a class attribute carrying no mode check, so a
841
+ model that had ever called `monitor_with_lyra` kept writing to the event store after Lyra
842
+ was disabled. A 150-write workload emitted 150 `event_store_events` rows in `:disabled`
843
+ mode, identical to `:monitor`. `:disabled` is now gated on `lyra_events_enabled?`
844
+ (monitored **and** not disabled), making it a true plain-ORM baseline. Hijack and
845
+ event-sourcing modes still pass the guard and are unaffected — their `after_*` callbacks
846
+ already return early via `@lyra_hijacked`.
847
+
848
+ If you have been using `:disabled` as an off switch on models that call
849
+ `monitor_with_lyra`, those models were still writing events before this fix.
850
+
851
+ The existing `test_disabled_mode_captures_no_events` could not catch this: it redefines the
852
+ model *without* the interceptor, asserting the outcome by construction. The new
853
+ `test_disabled_mode_writes_no_events_for_a_monitored_model` keeps the model monitored and
854
+ flips only the mode — what a migrating team actually does — and counts rows directly.
855
+ - **Empty-stream reads raised `NameError`** (`Lyra::Projections::CachedProjection`) —
856
+ `load_events` rescued `RubyEventStore::StreamNotFound`, which does not exist in
857
+ `ruby_event_store` 2.18/3.0; the real class is `EventNotFoundInStream`. The bad constant
858
+ reference raised instead of catching the empty-stream case, surfacing as silent failed
859
+ operations on the ES (No Projection) read path.
860
+
10
861
  ## [0.6.0] - 2026-01-05
11
862
 
12
863
  ### Added