Dynamic Consistency Boundary
DCB lets a consistency boundary be a set of events matching a query, rather than one stream. It is the answer to the modelling problem where the thing you must be consistent about does not line up with the aggregate you chose.
Registering a tag type
opts.Events.RegisterTagType<BasketId>("basket"); // fi_event_tag_basket
opts.Events.RegisterTagType<CustomerId>(); // suffix from the type nameEach registered tag type gets a table.
Tagging events
Tag the events an append produces:
session.Events.AssignTagWhere(e => e.EventTypeName == "ItemAdded", basketId);
session.Events.Append(streamId, new ItemAdded(sku, quantity));
await session.SaveChangesAsync();The predicate goes through the same parser Query<T>() uses, over IEvent members resolved to fi_events columns rather than json_extract paths.
TIP
IEvent.Timestamp permits range comparison where a document's DateTimeOffset member does not — same CLR type, but fi_events.timestamp is Fisher's fixed-width UTC format, chosen precisely so a string comparison is an instant comparison.
Querying by tags
var events = await session.Events.QueryByTagsAsync(query);
var exists = await session.Events.EventsExistAsync(query);
var basket = await session.Events.AggregateByTagsAsync<Basket>(query);Writing against a boundary
var boundary = await session.Events.FetchForWritingByTags<Basket>(query);
if (boundary.Aggregate?.Items.Count < 20)
{
boundary.AppendOne(new ItemAdded(sku, quantity));
}
await session.SaveChangesAsync();The boundary records the highest sequence it saw. At commit, Fisher checks inside the write transaction, before anything is written, that no later event matches the query — and fails the commit if one does.
TIP
Checking after would be checking against the session's own appends. Checking outside the transaction would prove nothing, because BEGIN IMMEDIATE is what holds the write lock.
TIP
A boundary over an empty result still enforces consistency: the last-seen sequence is 0, and any later matching event exceeds it.
How tag rows are stored
One fi_event_tag_<suffix> table per tag type, with a composite primary key leading with value. That key is load-bearing twice over:
- a tag query filters on
value, so leading with it makes the lookup a range scan; - it is what lets both the append path and
AssignTagWherewriteon conflict do nothinginstead of reading first — which is where idempotency comes from.
Tags are written after the batch, inside its transaction
A tag row is keyed by the seq_id SQLite assigns on insert, which Fisher only learns from the trailing sequence read-back — so there is nothing to write until the appends postprocess.
WARNING
Committing tags separately would leave an event visible but untagged, and to a tag query that is indistinguishable from an event that was never tagged.
The query shape
Each condition becomes a seq_id in (select seq_id from <tag table> where value = ?) subselect, OR'd together.
TIP
Subselects rather than joins, because joining several tag tables multiplies rows when one event carries two matching tags — and the caller expects each event once.
Ordering is by seq_id, since a tag query spans streams and version is not a global order.
Guid tag values
Bound as lowercase canonical text, the same trap as everywhere else. Bound any other way, every lookup returns nothing.
Batched DCB reads
var batch = session.Events.CreateBatchQuery();
var exists = batch.EventsExist(queryA);
var boundary = batch.FetchForWritingByTags<Basket>(queryB);
await batch.Execute();The batch is where DCB is most useful: several boundaries opened against one coherent view, since the reads run back to back on one connection with nothing interleaved.
WARNING
It is not a performance feature on Fisher. See Batched Queries for why, and for the failure-handling contract.
Tags and rewriting
- Replacing an event deletes its tag rows. A tag describes the event that was appended, so carrying it over would let a tag query return the replacement as though it were the tagged event.
- Deleting events clears tag rows first.
fi_event_tag_*has a real foreign key tofi_events(seq_id)and enforcement is on, so the other order fails withFOREIGN KEY constraint failed. - Compacting removes every compacted event's tags, including the replaced one. Keeping the last event's tag while deleting the rest is the one outcome that is neither "the stream is still tagged" nor "the tagged events are gone".
Raised events from projections
An event-emitting async projection routes its raised events through Fisher's own append operation precisely so they get the sequence read-back — queuing bare per-event operations would silently make raised events untaggable.

JasperFx provides formal support for Fisher and other Critter Stack libraries. Please check our