Changelog

View Source

All 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/2 hands out live members only, and a noproc from 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.

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_registry announces 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/1 returns the current registrations as {Registrant, Types}. New: register_process_manager/2, unregister_process_manager/1, get_process_managers/1. get_all_event_types/0 includes process manager types.

  • evoq_pm_router:register_pm/1 registers the process manager with the type registry. New: evoq_pm_router:route_event/3, to named process managers.

  • evoq_event_router:route_event/3 takes targets => [pid()].

  • Registrations queued together are backfilled in one pass over the store.

  • evoq_store_subscription:filter_by_type_set/2 is 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/3 takes #{identity => Term, from => Position}. evoq_event_handler and evoq_projection pass {Module, Config} as the identity (an event handler takes identity in its start options instead, needed when its Config holds a pid or a ref); a projection passes its checkpoint plus one as from. Registrations are announced as {new_registrant, Registrant, Types, #{identity, from}}, and register_listener/1 returns {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 with replay_policy() -> skip does 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/2 returns 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_cap ids (application env, default 100000). Past that it keeps the newest and logs evoq_redelivery_window_capped with 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/2 takes 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/4 takes 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/1 is gone; it is required/2 now), 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/4 now drops self(), 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.md and this module's own doc: a cascade should dispatch :eventual anyway, 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_handler and evoq_projection acknowledge 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/4 policy let the event go (skip, {dead_letter, _}) sends evoq_consistency:dropped/2 instead 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} from project/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's first_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 pg scope, 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/1 takes the unneeded acks out of the caller's mailbox. Every waiter on a stream's group is sent every ack for that stream, and a gen_server that dispatches in a loop would otherwise accumulate them. An ack already in flight can still land after close/1 returns.
  • 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/4 refuses all with {error, all_needs_events} rather than answering ok: it sees no events, so it cannot know which handlers a dispatch would have to wait for, and ok would claim they had all written. A dispatch asks for strong, which can resolve it.
  • evoq_consistency:open/2, close/1, await/4, required/1 and waitable/1 are new, acknowledge/2 acknowledges straight from an event's metadata, and dropped/2 reports a handler that dropped one. await/4 gains {error, {dropped, Handler}}, which the router answers as {error, {consistency_dropped, Handler}}.
  • strong does 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/2 does not match, restarts the router empty: every process manager on the node stays silent until the node restarts or each is registered again with evoq_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 met context_changed. It could commit only at the truncation boundary under concurrent appends, when a later sequence number sorted inside the oldest 1000 by epoch_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/3 and apply/2 still run, so an instance rebuilds exactly its state, but the commands handle/3 returns 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):

  1. Every fresh $all subscription always passed start_from => 0. subscribe_to_all/2 (now /3) hardcoded this regardless of what catch_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.

  2. Because ack/4 was 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 own Metadata.version (a monotonically-increasing per-delivery counter, overwritten specifically so $all subscribers get ordered checkpoints) means evoq_projection's EventVersion =< Checkpoint guard and evoq_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/2 now 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/3 is handed Offset0 explicitly and passes it as start_from, closing the gap for a subscription's own very first ever create.
    • handle_continue/2 now acks Offset0 to the persisted subscription BEFORE calling subscribe_to_all/3 -- this is the one that actually closes every RESTART, not just the first boot: start_from only affects a subscription's initial creation (reckon_db_subscriptions:store_and_setup/6); on a reconnect, reregister_subscriber/4 ignores 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 the start_from half 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_subscription gen_server against a fake adapter modeling reckon_db_subscriptions' actual checkpoint semantics exactly (read from its source, not guessed: a fresh subscription honors the caller's start_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 real evoq_subscriptions API 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 $all subscription'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

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_decision gains 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/2 receives the folded model instead of the raw context-events list).
  • New modules: evoq_decision_actor, evoq_decision_registry (own evoq_decision_pg scope, group {decision, Module, Key}, node-local lookup), evoq_decision_partition_sup, evoq_decisions_sup (4 partitions by phash2({Module, Key})). Wired under evoq_sup. A parallel tree — no aggregate module changed (see proposals/SPIKE_EVOQ_DECISION_ACTOR.md, Path 1).
  • evoq_decision_runtime:dispatch/3 is now a facade: boundary_key present ⇒ route to the actor; absent ⇒ stateless loop. Shared load_context/2 keeps one context-read + cutoff rule across both paths.
  • Lifespan/TTL reuses evoq_aggregate_lifespan as-is; idle actors passivate (v1: stop + rebuild on next spawn). Decision-model snapshotting (proposal OQ3) is left as a documented extension point in evoq_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: new ccc_read_by_payload/4 and ccc_read_by_payload_hash/4 reads, plus payload_indexes/1 / payload_hash_indexes/1 introspection (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/2 gains payload_match / payload_hash_match clauses 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 via evoq_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 reads event_type from the event map rather than the tag list.
  • collect_event_types/1 added 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_lineage module:
    • accessors causation_id/1, correlation_id/1, conversation_id/1 over an event's metadata map (tolerant of atom keys in-process and binary keys after JSON round-trip);
    • canonical key binaries causation_key/0 etc. (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 over read_by_metadata with the blessed keys.
  • evoq_event_store:read_by_metadata/3 delegates 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_ID etc.); evoq_aggregate now 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 3

context_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 by evoq_decision — use evoq_aggregate for per-aggregate consistency, evoq_decision for pure cross-cutting.
  • No options API. Retry budget can be set via callback only; there's no dispatch/4 taking 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{} gains prev_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 mac and signature fields 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/1 includes prev_event_hash in the produced map.

Error classification

  • New evoq_aggregate:is_integrity_violation/1 recognises the {error, {integrity_violation, _}} class shipped by reckon-db 2.1.0. Distinct from wrong_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/4 distinguishes integrity from other rebuild failures. Previous behaviour normalised any rebuild error to wrong_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 undefined everywhere.
  • 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_aggregate now recognises every shape of the wrong_expected_version error 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 like confirm_realm_membership surfaced the raw conflict up to the caller instead of triggering a rebuild + dispatcher retry.

    The classifier is factored into is_wrong_version_error/1 and wired through a single reply helper so adding a new shape in the future is a one-line change. Regression test in test/unit/evoq_aggregate_version_conflict_tests.erl.

[1.14.3] - 2026-04-23

Fixed

  • evoq_aggregate:rebuild_from_events/3 now reports version -1 for an empty stream, matching load_or_init/3. It previously returned 0, which caused the dispatcher's wrong_expected_version retry loop to spin forever against a Ra stream at version -1 — each retry handed the backend expected_version=0 for a stream that had never been written. Regression test in test/unit/evoq_aggregate_rebuild_tests.erl.

[1.14.2] - 2026-04-19

Changed

  • Updated cross-references in include/evoq_types.hrl from esdb_gater_types.hrl to reckon_gater_types.hrl following 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.md with 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/1
    • subscription_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_state behaviour (NEW): Formalizes the aggregate state as a first-class module — the "default read model". Every aggregate MUST have a corresponding state module declared via state_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/0 callback (REQUIRED): Every aggregate must now declare which module implements evoq_state for its state. This makes codegen fully mechanical — every aggregate gets a state module, no exceptions.

[1.11.0] - 2026-03-14

Added

  • evoq_emitter behaviour (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_listener behaviour (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_requester behaviour (NEW): Formalizes requesters that send hopes over mesh and wait for feedback (cross-daemon RPC). Required callbacks: hope_module/0, send/2.

  • evoq_responder behaviour (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_feedback behaviour (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_command behaviour expanded: Commands are now formal domain artifacts. Added required callbacks command_type/0, new/1, to_map/1 and optional from_map/1. Existing validate/1 remains optional. Modules without the behaviour continue to work unchanged -- the callbacks are opt-in.

  • evoq_event behaviour (NEW): Events are formal domain artifacts with required callbacks event_type/0, new/1, to_map/1 and optional from_map/1. Event construction via new/1 returns the event directly (no {ok, _} wrapper) since events are produced from validated handler output.

  • evoq_fact behaviour (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 returns skip). Optional: serialize/1, deserialize/1, schema/0. Default JSON serialization via OTP 27 json module provided as evoq_fact:default_serialize/1 and default_deserialize/1.

  • evoq_hope behaviour (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_type support in aggregates: evoq_aggregate:append_events/5 now auto-converts atom event_type values to binary for storage via resolve_event_type/1. Typed event modules can return atom event_type in to_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.md documenting 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-projection store_id for replay. Projections that replay events on rebuild used a global application:get_env(evoq, store_id) which defaults to default_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 accept store_id in Opts (3rd argument to start_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's has_events/1 if available, falls back to reading 1 event via read_all_global.
  • Catch-up diagnostic logging: evoq_store_subscription now 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_subscription now replays all historical events from the store before subscribing to new events. Uses read_all_global/3 to 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 to read_all_events/2 if adapter does not implement the optional callback.

[1.8.2] - 2026-03-08

Fixed

  • evoq_store_subscription: Cross-stream checkpoint collision with $all subscriptions. The $all subscription delivers events from multiple streams, but stream-local versions overlap (stream A version 0, stream B version 0). When these were passed to evoq_projection as the version in metadata, the idempotency check EventVersion =< Checkpoint incorrectly skipped events from the second stream. Now maintains a monotonically increasing sequence counter per subscription instance. The global sequence is injected as version in metadata (for projection checkpoints), and the original stream version is preserved as stream_version.

  • Test infrastructure: Added evoq_type_provider to test helper ensure_routing_infrastructure/0, preventing ETS table crashes when evoq_event_router attempts 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 =< Checkpoint skipped 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 to do_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 $all subscription 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 single by_stream subscription with <<"$all">> selector, receiving ALL events in global store order. Events are filtered locally by checking evoq_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_v1 before license_published_v1) could be delivered out of order to their respective projections.

[1.7.0] - 2026-03-07

Changed

  • evoq_read_model_ets shared 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_subscription module: 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_router and evoq_pm_router
  • 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 — no register/2 call 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_subscriptions facade module: Application-level API for subscription operations, mirroring the evoq_event_store pattern. Application code should call evoq_subscriptions:subscribe/5 instead of the adapter directly. Delegates to a configured subscription_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/3 now uses State =/= undefined instead of Version > 0 to 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/1 exported: evoq_event_store:event_to_map/1 is now public API. Converts #evoq_event{} records to flat maps, merging business data from the data field into the top level so aggregates see consistent shapes regardless of source.

[1.3.0] - 2026-02-11

Added

  • idempotency_key field on #evoq_command{}: Optional caller-provided key for deterministic command deduplication. When set, the idempotency cache uses this key instead of command_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-generates command_id via crypto:strong_rand_bytes/1 if the field is undefined. Callers no longer need to generate their own command IDs — the framework handles it.

  • evoq_command:ensure_id/1: Fills in command_id if undefined, returns command unchanged if already set.

  • evoq_command:get_idempotency_key/1 and set_idempotency_key/2: Accessors for the new idempotency_key field.

Changed

  • Idempotency cache key selection: The dispatcher now uses idempotency_key (if set) for cache lookups, falling back to command_id. This separates the concerns of command identification (tracing, unique per invocation) from command deduplication (deterministic per intent).

  • Validation relaxed: evoq_command:validate/1 no longer rejects commands with undefined command_id, since the dispatcher auto-generates it before execution.

Migration

  • No breaking changes for existing code. Commands with manually-set command_id continue to work exactly as before. The new idempotency_key field defaults to undefined.
  • Recommended: Stop generating command_id manually in dispatch modules. Let the framework handle it. If you need deduplication, use idempotency_key instead.

[1.2.1] - 2026-02-01

Added

  • Event Envelope Documentation: Comprehensive guide explaining evoq_event record 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 (data field)
    • Explains metadata usage (correlation_id, causation_id, etc.)
    • Event naming conventions and versioning patterns
    • Common mistakes to avoid

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
    • tags field added to #evoq_event{} record in evoq_types.hrl
    • evoq_tag_match() type - Support for any (union) and all (intersection) matching
    • tags subscription 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 state

    • set/2, unset/2: Set/unset single flags
    • set_all/2, unset_all/2: Set/unset multiple flags
    • has/2, has_not/2: Check single flag state
    • has_all/2, has_any/2: Check multiple flags
    • to_list/2, to_string/2,3: Human-readable conversions with flag maps
    • decompose/1: Extract power-of-2 components
    • highest/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 -ifndef guards around macro definitions in evoq_types.hrl to prevent redefinition errors when used alongside esdb_gater_types.hrl in 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.hrl with 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

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_adapter behavior for custom adapters
    • evoq_event_store facade with adapter delegation
    • Support for erl-esdb-gater integration
  • Checkpoint Store: Persistent checkpoint tracking

    • evoq_checkpoint_store behavior
    • evoq_checkpoint_store_ets ETS-based implementation
    • Position tracking for projections and handlers
  • Dead Letter Queue: Failed event handling

    • evoq_dead_letter for events that exhaust retries
    • List, retry, and discard operations
    • Telemetry integration for monitoring
  • Error Handling: Comprehensive error management

    • evoq_error_handler for 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/0 for event types
    • Prevents subscription explosion with many aggregates
    • Constant memory usage regardless of aggregate count

[0.1.0] - 2024-12-18

Added

  • Initial release of evoq CQRS/Event Sourcing framework

  • Aggregates (evoq_aggregate):

    • evoq_aggregate behavior 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