Changelog
View SourceAll notable changes to evoq will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[1.30.1] - 2026-10-23
1.30.0 was tagged and never reached hex: its docs did not build (a
duplicate @private in evoq_event_router), and the publish stopped there.
1.30.1 is 1.30.0 with that tag fixed; every change listed under 1.30.0
ships here first. CI now builds the docs on every branch, as the publish does.
[1.30.0] - 2026-10-23 (not published)
Changed: OTP 28
evoq is built, tested and documented on Erlang/OTP 28 (CI: 28.4.3); the
README says so. The default fact serialisation uses the OTP json module,
in OTP since 27.
Fixed: every execute span closes (evoq#11)
[evoq, aggregate, execute, start] was followed by neither stop nor
exception when the append failed (a version conflict, an integrity
refusal, a store timeout), so a span tracer held an open span per conflict.
It closes with exception now, carrying the store's reason. A command that
crashes the aggregate (a raising execute/2 or apply/2, an answer
execute/2 may not give, a raising append) also emits exception before
the crash, which is otherwise unchanged.
Fixed: a failure's duration is elapsed time, never negative (evoq#12)
evoq_failure_context:get_duration/1 subtracted two wall-clock readings, so
an NTP step between a failure and its retry handed on_error/4 a negative or
inflated duration. It reads the monotonic clock now: the record carries
first_failure_mono and last_failure_mono next to the wall-clock fields
that dead letters show. The event handler and the projection built their
contexts by hand and bypassed the module; they use
evoq_failure_context:new/3, the new failed/2 and increment/1, and the
first failure starts the duration rather than the moment the event arrived.
Fixed: an idempotency entry lives for its TTL, whatever the wall clock does (evoq#13)
evoq_idempotency took its deadlines from the wall clock: a forward NTP step
of at least the TTL expired every cached result at once, so a duplicate
command re-executed, and a backward step kept entries past their TTL. The
deadlines are monotonic now; the table is node-local and never persisted.
Fixed: a process-manager instance runs on one node (evoq#10)
evoq_pm_router:get_instance/2 took the first member of a pg group, and pg
lists every connected node's members. With the same process manager on two
nodes, an event could be handed to the other node's instance (handled twice,
while this node never started its own) and a {stop, Id} on one node
stopped the other's. It reads this node's members only; the process
managers guide says instances are node-local.
Fixed: one misbehaving process manager never silences the others (evoq#9)
The PM router ran every process manager's correlate/2, and its call into
the instance, with no try, and kept its registrations only in its state.
One function_clause in one correlate/2 (the guide's basic process
manager has no catch-all), or a crash in handle/3 or apply/2, restarted
it empty: from then on every process manager on the node received nothing.
- Each process manager is routed in isolation. What it raises is logged
(
evoq_process_manager_failed), emitted as[evoq, process_manager, exception]and kept as its dead letter; the other process managers still get the event. - A restarted router rebuilds its routing from the type registry, which
holds the registrations and outlives it
(
evoq_event_type_registry:get_all_process_managers/0). - The next event for a crashed instance's id starts a fresh instance, as
the process managers guide says. pg drops a dead member asynchronously,
so the router could still be handed the dead pid, or the instance could
die between the lookup and the call; either way the event was
dead-lettered.
get_instance/2hands out live members only, and anoprocfrom that instance starts a fresh one.
Fixed: one slow or retrying handler delays only its own events (evoq#30)
The event router delivered to each handler in turn with
evoq_event_handler:notify/4, a call that waits for the handler's write,
so a handler that blocked, or slept out a {retry, DelayMs} backoff inside
it, held up every handler and projection of every type on the node. Each
local handler now has a courier that hands it its events in routing order;
the router only passes events on. notify/4 and the strong-consistency
acknowledgments are unchanged: the handler still acknowledges its own write.
Fixed: a checkpoint store is refused next to a read model that does not survive a restart (evoq#1)
A projection skips every event at or below its checkpoint. With a checkpoint
store, that number survives a restart; the rows of an in-memory read model do
not. Paired, a restart loaded "everything up to N is projected" over an empty
read model, skipped the whole history and served nothing, silently. The ETS
read model belongs to the projection process, so a projection crash was
enough, even with the node-wide evoq_checkpoint_store_ets.
evoq_read_modelhas an optional callbackdurable/0and a functionevoq_read_model:durable/1. A read model without the callback is not durable.evoq_read_model_ets:durable/0answersfalse.evoq_projection:start_link/3with acheckpoint_storeand a read model that is not durable fails with{checkpoint_outlives_read_model, ProjectionModule, ReadModelModule}.
Upgrading: a projection that passes checkpoint_store must have a read
model whose module exports durable() -> true. No service in our repos
passes a checkpoint store today. Seen red first in
evoq_projection_durable_read_model_tests.
Fixed: every consumer of a type gets its history, and a process manager gets types no handler consumes (evoq#4, evoq#2)
Backfill ran only for a type's FIRST handler, and delivered to whoever was
registered at that moment. A second consumer of a type that registered late
got no history of it. A projection on [A, B] registering after a handler
on [B] lost all of B's history. A handler that registered while a live
event was queued got that event, then its history, then the event again.
A process manager registered with the PM router only, so a type no event
handler consumed was never routed to it: not at catch-up, not at backfill,
not live.
The store subscription now keeps, per store, which registrants are active for which types. A registrant is a handler process (event handler or projection) or a process manager. An event goes to the active registrants of its type and to nobody else. A registrant that registers after the subscription started gets the history of all its types, to it alone, up to the subscription's own position, and only then becomes active. Each event reaches each registrant once, in store order.
evoq_event_type_registryannounces every registration as{new_registrant, Registrant, Types}and every withdrawal as{registrant_left, Registrant, Types}, to the store subscriptions on its own node.register_listener/1returns the current registrations as{Registrant, Types}. New:register_process_manager/2,unregister_process_manager/1,get_process_managers/1.get_all_event_types/0includes process manager types.evoq_pm_router:register_pm/1registers the process manager with the type registry. New:evoq_pm_router:route_event/3, to named process managers.evoq_event_router:route_event/3takestargets => [pid()].Registrations queued together are backfilled in one pass over the store.
evoq_store_subscription:filter_by_type_set/2is gone: the active set replaces the catch-up type snapshot it served.A registrant names its identity and where its history starts:
evoq_event_type_registry:register_all/3takes#{identity => Term, from => Position}.evoq_event_handlerandevoq_projectionpass{Module, Config}as the identity (an event handler takesidentityin its start options instead, needed when its Config holds a pid or a ref); a projection passes its checkpoint plus one asfrom. Registrations are announced as{new_registrant, Registrant, Types, #{identity, from}}, andregister_listener/1returns{Registrant, Types, Identity}.A handler the supervisor restarts is a new process but the same consumer. Everything this boot routed to its last incarnation reaches the new one marked
replaying => true, so a handler withreplay_policy() -> skipdoes not repeat a side effect. Like the boot's own replay this is at-most-once around a crash: an event in flight to the process that died arrives as replay too.A backfill starts at the registrant's
from. A projection restarting at its checkpoint is not handed its whole history again through the node's event router, and the subscription does not read it again.
The workaround (start the store subscription after every handler has
registered) is no longer needed. Seen red first in
evoq_per_registrant_backfill_tests.
Fixed: a reconnect after a failed pre-subscribe ack projects nothing twice, however long the node was down (evoq#8)
The store subscription acks its catch-up position before it subscribes, so reckon-db's reconnect resumes there. When that ack failed, reckon-db resumed from the older checkpoint and redelivered every event appended while the node was down. The live path recognises a redelivery by its id, but its window held only the last 2000 ids. After more than about 2000 appends while down, the oldest redeliveries were counted as new positions: they were projected twice, and every later position was shifted by as many.
ack_progress/2returns its result.- The pre-subscribe ack is tried once more when it fails.
- When it fails twice, the window reaches back to the checkpoint this boot
read, where reckon-db resumes, and is not trimmed while it is seeded. A
read of it that fails is tried once more. A store without a subscription
yet (
subscription_not_found) is not a failure. - The widened window holds at most
redelivery_window_capids (application env, default 100000). Past that it keeps the newest and logsevoq_redelivery_window_cappedwith the positions a redelivery is no longer recognised at.
Seen red first in evoq_projection_restart_checkpoint_tests (2505 events,
2500 appended while down).
[1.29.2] - 2026-10-23
Fixed: an aggregate without a state module compiles without a warning (evoq#28)
state_module/0 was a required callback of evoq_aggregate, yet nothing in
evoq or reckon-evoq calls it. Every aggregate written from the README or a
getting-started guide (init/1, execute/2, apply/2) compiled with
"undefined callback function state_module/0", and failed to build under
warnings_as_errors. It is optional now; an aggregate that pairs with an
evoq_state module may still declare it.
[1.29.1] - 2026-10-21
Fixed: a multi-event dispatch waits for each handler at its own version (evoq#23)
1.29.0 answered every handler in the required set against the append's LAST
version. A command that appends two events reaches the handler of the first
with the first event's version, and that handler was then held to the second
version until the deadline: every dispatch whose events have different
handlers answered {error, consistency_timeout} with every write already
made. Found by macula-io/macula-realm#92, where a realm's initiation appends
realm_initiated_v1 and realm_admin_appointed_v1 and each has its own
projection.
evoq_consistency:required/2takes the append's first version and answers{Handler, Version}pairs, each handler at the last of the appended events it was handed (a handler subscribed to both is required at the later one).evoq_consistency:await/4takes those pairs and counts a handler as written when its own ack reaches the version it is required for. A handler that acked an earlier event of the same append no longer answers for a later one.- The router passes the append's first version (
required/1is gone; it isrequired/2now), and{handlers, [...]}still answers at the version the dispatch produced, which is what naming handlers rather than events means. - A dispatcher is never in its own required set. A handler that dispatches
from inside
handle_event/4(a cascade) is subscribed to the event type its own command appends, so the registry named the calling process in its own required set, and it could never acknowledge while blocked in the wait: the dispatch ended at the deadline with the write made.await/4now dropsself(), so a cascade returns on the other handlers' acks and writes its own event when it returns. It still waits for every other handler. - Documented, in
guides/event_handlers.mdand this module's own doc: a cascade should dispatch:eventualanyway, because it does not read its own write, and a projection still busy with the event that ran the cascade cannot be waited for by anything.
[1.29.0] - 2026-10-21
Fixed: strong consistency waits for the handlers that were appended to (evoq#23)
consistency => strong was a no-op by construction: its required set came from
evoq_consistency:wait_for/4's default all, which collapsed to an empty
list, and wait_loop/4 answered an empty list at once. No handler and no
projection acknowledged anything either, so even {handlers, [M]} could only
ever end in the timeout. Found through macula-io/macula-realm#92: a live
projection writing to SQLite after the test's sandbox owner had exited.
- The required set is derived, not kept. For each appended event the router
asks the event type registry which processes the delivery reaches
(
evoq_event_router:delivery_targets/1, the same lookup the router delivers by), so nothing is a hand-kept list and a handler that registers later is in the set from the moment it registers. - Handlers and projections acknowledge after a write, and only after one.
evoq_event_handlerandevoq_projectionacknowledge after a successful write, carrying the event's own stream version. A halt or a stop writes nothing and acknowledges nothing. - A drop is not a write. A handler or projection whose own
on_error/4policy let the event go (skip,{dead_letter, _}) sendsevoq_consistency:dropped/2instead of an ack, and the dispatch answers{error, {consistency_dropped, Handler}}at once rather than waiting out its deadline for a write that will never happen. A projection declining an event it does not want ({skip, State, ReadModel}fromproject/4) is a decision, not a drop: it acks. - A drop counts only inside the dispatch's own append. The router passes
the range of versions its append produced (
await/4'sfirst_version), so a drop above it belongs to the next command and one below it was answered for before this append. Inside it, a drop fails the wait even when the same handler wrote a later version of that append. - A handler on another node is not in the required set. The event type
registry is a cluster-wide
pgscope, so it names members on every connected node, while the router delivers only to local pids; waiting for a remote pid would end every strong dispatch in a timeout. close/1takes the unneeded acks out of the caller's mailbox. Every waiter on a stream's group is sent every ack for that stream, and agen_serverthat dispatches in a loop would otherwise accumulate them. An ack already in flight can still land afterclose/1returns.- An ack cannot arrive before anyone is listening. The router opens the waiter, keyed on (store, stream), before the append. A handler fast enough to finish during the append is counted, and the version in the ack means one already past the waited version answers for it while one behind does not.
- The wait is the dispatch's
timeout(five seconds by default) and still answers{error, consistency_timeout}, since the events are appended either way.
Observable changes a consumer floating ~> 1.28 picks up on its next lock
refresh:
- routing metadata carries
store_id. An event routed outside a store subscription has none, and is not acknowledged: there is no stream for a waiter to be waiting on. evoq_consistency:wait_for/4refusesallwith{error, all_needs_events}rather than answeringok: it sees no events, so it cannot know which handlers a dispatch would have to wait for, andokwould claim they had all written. A dispatch asks forstrong, which can resolve it.evoq_consistency:open/2,close/1,await/4,required/1andwaitable/1are new,acknowledge/2acknowledges straight from an event's metadata, anddropped/2reports a handler that dropped one.await/4gains{error, {dropped, Handler}}, which the router answers as{error, {consistency_dropped, Handler}}.strongdoes not wait for process manager instances: which instance handles an event is decided at delivery time (correlate/2, which may start one), so the set does not exist when the command is dispatched. A process manager that needs its own writes seen dispatches and waits itself.
[1.28.0] - 2026-10-21
Fixed and changed: lineage, projection errors, rebuild state and compensation (evoq#22)
Read against the GY-AW reference book. Four defects where evoq did something
other than what it documents, plus the guide statements that no longer
matched the code. No wire format and no on-disk format changes, but several
observable behaviour changes - those are listed under Changed, because a
consumer floating ~> 1.27 picks this release up on its next lock refresh.
Fixed
A process manager threw the conversation away. Its commands now carry
causation_id = the triggering event's id and correlation_id = that
event's correlation, on both dispatch paths, and the compensating commands
carry the same. A self-scheduled message (handle_info/2) has no triggering
event and nothing is stamped.
A projection's {error, Reason} dropped the event forever. on_error/4
was called and the action it returned thrown away; the error went back to a
router that discarded the reply; the next successfully projected event moved
the checkpoint past the failed one. It is now handled exactly as an event
handler is: retry, {retry, DelayMs}, skip, stop or
{dead_letter, Reason}, and the shared default (bounded backoff, then dead
letter) when the projection has no on_error/4. max_retries/0 and
backoff_ms/1 are optional projection callbacks now. A skip or a dead letter
is logged with the event id and the position. A rebuild replays through the
same path, so a failure during a rebuild means what it means live.
A rebuild kept the projection's state. It cleared the read model and
reset the checkpoint, then replayed the whole log on top of state that
already reflected it. The Config is kept and init/1 runs again.
Compensation was documented and never ran. A failed command was logged
and the rest of the saga's commands still ran. The instance now stops at the
first failure, records each successful dispatch in the PM's state, runs the
PM's compensate/2 and dispatches what it returns.
A restarted projection resumes. It used to come back at its checkpoint
and never see the events it missed, because the node's shared $all
subscription had already acknowledged past them. The projection now replays
from its checkpoint before serving anything live - only when a checkpoint
exists, so a first boot still gets the store subscription's backfill and
nothing doubles.
The catch-up runs before the projection registers, and a second pass runs
after. Registration is what makes the event router deliver to the process,
and it delivers with gen_server:call(..., infinity): replaying while
registered would hold up every other handler and process manager on the node
for the length of the catch-up. Replaying first, registering, then replaying
again from the position reached closes the gap without that stall; the
checkpoint dedupe makes the overlap free.
A rebuild that halts says so. A rebuild whose replay stops part-way
already kept halted and answered {error, Reason}; it did not log the halt
or emit [evoq, projection, halted], though the guide and the CHANGELOG
promised both. It reports through the same path a boot halt uses now, once
per rebuild, so an operator cannot be looking at a halted projection whose
halt was never announced.
A projection never refuses to boot, and a halted one stays halted. One
that cannot catch up - a poison event whose on_error/4 answers stop, a
store it cannot read - comes up halted and behind: the state carries the
checkpoint it actually reached and a halted reason, the halt is logged
(evoq_projection_halted_behind) and counted ([evoq, projection, halted])
once, and a rebuild after the cause is fixed recovers it. Before, the resume
ran in init/1 and answered {stop, _}: a statically supervised projection
failed the application's boot, and a restarted one looped on the same event.
While halted it answers every later event {error, {halted, Reason}} instead
of projecting it. That is not politeness: the process is registered, so the
events keep arriving, and projecting them would move the checkpoint past the
event the projection refused - the permanent, silent hole this whole path
exists to remove. A store that answers {error, _} halts the replay the same
way, instead of reporting an empty success.
A raise in project/4 is a failure like any other. It used to escape -
the call sits in the of body of the replay's try, which the catch does not
cover - and killed init/1 (restart loop at boot) or the caller of
rebuild/1. It now goes through on_error/4 with {Class, Reason}, with the
stacktrace in the log, live and during a replay. A raise is the common way a
projection fails (a badmatch, a badkey, a failing {ok, _} = ...), so the
parity between a raise and an {error, _} is what makes "never refuses to
boot" true.
A checkpoint that is not a position no longer kills the projection. A
store answering {ok, undefined} put undefined in the checkpoint, and
0 =< undefined is true in Erlang (numbers sort below atoms): every event
read as already projected, forever, in silence. Anything that is not an
integer is answered as "nothing projected" with a warning
(evoq_projection_bad_checkpoint).
A rebuild whose init/1 fails stops the projection instead of keeping
the old projection state over a read model that has already been emptied, and
the comment, the code and the guide now say the same thing. The supervisor
restarts it and init/1 runs again.
evoq_aggregate:execute_command/2 mints the command's ids, as the
command router already did: a caller that talks to an aggregate directly gets
a command_id and a correlation_id instead of events stamped with
undefined. Both paths are idempotent.
The checkpoint store was probed before it was loaded. A store named in
Opts had usually never been called, so function_exported/3 answered
false and the projection started as if it had no store at all: no checkpoint
loaded, none saved.
The projection checkpoint telemetry is emitted. [evoq, projection, checkpoint] was declared and never fired; it fires on every save now, with
or without a checkpoint store, which is what makes a stalled projection
visible.
Changed
A correlation is minted at dispatch, not at construction.
evoq_command:new/4,5 leaves correlation_id undefined; the command router
mints one when the command reaches it with none (evoq_command:ensure_id/1).
This is what makes "join the conversation that caused me" a decision the
caller can take: a process manager sets the id on the command and keeps it,
and one that does not inherits the triggering event's correlation.
A process manager stops at the first failed command. Later commands the
PM returned in the same batch are not dispatched. {ok, Results} from
evoq_pm_instance:handle_event/3 carries one entry per dispatched command
plus one per compensating command, so it can be shorter than the command
list.
A compensating process manager gets executed_commands in its state -
the commands it dispatched, most recent first - cleared once compensation has
run. A PM that does not export compensate/2 is not touched. The key is in
memory only and does not survive a restart.
A read model's checkpoint starts at -1, not 0. evoq_read_model:new/2
and clear/1 both said 0, which reads as "position 0 is done" and is the
same off-by-one that dropped the first event from load_checkpoint/1.
Positions start at 0, so "nothing yet" sits below them.
evoq_saga_compensation is gone, replaced by evoq_pm_compensation:
record_command/2, get_executed_commands/1 and clear/1. The
per-executed-command build_compensation_chain/2 and execute_compensation/3
are deleted - there is one compensation contract now, compensate(State, FailedCommand).
The consistency option on evoq_event_handler:start_link/3 is gone. It
was stored and never read. Consistency is a dispatch option:
evoq_router:dispatch(Command, #{consistency => {handlers, [...]}}). And
strong waits for nothing today - the required handler set resolves empty -
so the guides now say that.
handler_defaults is removed from the application environment. Nothing
read it.
Guides
Corrected against the code: the delivered version is the subscription's
per-boot counter and stream_version is the event's version in its stream;
aggregate_id -> stream_id where the metadata key is meant, and a read
model is keyed on a business id from the event, not on the stream name;
evoq_dispatcher -> evoq_router /
evoq_command_router:dispatch_with_state/2; evoq_read_model_postgres,
evoq_projection_sup, evoq_subscription_manager and evoq_test.hrl do not
exist; table_name -> name; list/1 -> list/2, new/1 -> init/1;
retry_strategy/0 is not a callback (on_error/4, max_retries/0,
backoff_ms/1 are, and on_error/4 replaces the default policy rather than
extending it); event types are {subject}_{verb_past}_v{N}, not
OrderPlaced.v1; delivery is at-least-once behind the shared $all
checkpoint, not exactly-once, and a checkpoint save is a second write, so the
last event may repeat after a crash; compensation carries the
#evoq_command{} record, not a map; a process manager is not a saga;
evoq_emitter is a contract the application runs, and emitting is the desk's
decision rather than a reflex on every event. projections.md gains the
error-handling section it never had, and aggregates.md tests aggregates the
way the literature does: given events, when a command, then events.
New: session consistency is documented as a named, deliberate concept in
guides/integration_actors.md, guides/state_modules.md and
guides/architecture.md. The reference model says a command returns only a
status; evoq returns the events (dispatch/2) and, with
dispatch_with_state/2, the aggregate state too, so a caller sees its own
write without waiting for a projection. That stays; it is the point.
[1.27.0] - 2026-10-19
Added: a process manager instance can be woken by its own message, and evoq can mint a stream id (mcl-chess#1)
Two additive hooks, both optional callbacks. No wire format, no on-disk
format and no existing process manager, adapter or consumer changes:
~> 1.26 floats here and nothing in a consumer has to move.
A PM instance dropped every message it was sent. handle_info(_Info, State) ignored all of them, so a process manager that scheduled a message
to itself from handle/3 (a clock deadline via erlang:send_after, which
is exactly what a per game chess clock is) scheduled it and never heard it.
handle_info/2 is an optional evoq_process_manager callback now, the
same shape evoq_event_handler has had since 1.24.0. It answers
{noreply, State}, {noreply, State, Commands} or {stop, Reason, State};
commands are dispatched exactly as handle/3's are. The atom timeout
stays evoq's own idle timer and is never forwarded, so the existing idle
behaviour is unchanged for every PM written before this release. A PM
without the callback that is sent a message logs a warning naming itself
and the message, because that silence is what hid the gap.
evoq had no way to mint a stream id. A service that needed a fresh
aggregate id (a game, a session) had to reach past evoq to the store layer
and hand-roll an id in the store's private format, a second copy of a
contract that belongs to the backend. new_stream_id/1 is an optional
evoq_adapter callback, exposed as evoq_event_store:new_stream_id/1, so
the format keeps one definition, in the adapter; reckon_evoq implements
it by delegating to reckon_gater_stream_id:new/1 from 2.8.0 on. An
adapter written before the callback keeps working, and a caller that asks
it to mint gets {adapter_cannot_mint_stream_ids, Adapter} naming it
rather than an undef under the caller's command handler.
Both hooks are red-first in test/unit/evoq_pm_instance_info_tests.erl
and test/unit/evoq_event_store_new_stream_id_tests.erl, and the how and
the why are in guides/process_managers.md (A Deadline of Your Own) and
guides/adapters.md (Minting a stream id).
[1.26.4] - 2026-10-15
Fixed: one aggregate per stream, and a call that waits on a load answers instead of exiting (evoq#17)
Two things a consumer can see change. No wire format and no on-disk format
changes, and nothing a consumer has to do: it is a patch, and ~> 1.26 floats
here with no change in the consumer.
One stream gets one aggregate, however many dispatches arrive together.
evoq_aggregate_registry:get_or_start/2,3 looked the aggregate up in pg and,
on a miss, started one. The two steps were not atomic, and the guard written to
cover the gap could never run: the partition supervisors are
simple_one_for_one, so supervisor:start_child always adds another child and
never answers {error, {already_started, Pid}}. Two dispatches arriving
together for one stream could therefore be handled by two processes. Both
replayed the stream, and the second appended on the state it read before the
first append landed, so a legitimate command came back as
{error, wrong_expected_version}. The pg lookup stays the fast path; a miss
goes through the registry's own gen_server, which re-checks before it starts
anything. Supervision is untouched: the partition shape is what
evoq_memory_monitor and every consumer's aggregate lifecycle sit on.
A store that cannot be read answers the caller rather than taking it down.
The snapshot read and the replay used to run inside init/1, on the path every
start is serialised through, so one stream's replay was every other cold start's
wait, and a store read that never answered held the whole node's starts with no
timeout to end it. The load is a handle_continue/2 now: init/1 joins the
registry and describes the stream, and the load runs after that has answered. A
command that arrives in the meantime is neither lost nor served early: it queues
in the aggregate's mailbox, because a gen_server does not read its mailbox until
its continue has finished.
That shape change is what made the calls that wait on a load unsafe, and all of
them answer with a value now. Since the registry hands out its process before
the stream is loaded, a caller of execute_command/2,
execute_command_with_state/2, get_state/1 or get_version/1 is waiting on
that load: a store read that fails kills the aggregate mid-call, and a cold
stream whose replay outruns gen_server's 5 s default ends the call with a
timeout. Both used to end the caller with an exit, which the command router's
own {error, _} clauses never see, so a store problem arrived as a crash in a
service that was only reporting one. All four answer {error, Reason} now, and
get_state/1 and get_version/1 say so in their specs. The shipped caller that
had already written the right clause and could not receive it,
evoq_test_assertions:get_aggregate_state/2, gets one now.
The registry's own start call was the one wait with no bound at all: infinity
meant a wedged registry or supervisor produced callers that never returned. It
is bounded now and answers {error, timeout}.
The tests pin the whole shape: ten concurrent callers for one stream get one
pid, against a store that answers (with no store configured every start failed
and the assertion held over an empty list), and a store that cannot be read
gives execute_command/2, get_state/1 and get_version/1 an error rather
than an exit, with the aggregate gone afterwards.
[1.26.3] - 2026-10-15
Fixed: a declined command crashed the aggregate instead of answering (evoq#16)
execute/2 declining a command by returning the documented {ok, []}, the
sanctioned no-op a handler uses for a command it has already honoured, killed
the aggregate process with a badarg and gave the caller an exit instead of
{ok, Version, []}. append_events/5 has an explicit clause for the empty
event list, so the outcome was intended; the reply path then called
hd/1 on it, at both reply_after_append/5 and
reply_after_append_with_state/5. Nothing produced an event, so nothing is
what should reset the event timer: the timeout now comes from a shared helper
with an [] clause that uses after_command/1, which is what this module
already does for a read.
append_events/5 also answered a hardcoded version 0 for the empty list
rather than the stream's own, which is a version the stream may never have
been at. It answers the stream's version now, so a caller that reads a version
off a no-op reads the truth. No working caller can have depended on the old
value: the reply crashed before it was ever returned.
A declined command is also not something to wait for. Under {handlers, [...]}
consistency the router waited for handlers to acknowledge the version a command
produced; a command that produced nothing has no such version and runs no
handler, so the caller sat out the whole timeout and got
{error, consistency_timeout} where the aggregate had said {ok, Version, []}.
It answers at once now. strong never waited here for a different reason:
evoq_consistency:wait_for/4 turns its default all into an empty
required-handler list and answers that immediately.
Specs that said a version is non_neg_integer() were telling callers something
false: a stream's version is -1 until its first append, which is what
evoq_aggregate:rebuild_from_events/3 already reports for an empty stream.
execute_command/2, execute_command_with_state/2, get_version/1,
evoq_command_router:dispatch/2, dispatch_with_state/2,
evoq_consistency:wait_for/4 and acknowledge/4 say integer() now.
Any consumer that uses {ok, []} as its refusal is affected and needs this
release. At least one deployed service does, for a redelivered event, so its
aggregate crashes on redelivery today. It is a patch: ~> 1.26 floats here
with no change in the consumer.
No test in this repository had ever driven execute/2 to {ok, []}, which is
why a documented path could be broken in a released library.
test/unit/evoq_aggregate_noop_tests.erl pins it now: the reply, the unchanged
version, that the aggregate is still alive and still answering, and that an
accepted command still produces its event, without which the test would pass on
an aggregate that appends nothing at all.
[1.26.2] - 2026-10-07
Fixed: a clean stop never acked the subscription's position (macula-realm#64)
evoq_store_subscription acks its $all checkpoint every 200 events and,
by its own documentation, on a clean stop, in terminate/2. But it did not
trap exits, and a supervisor stops a child with exit(Pid, shutdown): a
gen_server that does not trap exits dies of that without running
terminate/2. So no stop ever acked, and every restart, clean or not,
delivered up to 199 already-handled events again, without the replay flag.
In macula-realm that reran handlers a replay_policy :skip exists to hold
back.
init/1 now traps exits, so a supervisor's shutdown runs terminate/2 and
acks the position routed so far. An exit from any other linked process still
stops the subscription with that reason, as before, so its supervisor
restarts it. A kill (SIGKILL, a brutal_kill shutdown) still skips
terminate/2; that case is what the every-200 ack bounds.
[1.26.1] - 2026-09-26
Fixed — telemetry durations came from the wall clock, in microseconds (evoq #7)
Every duration (aggregate execute, command dispatch, event handler,
projection, evoq_telemetry:span/3) was the difference of two
erlang:system_time(microsecond) readings. That is the wall clock, which an
NTP step or a time warp moves, so a duration could be wrong or negative; and
it is microseconds, where telemetry's convention, and any consumer that
follows it with erlang:convert_time_unit(D, native, _), expects native
units: a 1000x misreading on Linux. reckon-db had the same unit bug in its log
handler, fixed in 5.11.10.
Durations now come from the monotonic clock in native units, through
evoq_telemetry:monotonic_start/0 and duration_since/1. Every
system_time measurement (the start events, [evoq, projection, event] and
the integrity-violation event) is native wall-clock time
(erlang:system_time()), where it was microseconds (milliseconds for the
integrity event). The aggregate's idle-timeout arithmetic runs on the
monotonic clock too.
A consumer that read duration or system_time as microseconds or
milliseconds must convert from native now. Nothing in our services
attaches to evoq telemetry. delay on [evoq, handler, retry] stays
milliseconds.
Documentation — the telemetry tables match what is emitted
The event handler and projection guides listed events evoq never emits
([evoq, handler, start|stop|exception], projection checkpoint and
rebuild) and wrong measurements, and left out the per-event handler events
that are emitted. Both tables now come from the source, with the units.
[1.26.0] - 2026-09-26
Fixed — two process managers correlating on the same id reached each other's instance
evoq_pm_router found a process manager's instance by event type and
process id, with no process manager module in the key, and every instance
registered under each of its types. Two process managers on the same event
type correlating on the same id (two process managers keyed on an order id,
the common case) shared that key: the second one's {continue, Id} found the
first one's instance, and the first handled, and folded into its own state,
events meant for the second, which never saw them. Instances are now keyed
by process manager module and process id, one registration per instance.
Behaviour change for anyone running two process managers that correlate
on the same id: each now gets its own instance and state. Code that relied
on reaching the other's instance was depending on the defect.
evoq_pm_router:register_instance/3, unregister_instance/3 and
get_instance/2 take the process manager module where they took an event
type; nothing outside evoq calls them.
Fixed — a stopping instance unregistered its siblings
evoq_pm_instance:terminate/2 removed every member of its instance group,
so after a second {start, Id} for the same process manager and id (which
always starts a new instance) the first instance stayed alive but could no
longer be found once the second stopped. An instance now leaves its own
registration only (evoq_pm_router:unregister_instance/3 takes the pid).
Fixed — module-handler registration answered ok and registered nothing (evoq #3)
evoq_event_type_registry:register_handler/2 and unregister_handler/2
stored nothing and replied ok, so a caller believed a module registered
that would never receive an event. They now return {error, not_supported}.
A handler is a process started with evoq_event_handler:start_link/2, which
registers itself. Both are marked -deprecated, so an xref
deprecated_function_calls check flags callers and the hexdocs show it (the
compiler does not warn, and rebar3's default xref checks do not include it),
and they go in 2.0.0 with the event router's unreachable clause for module
handlers. evoq_event_type_registry:get_handlers/1 is specced as returning
pids only, which is all it ever returned.
Removed — evoq_pm_sup:start_pm_instance/2
It called evoq_pm_instance:start_link/2, which does not exist, so it failed
with undef on first use; nothing called it. Instances start through the PM
router.
Documentation — the process manager guide matches the runtime
handle/3 runs before apply/2 (the guide said the reverse, and its test
example did it that way); there is no timeout callback (the guide documented
one); telemetry metadata is pm_module and process_id; process ids are
binaries. New: an Instance Lifetime section on {start, _} always starting a
new instance, {stop, _} dropping the event when no instance exists, and why
every process needs a terminal event.
Known, not in this release
- The PM router has no isolation: one crash in a process manager's
callbacks, or one event its
correlate/2does not match, restarts the router empty: every process manager on the node stays silent until the node restarts or each is registered again withevoq_process_manager:start/2(evoq #9). The router's own restart is not recovery. - Process-manager instance routing is cluster-wide, unlike handler routing (evoq #10).
A type only a process manager declares is still not delivered (evoq #2): the store subscription routes a type only when an event handler consumes it. The fix has to come with per-registrant backfill (evoq #4), because making a process manager a type's consumer on its own lets a handler that registers just after it on the same type miss history. Both land together in 1.27.0. Until then, give such a type an event handler.
[1.25.1] - 2026-09-26
Fixed — a decision over more than 1000 matching events read only the oldest 1000 (evoq #6, part one)
evoq_decision_runtime read a decision's context with one store read by
tag, event type or payload, limited to 1000, and the store returns the
oldest matching events and cannot page. A context with more held only the
oldest 1000. What that did, against reckon-db:
- A decision that rejected did so on the oldest 1000, and the rejection reached the caller as if it were decided on the whole context.
- A decision that produced events usually failed as
{error, retry_budget_exhausted}: the store's append precondition sees every matching event, so a cutoff below the unseen tail always metcontext_changed. It could commit only at the truncation boundary under concurrent appends, when a later sequence number sorted inside the oldest 1000 byepoch_us.
Reads now ask for 1001, and load_context/2 refuses with
{error, {context_truncated, Leaf, 1000}} when one comes back full, where
Leaf is the filter whose read was cut (the filter itself when flat). The
stateless loop and the stateful actor both refuse. Exactly 1000 matching
events is still a whole context. The limit counts every event the read
returns, before the _dcb filter, and applies per leaf of a compound
filter.
Fixed — the stateful decision actor decided on a model it knew was stale
After a refused append (context_changed) whose reload then failed, the
actor kept its old model and cutoff and decided the next commands on them,
returning any rejection they produced without reading the store. It now
drops the model on a failed reload, so the next command reads the store
first. With the refusal above a failed reload is a steady state, not a blip,
because a context only grows.
Documented — the adapter's limited-read contract
evoq_adapter states what the reads by type, tag and payload must do with
BatchSize (at most that many, global order, oldest first, never capped
lower), and declares the read_by_tags/4 callback evoq_event_store already
called without it. It is required: an adapter without it failed with undef
on the first tag decision, and now gets a compile warning instead.
This release refuses rather than pages. Paging needs a resumable position on the store's reads by tag, type and payload (reckon-gater, reckon-db, reckon-evoq), which is part two of #6.
[1.25.0] - 2026-09-25
Fixed — a projection's saved checkpoint pointed at a different event after a restart (evoq #1, part one)
A projection checkpointed on version from the store subscription, a
per-boot counter that restarts at 0 and counts only events with a handler.
With a checkpoint store, a restart that added or removed a handler for any
other type renumbered the same events: removing one skipped events appended
while the node was down, adding one projected already-projected events
again. Proved with a real two-boot test before the fix: [1,2,3,4,5] where
[1..7] was due, and [1,2,3,4,5,4,5,6] where [1..6] was.
The store subscription now puts global_position in the metadata of every
event it routes, from catch-up, backfill and the live feed: the event's
index in the store's global log. A projection skips and checkpoints on it,
falling back to version for events given to notify/4 directly. A
projection takes its events from one of the two, not both. version is
unchanged for anything that reads it.
Changed — a late handler's types are backfilled in one pass, in store order
A handler registering after the store subscription's catch-up (every
handler in an application that boots after the one owning the store) gets
its history through backfill, which ran once per type: the whole history of
the first type, then of the second. With checkpoints on the global
position, a projection on several types would then have skipped every event
of its second type below the first type's last one (A1 B1 A2 B2 A3 B3
projected as A1 A2 A3 B3). Projections and event handlers now register all
their types at once with the new
evoq_event_type_registry:register_all/2; store subscriptions get one
{new_event_types, Types} message in place of one {new_event_type, Type}
per type, and backfill those types in one pass in global order. A late
multi-type projection now also receives its history in store order rather
than grouped by type.
Fixed — a duplicate live delivery moved every later position
reckon-db arms its trigger before its own catch-up and documents that an
event written in between may be delivered twice; a reconnect whose
pre-subscribe ack failed redelivers from the older checkpoint. The store
subscription counted each delivery as a new position, so after a
duplicate every later live event sat one position too high, and after a
restart the checkpoint covered an event appended while the node was down,
which was skipped (and the duplicate projected twice). It now drops an
event whose id it consumed among the last 2000 (seeded with the tail of
its own catch-up) before positioning or counting it. That also keeps the
$all checkpoint acked to the store and the next boot's replay boundary
from drifting.
Fixed — a projection rebuild read one batch and checkpointed on the stream version
evoq_projection:rebuild/1,2 read the projection's types with one
1000-event read, so a larger store rebuilt partially (the rebuild half of
evoq #6), and checkpointed on each event's per-stream version, which repeats
across streams. It now pages through the store's global log and checkpoints
on global_position, the same number the live feed uses.
A rebuild also handed project/4 a different event shape from the live
feed: the event's data flattened into the top level, and metadata of only
stream_id, version and replaying. It now delivers exactly what the
store subscription delivers, data nested and the event's own metadata,
plus global_position and replaying => true. A projection written against
the live feed crashed on rebuild before; one written against the old rebuild
shape only must change.
Fixed — evoq_event_store:read_all_global/3 on an adapter without the callback
The fallback returned the whole store for every offset, so a caller paging
it (the store subscription's catch-up, and now rebuild) never saw a short
page on a store of at least one batch. It returned maps where the callback
returns #evoq_event{} records, and the store subscription routes only
records, so on such an adapter it routed nothing. And it dropped a stream
it could not read, which would renumber every event after it. It now
returns the requested page of the adapter's records in epoch_us order,
and {error, {read_all_failed, StreamId, Reason}} when a stream cannot be
read. It reads every stream for each page; it is a fallback, not a path to
run a large store on.
Upgrading a projection with a persisted checkpoint
A checkpoint saved by 1.24.x is the old per-boot counter, which is always
below the length of the store at the boot that saved it. On the first boot
of 1.25.0 nothing appended while the node was down is skipped, but every
event of the projection's types with a global position above the old number
is projected once more, which for a type that is a small part of the store
can be most of its history. A projection
that is not idempotent (counters, appended lists) is corrupted by that.
Delete its checkpoint before upgrading and let it rebuild, or call
evoq_projection:rebuild/1 after. Projections without a checkpoint store
start from -1 on every boot and are not affected.
What the global position assumes
The position is an index into the store's global order. It names the same
event in every boot as long as the store keeps that order: with reckon-db,
while epoch_us stamps are monotone in commit order (no clock stepping
back, no cluster nodes with differing clocks, no two concurrent appends
committing in the reverse order of their stamps) and no event is ever
removed (delete_stream, scavenge). Each violation moves later positions
by one or more, and a restart then skips or repeats that many events. The
$all checkpoint acked since 1.23.3 carries the same assumption. A commit
sequence number in the store is what would make it exact.
Live positions are counted by the store subscription, not read from the store. An event appended after catch-up's last read and before reckon-db arms its live trigger reaches evoq through reckon-db's own catch-up, and can arrive after an event appended just after arming. The two are then numbered in the wrong order, and a crash between projecting them skips one and repeats the other on the next boot. Dropping duplicates does not cover this.
Known gaps, for 2.0.0 (evoq #4)
Backfill runs only for a type's first handler ever. A handler that registers late for a type another handler already covers gets none of that type's history.
And one shape is worse than in 1.24.2: a late projection on a covered type
and a new one. Handler H on [B] registers, then projection P on [B, C]. H's
backfill pass over B delivers B's history to P too and moves P's
checkpoint to B's last position; P's own pass over C then skips every C
event below that. On 1.24.2 P got them, because the per-boot counter kept
rising across passes. It needs the subscription to start before the
handlers' applications and one handler per type set to overlap another's;
the same happens when a projection registers during catch-up for a type
catch-up already delivers. The 2.0.0 fix backfills per registrant, all its
types, to it alone. Until then, start the store subscription after every
handler has registered (from the application listed last), which makes
catch-up deliver everything in store order and no backfill run, or call
evoq_projection:rebuild/1 once registered. See guides/projections.md.
[1.24.2] - 2026-09-24
Documentation — what "called during replay" means for apply/2
guides/aggregates.md said apply/2 is "called during replay - don't assume
order", and its example read the clock inside apply/2. Both were wrong.
Replay folds apply/2 over the stream in version order, oldest first, and a
load from a snapshot runs apply/2 from the from_snapshot/1 state on the
events after the snapshot's version. What apply/2 must not assume is that it
runs once: it runs after execute/2 and again on every later load, so state
comes from State and Event alone. The example now decides opened_at in
execute/2, puts it in the event, and apply/2 copies it.
guides/state_modules.md and the apply callback docs in evoq_aggregate
and evoq_state say the same.
A new test, evoq_aggregate_replay_determinism_tests, replays one stream twice
and from a snapshot and checks the three states equal the one execute/2 and
apply/2 built live. It keeps the guide's old example and shows that every
load of its stream gives the account a different opening time. Prompted by a
question on the Elixir forum.
[1.24.1] - 2026-09-23
Fixed — an aggregate over 1000 events could never accept another command
evoq_aggregate replayed a stream with ONE store read of 1000 events and
no loop. A longer stream loaded at version 999, and every command on it
failed {wrong_expected_version, 999, N}, forever, on both the load path
and the rebuild path. Found live on macula-realm (a 1173-event aggregate).
Replay now reads 1000-event pages until a short one, and refuses
({replay_not_advancing, ...}) instead of looping if a store ignores the
start version.
Fixed — resuming from a snapshot applied its last event twice
Replay after a snapshot at version V started at V (reads are inclusive), so
event V was applied again on top of the snapshot. It now starts at V + 1.
Only aggregates exporting snapshot/1 and from_snapshot/1 were affected.
Known limit, not fixed here
Reads by event type and by tag (evoq_decision_runtime, projection
rebuild) return at most their batch size (1000), and reckon-db offers no
offset for them, so they cannot page from evoq. Tracked as an issue.
[1.24.0] - 2026-09-23
Fixed — a restart re-fired everything the node had already reacted to
evoq_store_subscription rescans the whole store on every boot, so
in-memory read models can rebuild, and routed every stored event to every
handler and process manager as if it were new. A process manager dispatched
its whole command history again and a side-effecting handler repeated every
side effect, on every restart. Measured live: 1174 re-publishes on one
macula-realm restart; the same in mcl-sentinel.
The persisted $all checkpoint (acked since 1.23.3) is now read at boot and
is the boundary. Events below it are replay and carry
replaying => true in their metadata, from catch-up and from backfill
alike. Events at or above it were appended while the node was down and are
delivered exactly as before. Live metadata is unchanged.
- Process managers: for replay,
handle/3andapply/2still run, so an instance rebuilds exactly its state, but the commandshandle/3returns are not dispatched. - Event handlers: see Added. Undeclared handlers keep receiving replay, as before.
- Projections: unchanged.
Added — evoq_event_handler optional callback replay_policy() -> skip | deliver
skip for a handler with side effects: it no longer sees replay. deliver
for a handler rebuilding in-memory state. Not declaring it delivers, as
before, and logs one warning per boot naming the handler the first time it
is handed replay (what => evoq_handler_received_replay_without_policy).
Any other return is an error.
Consumers with side-effecting handlers should declare skip: until
they do, they re-fire on restart exactly as on 1.23.x, and the warning names
them.
Fixed — a handler's own messages never reached it
evoq_event_handler's handle_info/2 dropped every message, so a handler
that scheduled one to itself (a retry via send_after, as macula-realm's
delegation publisher does) never saw it and the retry silently never ran.
Added — evoq_event_handler optional callback handle_info/2
Receives every other message the handler process gets, gen_server's shape:
{noreply, NewState} | {stop, Reason, NewState}. A handler without it that
is sent a message logs a warning naming itself and the message
(what => evoq_handler_has_no_handle_info).
Fixed — a projection with a checkpoint store dropped the first event it was ever handed
A store holding no checkpoint yet (load/1 returning {error, not_found},
i.e. every projection's first boot) or a store without load/1 loaded as
0, and since routed events are numbered from 0, event 0 read as already
covered and was skipped without a word. Both now load as -1, the value
do_rebuild/1 already used for "nothing projected".
evoq_projection:get_checkpoint/1 therefore returns -1, not 0, before
anything is projected when a checkpoint store is configured (it already
returned -1 without one), and is typed integer().
Build — project plugins pinned
rebar3_hex 7.2.0, rebar3_ex_doc 0.3.0, rebar3_proper 0.12.1,
rebar3_lint 6.0.0. rebar3_hex 7.3.0 ignores HEX_API_KEY and cannot
publish from CI; the pin is inside the tag, so an unpinned tag could not have
been rescued by a re-run.
Tests — every unit test module runs
eunit_tests was a hand-kept module list, and two modules had never run in
CI (evoq_aggregate_registry_tests, evoq_lineage_tests). It is now
{dir, "test/unit"}. Two isolation bugs that the list had hidden are fixed.
Known limits
- After a crash, up to 199 events delivered since the last ack arrive as new again. Exactly-once would need a checkpoint per handler.
- A node whose checkpoint was never moved (evoq before 1.23.3) sees its history as new once more, on its first boot with 1.24.0.
- An unreadable checkpoint is treated as "nothing is replay" (at-least-once) and logged.
[1.23.4] - 2026-09-21
Fixed — event_type/0's declared type was narrower than this library's own runtime
-callback event_type() -> atom(). is now atom() | binary().
Nothing about behaviour changes. The spec was simply wrong about what evoq
accepts: evoq_aggregate:resolve_event_type/1 has always had an is_atom
clause that converts to binary and an is_binary clause that passes through,
and every layer below it is binary-only — {event_type, binary()} in
evoq_decision, the [by_event_type] index read in
evoq_decision_runtime, event_type :: binary() in reckon_gater's shared
event record, and is_binary guards on reckon_db's index paths. The
atom-only spec was the single outlier in the whole stack.
The cost of it was borne by consumers: every Elixir event module returning a
string (the wire and event-store discriminator format the pipeline already
depends on) drew a dialyzer callback_type_mismatch it could not fix without
changing stored event data. macula-realm alone carried twelve
.dialyzer_ignore.exs entries for it, which between them masked the only
callback this behaviour actually checks.
Purely additive: Erlang implementations returning atoms, which is what the hecate-sdk code generator emits, were correct before and are correct now.
[1.23.3] - 2026-09-05
Fixed — every event was delivered twice to every projection/PM, on every boot, since 2026-03-19
evoq_subscriptions:ack/4 exists and is exported but was never called
anywhere in this module. Two consequences, both real in production
(confirmed live examples: hecate-tube's clip-publish/withdraw PMs,
hecate-victron's mesh-publish PM, hecate-sentinel's threat projection —
each fires an unconditional, undeduplicated side effect):
Every fresh
$allsubscription always passedstart_from => 0.subscribe_to_all/2(now/3) hardcoded this regardless of whatcatch_up_historical/1(now/2, returning{Seq, Offset}) had already scanned moments earlier in the SAME boot. The persisted subscription's own catch-up (reckon_db_subscriptions:maybe_start_catchup/2) then redelivered the entire store a second time, immediately, every single first boot.Because
ack/4was never called, the persisted checkpoint never advanced past that initial 0.reckon_db_subscriptions' reconnect path (reregister_subscriber/4) resumes from the PERSISTED checkpoint on every restart — not from anything this process passes — so it replayed the entire store's history again on every restart too, not just the first boot.route_event_with_seq/2's ownMetadata.version(a monotonically-increasing per-delivery counter, overwritten specifically so $all subscribers get ordered checkpoints) meansevoq_projection'sEventVersion =< Checkpointguard andevoq_event_handler's checkpoint tracking can NEVER recognize a duplicate delivery as one — a duplicate always gets a new, higher number. This was not a narrow race window; it was the normal, expected shape of every boot, and evoq's own advertised idempotency mechanism provided zero protection against it.Fixed both ends:
catch_up_historical/2now returns{Seq, Offset}-- Seq is how many events were actually routed (had a handler; unchanged semantics), Offset is how many were scanned, routed or not (the real global store position).subscribe_to_all/3is handed Offset0 explicitly and passes it asstart_from, closing the gap for a subscription's own very first ever create.handle_continue/2now acks Offset0 to the persisted subscription BEFORE callingsubscribe_to_all/3-- this is the one that actually closes every RESTART, not just the first boot:start_fromonly affects a subscription's initial creation (reckon_db_subscriptions:store_and_setup/6); on a reconnect,reregister_subscriber/4ignores it entirely and resumes from whatever checkpoint is already persisted. Acking here moves that checkpoint to Offset0 before the reconnect ever reads it, so its own catch-up (maybe_start_catchup/2) finds the store already fully covered. An adversarial review caught that the first cut of this fix only had thestart_fromhalf wired in -- both existing tests passed even with this exact call stubbed to a no-op, because a subscription's first-ever create doesn't need it. Confirmed empirically: without this line a restart still redelivered the gap since the last boot; with it, nothing.handle_info({events, Events}, State)continues to ack afterward too, so a long-running instance's checkpoint doesn't go stale -- coalesced to once every 200 events consumed rather than once per message, since acking is a synchronous, retrying, Raft-committing gateway call and doing it per live event would be a real throughput ceiling under sustained load that delivery never had before. A failed ack is logged and non-fatal.
New tests boot a real
evoq_store_subscriptiongen_server against a fake adapter modelingreckon_db_subscriptions' actual checkpoint semantics exactly (read from its source, not guessed: a fresh subscription honors the caller'sstart_from; a reconnect ignores it and resumes from the persisted checkpoint instead; acking a subscription that doesn't exist yet is a no-op, not a silent create -- getting this last one wrong would let a test pass for the wrong reason, which is exactly what happened on the first pass before review). Three scenarios: a fresh boot over a 25-event store redelivers 0 events via the persisted subscription's own catch-up; a simulated restart (subscriber pid killed without a clean unsubscribe, store grown by 10 events while "down") redelivers 0 (not the original bug's whole-store replay, and not the 10-event partial fix an earlier version of this change stopped at); and acking directly against the realevoq_subscriptionsAPI confirms an existing checkpoint moves and a missing one no-ops.Note what this fix deliberately does NOT change:
catch_up_historical/2(Phase 1) still rescans the whole store fresh on every single boot, by original design, independent of any checkpoint -- a side-effecting handler still fires for the entire history once per restart, same as before this fix. What's fixed is the SEPARATE, redundant redelivery the persisted$allsubscription's own catch-up used to add on top of that, which is the part that was silently doubling everything.
[1.23.2] - 2026-09-05
Fixed — a slow catch-up replay blocked this process's own start_link, not just the caller
evoq_store_subscription:init/1 ran the full historical catch-up replay
(catch_up_historical/1) synchronously before returning, which meant
gen_server:start_link — and therefore this process's supervisor's own
start_link — stayed blocked for the whole replay. Against a store with real
accumulated volume this is not fast (reckon_db 5.11.1's cache alone measured
~110-130ms/page against a real ~87k-event evidence store, and the full
scan-and-sort for the first page was ~1.5s even on capable hardware — see
reckon_db 5.11.3's changelog for the read-side fix). On weaker or loaded
hardware that is long enough to plausibly trip an external liveness
expectation (a container healthcheck, a supervisor timeout) mid-replay;
killing the node there restarts catch-up from offset 0 with nothing carried
over — a crash-restart loop that never gets past the first page, matching
exactly what was observed in production (hecate-sentinel, stopped
2026-09-01 after this pattern pushed CPU past 300%+ and kept climbing).
Moved catch-up into handle_continue/2 ({continue, catch_up} returned
from init/1): this process now reports "started" to its supervisor
immediately, and the replay runs right after — still before this process
handles its first real message, so event ordering is unchanged — but
without blocking start_link itself. New test asserts init/1 returns in
under 100ms against a store id that would hang or error if init/1 still
touched it directly, proving catch-up genuinely moved out.
A second bug this surfaced (caught by adversarial review before release,
not by the fix's own author): making catch-up asynchronous means a
handler can now register for a type WHILE catch-up is running, which was
never previously possible — init/1 used to block the whole node, so no
sibling application could register a handler mid-replay. catch_up_loop/4
routed by calling evoq_event_type_registry:get_handlers/1 live, at
delivery time; a type gaining its first handler mid-burst would then get
routed by catch-up for every event scanned AFTER the registration landed,
and delivered again in full when the queued {new_event_type, EventType}
notification ran backfill_event_type/3 right after catch-up finished —
double delivery for exactly the events that happened to be scanned after
the race. Fixed by gating catch-up's routing on known_types, the type
snapshot register_listener/1 already returned in init/1 (previously
discarded) — a type absent from that snapshot is now always skipped by
catch-up, unconditionally, deferring its entire history to the backfill
sweep exactly as if the handler had registered after catch-up finished
outright (one delivery, not a mix of two paths). New tests cover
filter_by_type_set/2 directly, including the exact "type present at
scan time, absent from the original snapshot" case.
Also: catch_up_loop/4 now logs elapsed time per page, not just event
counts ([evoq] Catch-up ~s: routed ~b events (seq ~b -> ~b) in ~.1fms) —
the gap between reckon_db 5.11.1's synthetic 10k-event benchmark and its
real ~87k-event behavior was only found by rebuilding a from-scratch repro
against real data; per-page timing in the normal logs would have made
that unnecessary.
[1.23.1] - 2026-08-25
Fixed — a handler registering after catch-up never saw events already appended
evoq_store_subscription replays a store's full history exactly once, at
subscription startup (the "catch-up" phase), routing each event to whichever
handlers are registered for its type at that moment. A handler for a type
that registers after catch-up already ran — the normal shape for any
multi-application/umbrella architecture where the application owning the
store's subscription boots before the applications registering handlers for
it, not an edge case — never received the history appended before it
subscribed. The module already listened for {new_event_type, EventType}
(fired once, when a type gets its first-ever handler) but only logged
"already covered by $all" and did nothing: true for events appended after
the subscription started, false for anything appended before a late
handler's type had any handler at all.
Confirmed live in hecate-whiteboard (a real 3-app CMD/PRJ/QRY umbrella)
2026-08-25: a container restart with 13 real historical events logged
handlers=0 for every one during catch-up, then the exact "already covered"
message once the PRJ app's projection handlers registered ~0.8s later — the
read model came back completely empty on every restart from then on, even
though the underlying event store was untouched.
Fix: on {new_event_type, EventType}, evoq_store_subscription now
backfills history for that one type internally (reusing
route_events_with_seq/2 on the existing running seq counter so no
version collides with anything already delivered) instead of a no-op log
line. Filtered to the newly-registered type only, so already-covered
handlers for other types see no redundant delivery.
No public API changes. filter_by_type/2 is exported for unit testing only,
same convention evoq_event_to_routable/1/route_event/1/
route_events_with_seq/2 already use in this module.
[1.23.0] - 2026-06-25
Added — stateful Decision/Context actor (CCC Part B)
Opt-in per-node gen_server mode for evoq_decision, keyed on a decision-declared
boundary key. For a keyed, hot boundary (one seat, one account, one SKU) it
serialises commands at one process and caches the folded decision model, so the
store's append condition passes first try instead of N−1 context_changed
retries. It is a per-node cache + serialiser, never the correctness
authority — reckon-db's append_if_no_tag_matches/4 stays the sole source of
truth; on context_changed the actor invalidates, re-reads, and retries.
evoq_decisiongains three optional callbacks:boundary_key/1(the opt-in switch;undefined/absent ⇒ today's stateless path verbatim),init_decision_model/0+apply_context_event/2(optional folded model; when both present,decide/2receives the folded model instead of the raw context-events list).- New modules:
evoq_decision_actor,evoq_decision_registry(ownevoq_decision_pgscope, group{decision, Module, Key}, node-local lookup),evoq_decision_partition_sup,evoq_decisions_sup(4 partitions byphash2({Module, Key})). Wired underevoq_sup. A parallel tree — no aggregate module changed (seeproposals/SPIKE_EVOQ_DECISION_ACTOR.md, Path 1). evoq_decision_runtime:dispatch/3is now a facade:boundary_keypresent ⇒ route to the actor; absent ⇒ stateless loop. Sharedload_context/2keeps one context-read + cutoff rule across both paths.- Lifespan/TTL reuses
evoq_aggregate_lifespanas-is; idle actors passivate (v1: stop + rebuild on next spawn). Decision-model snapshotting (proposal OQ3) is left as a documented extension point inevoq_decision_actor.
Backwards-compatible: all new callbacks optional; no existing decision changes behaviour. Minor bump (no aggregate API broke — 2.0 not required).
[1.22.0] - 2026-06-25
Added — CCC payload conditions in evoq_decision:context_filter() (Part A)
DCB decisions can now scope their consistency context on opaque event-data fields, not just tags/types. This is the CCC (Consistency Context Condition) dimension reckon-db exposes via payload indexes, lifted into evoq's Decision behaviour. Tag/type-only decisions are unchanged.
evoq_decision:context_filter()gains two leaves:{payload_match, Key :: binary(), Value :: binary()}— payload field equals.{payload_hash_match, Keys :: [binary()], Values :: [binary()]}— composite.
evoq_event_store: newccc_read_by_payload/4andccc_read_by_payload_hash/4reads, pluspayload_indexes/1/payload_hash_indexes/1introspection (the latter degrade to{error, introspection_unavailable}on adapters that predate them).evoq_adapter: four new optional callbacks for the above.evoq_decision_runtime: payload read branches;match_filter/2gainspayload_match/payload_hash_matchclauses that read flattened data fields.
Fixed — compound-filter (or_) superset bug
The runtime now reads every leaf of a compound filter fully via its own
index, unions the results, and refines client-side — replacing the
collect-tags-then-single-union-read path. {or_, [...]} mixing tag,
event_type, and payload leaves is now correct: no branch is inferred from a
sibling. match_filter/2 recurses over the event map (not its tag list) inside
and_/or_, so event_type/payload leaves nested in a compound evaluate
correctly.
Graceful degradation (required by the proposal)
A decision using a payload leaf against a store that does not declare the
matching index fails loudly and early with
{error, {payload_index_unavailable, Filter}} — it never silently returns an
empty context and lets a bad decision through. The decision runtime also no
longer crashes on a context read error; it propagates it to the caller.
Requires reckon-gater >= 3.7 / reckon-db >= 5.3 for the payload indexes,
only for decisions that use payload leaves. (gater 3.7 adds the
get_payload_indexes/1 / get_payload_hash_indexes/1 introspection the
runtime needs to fail loud; the ccc_read_by_payload* reads landed in 3.6.)
Pair with a reckon_evoq release implementing the four adapter callbacks
(delegating to reckon_gater_api:ccc_read_by_payload/4,
ccc_read_by_payload_hash/4, get_payload_indexes/1,
get_payload_hash_indexes/1) — shipped in reckon-evoq 2.7.0.
[1.21.0] - 2026-06-22
Added — {event_type, binary()} in evoq_decision:context_filter()
DCB decisions can now scope their consistency context by event type, mirroring
the {event_type, binary()} leaf added to reckon_gater_types:tag_filter() in
reckon-gater 3.4.0 / reckon-db 5.2.0.
evoq_decision:context_filter()gains the{event_type, binary()}variant.evoq_decision_runtime:read_context/2: new top-level clause hits the[by_event_type]Khepri index directly viaevoq_event_store:read_events_by_types/3.- Compound filters (
and_/or_) that reference{event_type, T}are now handled by unioning tag-index and event-type-index reads before client-side refinement, so{and_, [{event_type, T}, {any_of, Tags}]}is fully correct. match_filter/2(public testing API): new{event_type, T}clause readsevent_typefrom the event map rather than the tag list.collect_event_types/1added to the internal test-API export.
Requires reckon-db 5.2.0+ for the [by_event_type] index. Events written
before 5.2.0 have no index entries and will not match {event_type, T} filters
(same caveat as the backend guide documents).
v1 compound-filter limitation: {or_, [{event_type, T}, {any_of, Tags}]}
may miss events that match only the {event_type} branch but carry none of the
tags referenced by sibling branches. See context_filter() type docs.
[1.20.0] - 2026-06-08
Added — evoq_lineage: first-class correlation/causation API
The Enterprise Integration Patterns correlation/causation identifiers are now a named, intent-revealing capability rather than raw metadata-map digging.
- New
evoq_lineagemodule:- accessors
causation_id/1,correlation_id/1,conversation_id/1over an event's metadata map (tolerant of atom keys in-process and binary keys after JSON round-trip); - canonical key binaries
causation_key/0etc. (single source of truth for the names, matching reckon_shared.proto); - lineage queries
get_effects/2(events directly caused by a message),get_correlated/2(the conversation),get_conversation/2— thin wrappers overread_by_metadatawith the blessed keys.
- accessors
evoq_event_store:read_by_metadata/3delegates to the adapter (paired with reckon-evoq 2.4.0 / reckon_gater 3.2.0 / reckon-db 5.0.0).- Reserved lineage key macros in
evoq.hrl(?EVOQ_META_CAUSATION_IDetc.);evoq_aggregatenow propagates via these instead of bare literals.
Design stance: evoq owns the intent-revealing API and auto-propagation; the
store stays generic with only read_by_metadata (no server-side
get_effects/graph verb). Multi-hop chain/graph assembly is composed by the
application over get_effects / get_correlated, never in the store.
[1.19.0] - 2026-05-27
Added — compound filters in evoq_decision
The evoq_decision:context_filter() type now includes the
recursive and_ and or_ cases, matching the backend's
reckon_gater_types:tag_filter() exactly:
-type context_filter() ::
{any_of, [binary()]}
| {all_of, [binary()]}
| {and_, [context_filter()]}
| {or_, [context_filter()]}.evoq_decision_runtime:read_context/2 now plumbs compound filters
through the read path: walks the filter tree, collects the union of
referenced tags, hits read_by_tags once with the broad set, then
filters client-side using per-event semantics. Empty {and_, []} /
{or_, []} filters short-circuit to an empty context (no read).
This closes the v1 limitation noted in the 1.18.0 release. The backend's conditional-append check already supported compound filters (in reckon-db 3.1.0); now the runtime can express the same shapes.
5 new eunit tests cover or-of-flats, and-of-flats, nested compound
(or_(any_of, all_of)), and empty-compound short-circuit. All 90
existing eunit tests still pass.
[1.18.0] - 2026-05-27
Added — evoq_decision behaviour + runtime (DCB)
A new write-side construct that sits alongside evoq_aggregate.
Where an aggregate locks on its own stream's version (stream-per-thing
optimistic concurrency), a Decision locks on the absence of new events
matching a tag-filter context query (Dynamic Consistency Boundary).
Decisions are for cross-cutting checks that don't fit the per-entity Dossier shape — uniqueness, allocation against shared resources, idempotency keys, rate limits.
Behaviour module (evoq_decision):
-callback context(Command :: map()) -> context_filter().
-callback decide(ContextEvents :: [map()], Command :: map()) ->
{ok, [Event :: map()]}
| {error, Reason :: term()}.
%% Optional
-callback retry_budget() -> non_neg_integer(). %% default 3context_filter() v1 supports {any_of, [Tag]} and {all_of, [Tag]}
(flat predicates only).
Runtime module (evoq_decision_runtime):
evoq_decision_runtime:dispatch(MyDecisionMod, StoreId, Command).The runtime: calls context/1, reads matching DCB-stream events,
computes the seq cutoff (max version seen, or -1), calls decide/2,
appends conditionally via the configured adapter, retries on
{error, {context_changed, _}} with bounded exponential backoff +
jitter. Default retry budget 3.
evoq_event_store gains two wrapper functions delegating to the adapter:
Adapters must implement these to support evoq_decision. The
reckon_evoq adapter has done so since 2.2.0.
v1 limitations
- Flat filters only. Compound
and_/or_filters are supported by the reckon-db backend's conditional-append check, but the runtime's read path doesn't yet translate them. Use multiple decisions or flat filters at the evoq layer. - DCB-stream only. The runtime considers events from the
<<"_dcb">>pseudo-stream only when computing the cutoff. Mixed- mode use cases (aggregate streams + DCB sharing tags) are not supported byevoq_decision— useevoq_aggregatefor per-aggregate consistency,evoq_decisionfor pure cross-cutting. - No options API. Retry budget can be set via callback only;
there's no
dispatch/4taking a per-call options map yet.
Notes
evoq_aggregate is unchanged. Apps that don't use DCB are unaffected.
This release adds two new modules and two new functions in
evoq_event_store. Backward-compatible.
Publish ordering: reckon-gater 2.3.0 -> reckon-db 3.1.0 ->
reckon-evoq 2.2.0 -> evoq 1.18.0. For local dev, the local
reckon_gater is linked via _checkouts/.
[1.15.0] - 2026-05-15
Added — Integrity-violation classification and chain-hash propagation
Layer 6 of the cross-package tamper-resistance work in reckon-db/plans/PLAN_TAMPER_RESISTANCE.md. Pure additions plus one non-breaking error-handling fix; no API removed, no caller broken.
Schema
#evoq_event{}gainsprev_event_hash :: binary() | undefined. Carried verbatim from reckon-gater's#event{}through the reckon-evoq adapter so projections and process managers can keylessly verify chain continuity as defense-in-depth.- The
macandsignaturefields on the storage-side record are intentionally NOT propagated into evoq. They belong to the storage layer and require the per-store HMAC / public key that the framework does not (and must not) hold. evoq_event_store:event_to_map/1includesprev_event_hashin the produced map.
Error classification
- New
evoq_aggregate:is_integrity_violation/1recognises the{error, {integrity_violation, _}}class shipped by reckon-db 2.1.0. Distinct fromwrong_expected_version: must NOT enter the rebuild-and-retry loop. - Post-append error path now classifies and handles integrity
violations explicitly — surface verbatim, emit telemetry
[evoq, aggregate, integrity, violation], no retry. rebuild_and_reply_conflict/4distinguishes integrity from other rebuild failures. Previous behaviour normalised any rebuild error towrong_expected_version, which would have caused the dispatcher to spin against corrupted state until the retry cap. Integrity errors now surface immediately.
Compatibility
- Requires reckon-gater >= 2.1.0 only if you want chain hashes
to flow through to the evoq layer. evoq 1.15 builds and runs
fine against reckon-gater 2.0.x — the new record field
defaults to
undefinedeverywhere. - Existing callers constructing
#evoq_event{}without the new field continue to compile and run.
Tests
14 new eunit tests in evoq_aggregate_integrity_tests:
classifier shape recognition (storage / replay / snapshot
violations with various context maps), negative cases that
must NOT over-match (wrong-version errors, stream-not-found,
bare violations, atom-collision cases, success tuples,
arbitrary terms), and schema sanity (record has the field,
defaults to undefined, survives the record→map boundary).
Full eunit: 75 tests pass (61 existing + 14 new). Zero regression.
Changed
src/evoq.app.src:{links, [{"GitHub", ...}]}updated to{"Codeberg", ...}to match canonical hosting.
[1.14.4] - 2026-04-24
Fixed
evoq_aggregatenow recognises every shape of thewrong_expected_versionerror the backend can produce —{error, wrong_expected_version},{error, {wrong_expected_version, Actual}}, and the current reckon-db form{error, {wrong_expected_version, Expected, Actual}}. The old handle_call matched only the plain-atom form, so the real 3-tuple returned by reckon-db fell through to the generic error branch and the rebuild/retry machinery was dead code: commands likeconfirm_realm_membershipsurfaced the raw conflict up to the caller instead of triggering a rebuild + dispatcher retry.The classifier is factored into
is_wrong_version_error/1and wired through a single reply helper so adding a new shape in the future is a one-line change. Regression test intest/unit/evoq_aggregate_version_conflict_tests.erl.
[1.14.3] - 2026-04-23
Fixed
evoq_aggregate:rebuild_from_events/3now reports version-1for an empty stream, matchingload_or_init/3. It previously returned0, which caused the dispatcher'swrong_expected_versionretry loop to spin forever against a Ra stream at version-1— each retry handed the backendexpected_version=0for a stream that had never been written. Regression test intest/unit/evoq_aggregate_rebuild_tests.erl.
[1.14.2] - 2026-04-19
Changed
- Updated cross-references in
include/evoq_types.hrlfromesdb_gater_types.hrltoreckon_gater_types.hrlfollowing the rename in reckon-gater 2.0.0. No API changes — comment/docs only.
[1.13.1] - 2026-03-19
Added
- Unit tests for
evoq_store_inspector - Guide:
guides/store_inspector.mdwith usage examples - Architecture diagram:
assets/store_inspector.svg
[1.13.0] - 2026-03-19
Added
evoq_store_inspector(NEW): Store-level introspection via adapter.store_stats/1,list_all_snapshots/1,list_subscriptions/1subscription_lag/2,event_type_summary/1,stream_info/2- Delegates to the configured event store adapter (graceful fallback if not supported)
[1.12.0] - 2026-03-14
Added
evoq_statebehaviour (NEW): Formalizes the aggregate state as a first-class module — the "default read model". Every aggregate MUST have a corresponding state module declared viastate_module/0. Required callbacks:new/1,apply_event/2,to_map/1. Optional:from_map/1. This separation keeps the aggregate focused on command validation and business rules, while the state module owns the data shape, field access, event folding, and serialization.evoq_aggregate:state_module/0callback (REQUIRED): Every aggregate must now declare which module implementsevoq_statefor its state. This makes codegen fully mechanical — every aggregate gets a state module, no exceptions.
[1.11.0] - 2026-03-14
Added
evoq_emitterbehaviour (NEW): Formalizes emitters that subscribe to domain events and publish integration facts to external transports (pg or mesh). Required callbacks:source_event/0,fact_module/0,transport/0,emit/3.evoq_listenerbehaviour (NEW): Formalizes listeners that receive integration facts from external transports and dispatch commands to the local aggregate. Required callbacks:source_fact/0,transport/0,handle_fact/3.evoq_requesterbehaviour (NEW): Formalizes requesters that send hopes over mesh and wait for feedback (cross-daemon RPC). Required callbacks:hope_module/0,send/2.evoq_responderbehaviour (NEW): Formalizes responders that receive hopes, dispatch commands, and return feedback with the post-event aggregate state. Required callbacks:hope_type/0,handle_hope/3. Optional:feedback_module/0.evoq_feedbackbehaviour (NEW): Typed feedback for hope/response cycles. Serializes command execution results ({ok, State}or{error, Reason}) for transport. Required callbacks:feedback_type/0,from_result/1,to_result/1. Optional:serialize/1,deserialize/1. Default JSON serialization provided.evoq_aggregate:execute_command_with_state/2: Execute a command and return the post-event aggregate state along with version and events. Enables session-level consistency where callers receive immediate truth about the resulting state.evoq_dispatcher:dispatch_with_state/2: Dispatch a command through the middleware pipeline and return{ok, Version, Events, AggregateState}on success. Full middleware pipeline, idempotency, and consistency support.
[1.10.0] - 2026-03-14
Added
evoq_commandbehaviour expanded: Commands are now formal domain artifacts. Added required callbackscommand_type/0,new/1,to_map/1and optionalfrom_map/1. Existingvalidate/1remains optional. Modules without the behaviour continue to work unchanged -- the callbacks are opt-in.evoq_eventbehaviour (NEW): Events are formal domain artifacts with required callbacksevent_type/0,new/1,to_map/1and optionalfrom_map/1. Event construction vianew/1returns the event directly (no{ok, _}wrapper) since events are produced from validated handler output.evoq_factbehaviour (NEW): Integration artifacts for cross-boundary communication. Facts translate domain events into serializable payloads with binary keys for external consumption via pg or mesh. Required callbacks:fact_type/0(returns binary topic),from_event/3(translates event to payload or returnsskip). Optional:serialize/1,deserialize/1,schema/0. Default JSON serialization via OTP 27jsonmodule provided asevoq_fact:default_serialize/1anddefault_deserialize/1.evoq_hopebehaviour (NEW): Integration artifacts for outbound RPC requests between agents. Required:hope_type/0,new/1,to_payload/1,from_payload/1. Optional:validate/1,serialize/1,deserialize/1,schema/0. Default JSON serialization provided. No implementations yet -- behaviour defined for when RPC use cases arise.Atom
event_typesupport in aggregates:evoq_aggregate:append_events/5now auto-converts atomevent_typevalues to binary for storage viaresolve_event_type/1. Typed event modules can return atomevent_typeinto_map/1(e.g.,venture_initiated_v1) and evoq stores it as binary (<<"venture_initiated_v1">>). Binary values pass through unchanged.Artifacts guide: New
guides/artifacts.mddocumenting the 4 artifact types (command, event, fact, hope), when to use each, and reference implementations.
[1.9.2] - 2026-03-12
Fixed
evoq_projection: Per-projectionstore_idfor replay. Projections that replay events on rebuild used a globalapplication:get_env(evoq, store_id)which defaults todefault_store. In multi-store systems (e.g. one store per bounded context), this caused projections to replay from the wrong store — or a non-existent one — resulting in empty read models after restart. Projections now acceptstore_idin Opts (3rd argument tostart_link/3), which takes precedence over the global app env during replay.
[1.9.1] - 2026-03-08
Added
evoq_event_store:has_events/1: Check if a store contains at least one event. Delegates to adapter'shas_events/1if available, falls back to reading 1 event viaread_all_global.- Catch-up diagnostic logging:
evoq_store_subscriptionnow logs each event's type and handler count during historical replay for troubleshooting.
[1.9.0] - 2026-03-06
Added
- Catch-up historical replay:
evoq_store_subscriptionnow replays all historical events from the store before subscribing to new events. Usesread_all_global/3to read events in batches, routing through the same path as live events. evoq_event_store:read_all_global/3: Read all events across all streams in global order with offset/batch pagination. Falls back toread_all_events/2if adapter does not implement the optional callback.
[1.8.2] - 2026-03-08
Fixed
evoq_store_subscription: Cross-stream checkpoint collision with$allsubscriptions. The$allsubscription delivers events from multiple streams, but stream-local versions overlap (stream A version 0, stream B version 0). When these were passed toevoq_projectionas theversionin metadata, the idempotency checkEventVersion =< Checkpointincorrectly skipped events from the second stream. Now maintains a monotonically increasing sequence counter per subscription instance. The global sequence is injected asversionin metadata (for projection checkpoints), and the original stream version is preserved asstream_version.Test infrastructure: Added
evoq_type_providerto test helperensure_routing_infrastructure/0, preventing ETS table crashes whenevoq_event_routerattempts upcasting during tests.
[1.8.1] - 2026-03-07
Fixed
- Projection checkpoint skips first event (version 0): Initial checkpoint
was 0, and the idempotency check
EventVersion =< Checkpointskipped events at version 0. Since ReckonDB stream versions are 0-based, the first event in every stream was silently dropped. Changed initial checkpoint to -1 (sentinel for "nothing processed yet"). Same fix applied todo_rebuild/1. This is the same class of bug that was fixed in aggregates in v1.3.1.
[1.8.0] - 2026-03-07
Changed
evoq_store_subscription: Single$allsubscription for global ordering. Previously created N independent per-event-type subscriptions to ReckonDB, one per registered handler type. Each subscription had its own bridge process, so events of different types had no ordering guarantee relative to each other. Now uses a singleby_streamsubscription with<<"$all">>selector, receiving ALL events in global store order. Events are filtered locally by checkingevoq_event_type_registry:get_handlers/1— types with no handlers are skipped. This fixes race conditions where causally related events of different types (e.g.,license_initiated_v1beforelicense_published_v1) could be delivered out of order to their respective projections.
[1.7.0] - 2026-03-07
Changed
evoq_read_model_etsshared named tables: Named ETS tables now support multiple projections writing to the same read model. If a named table already exists, new instances join it instead of crashing. This enables the vertical slicing pattern where each projection (desk) handles one event type but all project into the same read model. Anonymous (unnamed) tables remain isolated per instance as before.
[1.6.0] - 2026-03-07
Added
evoq_store_subscriptionmodule: Bridge between event stores and evoq's routing infrastructure. Creates per-event-type subscriptions to a reckon-db store, matching evoq's event-type-oriented architecture. Only events that have registered handlers/projections/PMs are subscribed to — filtering happens at the store level, not the application level. This is the critical missing link that connects the event store to evoq behaviours (evoq_event_handler,evoq_projection,evoq_process_manager).- Start one instance per store:
evoq_store_subscription:start_link(my_store) - Automatically discovers registered event types from
evoq_event_type_registry - Dynamically subscribes to new event types as handlers register
- Routes events to both
evoq_event_routerandevoq_pm_router
- Start one instance per store:
evoq_event_type_registry:register_listener/1: Atomically returns all currently registered event types AND subscribes the caller for future type registration notifications. Race-free — noregister/2call can execute between returning types and subscribing for notifications.evoq_event_type_registry:unregister_listener/1: Removes a store subscription listener.
Changed
evoq_event_type_registry:register/2: Now detects when an event type gets its first handler and notifies registered store subscription listeners via{new_event_type, EventType}messages.
[1.5.0] - 2026-03-05
Fixed
- Nested event structure in
append_events: Events produced by aggregates now use proper nested#{event_type, data, metadata}structure.
[1.4.0] - 2026-02-25
Added
evoq_subscriptionsfacade module: Application-level API for subscription operations, mirroring theevoq_event_storepattern. Application code should callevoq_subscriptions:subscribe/5instead of the adapter directly. Delegates to a configuredsubscription_adapter(set via{evoq, [{subscription_adapter, Module}]}). Exports:subscribe/5,unsubscribe/2,ack/4,get_checkpoint/2,list/1,get_by_name/2,get_adapter/0,set_adapter/1.
[1.3.1] - 2026-02-13
Fixed
- Aggregate replay at version 0:
load_or_init/3now usesState =/= undefinedinstead ofVersion > 0to detect replayed events. The first event in a stream is version 0, so the previous guard skipped it and re-initialized fresh.
Added
event_to_map/1exported:evoq_event_store:event_to_map/1is now public API. Converts#evoq_event{}records to flat maps, merging business data from thedatafield into the top level so aggregates see consistent shapes regardless of source.
[1.3.0] - 2026-02-11
Added
idempotency_keyfield on#evoq_command{}: Optional caller-provided key for deterministic command deduplication. When set, the idempotency cache uses this key instead ofcommand_id. Use for scenarios like "user cannot submit the same form twice" where the deduplication key should be deterministic and intent-based.Auto-generated
command_id: The dispatcher now auto-generatescommand_idviacrypto:strong_rand_bytes/1if the field isundefined. Callers no longer need to generate their own command IDs — the framework handles it.evoq_command:ensure_id/1: Fills incommand_idif undefined, returns command unchanged if already set.evoq_command:get_idempotency_key/1andset_idempotency_key/2: Accessors for the newidempotency_keyfield.
Changed
Idempotency cache key selection: The dispatcher now uses
idempotency_key(if set) for cache lookups, falling back tocommand_id. This separates the concerns of command identification (tracing, unique per invocation) from command deduplication (deterministic per intent).Validation relaxed:
evoq_command:validate/1no longer rejects commands withundefinedcommand_id, since the dispatcher auto-generates it before execution.
Migration
- No breaking changes for existing code. Commands with manually-set
command_idcontinue to work exactly as before. The newidempotency_keyfield defaults toundefined. - Recommended: Stop generating
command_idmanually in dispatch modules. Let the framework handle it. If you need deduplication, useidempotency_keyinstead.
[1.2.1] - 2026-02-01
Added
- Event Envelope Documentation: Comprehensive guide explaining
evoq_eventrecord structure- New guide:
guides/event_envelope.md- Complete explanation of envelope fields - New diagram:
assets/event-envelope-diagram.svg- Visual event lifecycle - Updated
guides/projections.md- Clarified envelope structure in projections - Documents where business event payloads fit (
datafield) - Explains metadata usage (correlation_id, causation_id, etc.)
- Event naming conventions and versioning patterns
- Common mistakes to avoid
- New guide:
Documentation
- Improved clarity on event envelope structure
- Added visual diagrams for event lifecycle
- Standardized metadata field documentation
[1.2.0] - 2026-01-21
Added
- Tag-Based Querying: Cross-stream event queries using tags
tagsfield added to#evoq_event{}record inevoq_types.hrlevoq_tag_match()type - Support forany(union) andall(intersection) matchingtagssubscription type for tag-based subscriptions- Tags are for QUERY purposes only, NOT for concurrency control
[1.1.3] - 2026-01-19
Fixed
- Documentation: Minor documentation improvements
[1.1.0] - 2026-01-08
Added
Bit Flags Module (
evoq_bit_flags): Efficient bitwise flag manipulation for aggregate stateset/2,unset/2: Set/unset single flagsset_all/2,unset_all/2: Set/unset multiple flagshas/2,has_not/2: Check single flag statehas_all/2,has_any/2: Check multiple flagsto_list/2,to_string/2,3: Human-readable conversions with flag mapsdecompose/1: Extract power-of-2 componentshighest/2,lowest/2: Get highest/lowest set flag description
Bit Flags Guide (
guides/bit_flags.md): Comprehensive documentation- Why use bit flags in event sourcing
- Core operations with examples
- Aggregate state management patterns
- Best practices for flag definition
- Complete function reference
Changed
- Aggregate status fields should now use integer bit flags instead of atoms for better memory efficiency, query performance, and event store compatibility
[1.0.3] - 2026-01-06
Fixed
- Macro guard compatibility: Added
-ifndefguards around macro definitions inevoq_types.hrlto prevent redefinition errors when used alongsideesdb_gater_types.hrlin adapters like reckon_evoq
[1.0.2] - 2026-01-06
Changed
- Independence from reckon_gater: Removed direct dependency on reckon_gater
- Introduced
include/evoq_types.hrlwith evoq's own type definitions - Adapters (like reckon_evoq) now handle type translation between evoq and backend
- evoq is now a pure CQRS/ES framework without storage backend coupling
- Introduced
Fixed
- hex.pm dependencies: Package now correctly publishes with only telemetry as dependency
[1.0.1] - 2026-01-03
Fixed
- SVG diagrams: Updated architecture.svg, command-dispatch.svg, and event-routing.svg to reference reckon-db instead of erl-esdb
[1.0.0] - 2026-01-03
Changed
- Stable Release: First stable release of evoq under reckon-db-org
- All APIs considered stable and ready for production use
- Fixed documentation links (guides/adapters.md)
- Updated dependency references to reckon_gater
[0.3.0] - 2025-12-20
Added
- Documentation: Comprehensive educational guides with SVG diagrams
- Architecture overview guide with system diagram
- Aggregates guide with lifecycle diagram
- Event handlers guide with routing diagram
- Process managers guide with saga flow diagram
- Projections guide with data flow diagram
- Adapters guide for event store integration
- ex_doc integration: Full hex.pm documentation support via rebar3_ex_doc
Changed
- Dependencies: Updated reckon_gater from 0.3.0 to 0.4.3
Fixed
- EDoc errors: Fixed XML parsing issues in memory monitor documentation
- EDoc errors: Removed invalid @doc tags before -callback declarations
[0.2.0] - 2024-12-19
Added
Event Store Adapter Pattern: Pluggable event store backends
evoq_adapterbehavior for custom adaptersevoq_event_storefacade with adapter delegation- Support for erl-esdb-gater integration
Checkpoint Store: Persistent checkpoint tracking
evoq_checkpoint_storebehaviorevoq_checkpoint_store_etsETS-based implementation- Position tracking for projections and handlers
Dead Letter Queue: Failed event handling
evoq_dead_letterfor events that exhaust retries- List, retry, and discard operations
- Telemetry integration for monitoring
Error Handling: Comprehensive error management
evoq_error_handlerfor centralized error processing- Failure context tracking via
evoq_failure_context - Retry strategies with backoff
Changed
- Event Router: Switched to per-event-type subscriptions
- Handlers declare
interested_in/0for event types - Prevents subscription explosion with many aggregates
- Constant memory usage regardless of aggregate count
- Handlers declare
[0.1.0] - 2024-12-18
Added
Initial release of evoq CQRS/Event Sourcing framework
Aggregates (
evoq_aggregate):evoq_aggregatebehavior with init/execute/apply callbacks- Partitioned supervision across 4 supervisors
- Configurable TTL and idle timeout
- Snapshot support for faster loading
- Memory pressure monitoring with adaptive TTL
Aggregate Lifespan (
evoq_aggregate_lifespan):- Configurable lifecycle management
- Default 30-minute idle timeout
- Hibernate after 1 minute idle
- Snapshot on passivation
Command Dispatch (
evoq_dispatcher):- Command routing to aggregates
- Middleware pipeline support
- Consistency mode (strong/eventual)
Middleware (
evoq_middleware):- Pluggable command pipeline
- Validation middleware
- Idempotency middleware
- Consistency middleware
Event Handlers (
evoq_event_handler):- Per-event-type subscriptions
- Retry strategies with exponential backoff
- Dead letter queue for failed events
- Strong/eventual consistency modes
Process Managers (
evoq_process_manager):- Long-running business process coordination
- Event correlation and routing
- Command dispatch from handlers
- Compensation support for failures
Projections (
evoq_projection):- Read model builders from events
- Checkpointing for resume
- Rebuild capability
- Multiple storage backends
Event Upcasters (
evoq_event_upcaster):- Schema evolution support
- Event migration on replay
Memory Monitor (
evoq_memory_monitor):- System memory pressure detection
- Adaptive TTL adjustment
- Aggregate eviction under pressure
Telemetry Integration:
- Comprehensive event emission
- Aggregate lifecycle events
- Handler processing events
- Projection progress events
Dependencies
- erl_esdb_gater 0.3.0 - Gateway API and shared types
- telemetry 1.3.0 - Observability