Skip to main content

guides/dcb.md

# Dynamic Consistency Boundary (DCB)

*Available in reckon-db 3.1.1+.*

DCB is the cross-cutting complement to per-aggregate optimistic
concurrency. Where `reckon_db_streams:append/4,5` enforces "no new
events on THIS stream since version N" before writing, DCB enforces
"no event matching THIS tag-filter has been written since seq N" —
without ever scoping to a single stream.

Use DCB when the invariant a write must preserve crosses streams:

- **Uniqueness**: "no other user has claimed this email"
- **Allocation**: "this slot has not been reserved by anyone else"
- **Rate limit**: "this caller has not exceeded N writes in the last
  window"
- **Eligibility**: "no exclusion event applies to this principal"

These don't fit per-aggregate locks because the invariant references
data living in events the aggregate doesn't own. DCB lets you read a
query-shaped slice of history, decide on it, and atomically commit
new events conditionally on nothing in that slice having moved.

---

## The Decision loop

The canonical pattern:

```
1. Read context events matching a tag-filter, get max observed seq
2. Run domain logic on those events, produce new events
3. Conditionally append the new events: write iff no event matching
   the same filter has seq > the observed max
4. On conflict, refresh the context and retry (bounded)
```

The conditional-append is atomic across the cluster — either all
events commit, or none do, with the precondition evaluated inside
the same Ra log entry as the write.

---

## API

### Direct (reckon-db)

```erlang
%% Read context: events matching the filter, scoped to the DCB
%% pseudo-stream. The runtime equivalent (evoq_decision behaviour)
%% wraps this; raw users can read via tags + filter client-side.

%% Conditional append:
case reckon_db_streams:append_if_no_tag_matches(
       StoreId, TagFilter, SeqCutoff, Events) of
    {ok, LastSeq} ->
        %% Committed. LastSeq is the seq assigned to the final
        %% event. Use it as the cutoff for follow-up writes
        %% against an overlapping tag set.
        ok;
    {error, {context_changed, MaxSeq}} ->
        %% Conflict: an event matching TagFilter has seq > SeqCutoff.
        %% Nothing was written. Refresh the context (re-read events
        %% matching the filter, observe new max seq) and retry.
        retry_with(MaxSeq);
    {error, no_events} ->
        %% Empty Events list; client error.
        ok;
    {error, Reason} ->
        %% Backend error (Ra unavailable, etc.).
        {error, Reason}
end.
```

### Through the gateway (Erlang clients on remote BEAMs)

```erlang
reckon_gater_api:append_if_no_tag_matches(StoreId, TagFilter, SeqCutoff, Events).
```

Same signature, same return shape. Routes via the gater worker pool.

### Through the evoq behaviour (high-level)

For BEAM consumers, the `evoq_decision` behaviour wraps the
read/decide/write loop with retry + backoff. See
[evoq's decisions guide](https://codeberg.org/reckon-db-org/evoq/src/branch/main/guides/decisions.md).

### Through gRPC (polyglot clients)

reckon-gateway 0.7.0+ exposes `DcbService` over gRPC. Go SDK:

```go
d := client.Dcb("orders")
res, _ := d.Read(ctx, dcb.MatchAny("slot:42"), 100)
committed, conflict, err := d.Append(ctx,
    dcb.MatchAny("slot:42"), res.MaxSeq,
    []dcb.ProposedEvent{...})
```

---

## TagFilter algebra

A `tag_filter()` is a recursive predicate over an event's tag set.
Four shapes:

| Shape | Meaning | Erlang term |
|-------|---------|-------------|
| `any_of` | Event matches if it carries ANY of these tags | `{any_of, [Tag]}` |
| `all_of` | Event matches if it carries ALL of these tags | `{all_of, [Tag]}` |
| `and_` | Event matches if ALL sub-filters match | `{and_, [Filter]}` |
| `or_` | Event matches if ANY sub-filter matches | `{or_, [Filter]}` |

Filters compose to arbitrary depth. Example:

```erlang
%% "Has tag 'slot:42' AND either tag 'tenant:acme' or tag 'tenant:globex'"
Filter = {and_, [
    {any_of, [<<"slot:42">>]},
    {or_, [
        {any_of, [<<"tenant:acme">>]},
        {any_of, [<<"tenant:globex">>]}
    ]}
]}.
```

---

## The DCB pseudo-stream

DCB events live under the pseudo-stream id `<<"_dcb">>`. They are
real events — they appear in `read_all_global`, `read_by_event_types`,
subscription deliveries, the gateway gRPC surface, every existing
read path — but the consistency-check semantics only apply to
events written *through* `append_if_no_tag_matches/4`. Direct
`append/4,5` to the `_dcb` stream is not a meaningful operation
and should be avoided.

The seq counter used by DCB is **separate from** the per-stream
version counter. It is a monotonic per-store integer, incremented
only by successful DCB commits.

---

## The seq cutoff

`SeqCutoff` is the highest seq the caller has observed for events
matching `TagFilter`. The server rejects the write if any event
matching the filter has seq **strictly greater than** the cutoff.

The sentinel `-1` means "I have observed nothing yet." Use it when:

- The caller is making a uniqueness claim and expects no matching
  event to exist (e.g., "this email has never been registered").
- The caller is the first writer in a fresh boundary.

After a successful commit, the returned `LastSeq` is the cutoff for
a follow-up write against an overlapping tag set, *if* you can
guarantee no concurrent third party wrote between your commit and
your follow-up. Most callers re-read fresh context instead.

---

## Worked example: email uniqueness

Goal: register a user with an email, atomically guaranteeing no
other user already holds that email.

```erlang
-module(register_user).

-export([register/2]).

-define(STORE, my_store).

register(Email, UserData) ->
    EmailTag = <<"email:", Email/binary>>,
    Filter = {any_of, [EmailTag]},

    %% 1. Read context: any user_registered event with this email
    %% tag. The runtime read uses read_by_tags + client-side filter
    %% scope to the _dcb stream. Higher-level libraries
    %% (evoq_decision) do this automatically.
    {ok, MatchingEvents} = read_dcb_events_matching(?STORE, Filter),
    Cutoff = max_seq(MatchingEvents),

    %% 2. Decide.
    case has_registration(MatchingEvents) of
        true ->
            {error, email_already_registered};
        false ->
            Event = #{
                event_type => <<"user_registered_v1">>,
                data => UserData#{email => Email},
                tags => [EmailTag]
            },
            %% 3. Append conditionally.
            case reckon_db_streams:append_if_no_tag_matches(
                   ?STORE, Filter, Cutoff, [Event]) of
                {ok, Seq} ->
                    {ok, Seq};
                {error, {context_changed, _}} ->
                    %% Concurrent writer claimed the email.
                    register(Email, UserData)
            end
    end.

%% Helpers omitted for brevity.
```

Note that the `Filter` and the events written share the same tag
`<<"email:Email>>`. This is how DCB events become visible to future
consistency checks: their own tags are indexed alongside, so a
subsequent read with the same filter will see them.

---

## Integrity (tamper-resistance)

On integrity-enabled stores, DCB events carry the same
`prev_event_hash` + `mac` fields as regular events, linked into a
per-store DCB chain. The Khepri transaction body can't run
`crypto:*` (Horus extractor rejects it), so MAC chains are
pre-computed outside the transaction and the chain-tip is verified
inside. Concurrent-writer races on the chain-tip are bounded by
`?INTEGRITY_RETRY_BUDGET` (5) before surfacing
`{error, dcb_concurrent_writer_exhausted}`.

This is invisible to callers: same API, same return shape.

---

## When NOT to use DCB

- **Per-aggregate invariants**: use stream-version concurrency
  (`append/4,5`) on the aggregate's own stream. DCB is for
  cross-cutting invariants.
- **Read-modify-write within one aggregate**: an aggregate's
  `apply/2` callback already serializes through the aggregate
  process. DCB doesn't add value here.
- **Eventual consistency is acceptable**: if the read model can
  reconcile after the fact, you don't need DCB's atomic precondition.

---

## See also

- [evoq decisions guide](https://codeberg.org/reckon-db-org/evoq/src/branch/main/guides/decisions.md) — the high-level Erlang API
- [`plans/PLAN_DCB_IMPLEMENTATION.md`](https://codeberg.org/reckon-db-org/reckon-db/src/branch/main/plans/PLAN_DCB_IMPLEMENTATION.md) — implementation design
- [hecate-corpus `CONSISTENCY_BOUNDARIES.md`](https://codeberg.org/hecate-social/hecate-corpus/src/branch/main/philosophy/CONSISTENCY_BOUNDARIES.md) — the doctrine
- Ericsson Architects' [aggregateless event sourcing](https://ricofritzsche.me/aggregateless-event-sourcing/) — the inspiration