Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,23 @@ says why it isn't there and what would change that.
- **Kani for aggregates** — state-space explosion over struct state needs its own
design.

- **Hash-chained append / per-entry tamper evidence.** `replay_hash` proves the
*outcome* (a terminal `AuditDigest` over final state); it cannot answer "was
entry 4,102 altered?". A per-entry chain can. It is deferred, and deliberately
being built **downstream first** by the adopter who needs it, for three
reasons. The claim is only worth its verification, and a chain is evidence
only when its root is *externally anchored* — anchoring is application
infrastructure, so ironstate would ship the half that does not deliver the
property alone. Of the two known consumers only one has the requirement; the
other has no audit machinery and could not consume it anyway. And the obvious
shape is wrong: a `Journal` decorator is unreachable from an async adapter, so
if this is ever upstreamed it must be **pure functions** —
`link_hash(prev, seq, encoded_events, entropy_pos)` plus a `verify_chain` over
an iterator — hashing bytes the adapter already persists, per *batch* (one
record), and carrying a per-link algorithm tag so the chain survives an
algorithm migration. *Activates when a downstream chain has proven the design
against a real anchor.*

**Out of scope — downstream, or unneeded.**

- **Storage adapters** (Postgres, SQLite, …) — still downstream, written against
Expand Down
2 changes: 1 addition & 1 deletion app/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ ironstate = { version = "0.1", path = "crates/ironstate" }
ironstate-derive = { version = "0.1", path = "crates/ironstate-derive" }
ironstate-aggregate = { version = "0.1", path = "crates/ironstate-aggregate" }
ironstate-aggregate-derive = { version = "0.1", path = "crates/ironstate-aggregate-derive" }
ironstate-journal = { version = "0.2", path = "crates/ironstate-journal" }
ironstate-journal = { version = "0.3", path = "crates/ironstate-journal" }

# external — pin minor, let patch float
rustc-stable-hash = "0.1" # rust-lang-maintained engine behind Digest128
Expand Down
11 changes: 6 additions & 5 deletions app/crates/examples/async-store/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,13 +261,14 @@ impl<A: AggregateRules + Clone> Log<A> {
Ok(self
.records(stream)
.iter()
.enumerate()
.skip(start)
.flat_map(|record| record.events.iter())
.map(|event| VersionedEvent {
event: event.clone(),
type_name: type_name.clone(),
version: 1,
.flat_map(|(i, record)| {
// The record's Seq, shared by every event it holds.
let seq = Seq(i as u64 + 1);
record.events.iter().map(move |event| (seq, event))
})
.map(|(seq, event)| VersionedEvent::new(event.clone(), seq, type_name.clone(), 1))
.collect())
}

Expand Down
12 changes: 5 additions & 7 deletions app/crates/examples/hidden-info/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,17 +503,15 @@ fn run_demo() -> Result<()> {
})?;
let mut subscription: Subscription<MatchState, PlayerProfile> = Subscription::new();
let mut profile_ctx = ctx(&seed, DrawPos(0), 0);
for (i, event) in journal
.events_since(&match_stream(), None)
.unwrap()
.iter()
.enumerate()
{
for event in journal.events_since(&match_stream(), None).unwrap().iter() {
subscription
.deliver(
SourceEvent {
stream: &match_stream(),
at: Seq(i as u64 + 1),
// The record's own Seq, not its index in the flattened list:
// one record can hold several events, so an index would run
// ahead of the head and mark real events as duplicates.
at: event.seq,
event: &event.event,
},
&StreamId::new("profile"),
Expand Down
62 changes: 62 additions & 0 deletions app/crates/ironstate-journal/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,68 @@ on [Keep a Changelog](https://keepachangelog.com/); from the next release on, th
entries are maintained by release-plz. This crate is the journal tier of the
[ironstate](https://github.com/kassian-dev/ironstate) family.

## [0.3.0](https://github.com/kassian-dev/ironstate/compare/ironstate-journal-v0.2.0...ironstate-journal-v0.3.0) - 2026-08-29

### Added

- `RetainableJournal`: `truncate_before` discards the oldest records in a
stream, refusing anything that would leave it unresumable; `retained_from`
reports the horizon and `streams` enumerates what a retention sweep could
expire. Conformance property 10 proves a truncated stream resumes to a
bit-identical aggregate.
- `VersionedEvent::new`, the constructor adapters build results with.

### Changed

- **Breaking:** `VersionedEvent` carries the `Seq` of the record an event came
from, and is now `#[non_exhaustive]`. `events_since` flattens events across
records, so an index into its result is not a sequence — a subscription keyed
on one silently dropped events as duplicates once any `decide` emitted more
than one. Construct through `VersionedEvent::new`; the next field is then
additive rather than breaking.
- `JournalError` gains `NoBaseForFork` and `NoSnapshotForTruncation`, and
`UnknownSeq` now also covers reads, forks and truncations below a retained
horizon.

### Fixed

- `MemoryJournal` no longer panics anywhere: `append_in` and `truncate_before`
return typed errors rather than asserting internal invariants.
- Truncation no longer strands a fork on a snapshot whose records it discarded,
which could return a wrong aggregate as `Ok`.
- A fully truncated stream reports its head instead of `None`, so `execute` no
longer rewinds the entropy stream to `DrawPos(0)`.
- Reading from below the retained horizon is refused rather than returning a
silently gapped list.

## [0.2.0](https://github.com/kassian-dev/ironstate/compare/ironstate-journal-v0.1.4...ironstate-journal-v0.2.0) - 2026-08-29

Recorded after the fact: 0.2.0 was published by the release workflow on a
version bump rather than through a release-plz PR, so no entry was generated
at the time.

### Changed

- **Breaking:** `Journal` takes a `&StreamId` on every operation, and `Seq` is
per-stream. One journal value now holds many aggregate instances, which makes
the write side agree with `Subscription`, whose idempotency key was already
`(StreamId, Seq)`.
- **Breaking:** `Journal` gains an associated `Tx<'a>`, the caller's unit of
work, with `append_in`/`snapshot_in`. A journal owning its durability sets it
to `()`, and `execute`/`resume` keep their previous shape through a
`for<'a> Journal<A, Tx<'a> = ()>` bound.
- **Breaking:** `fork` moves to `ForkableJournal`. With per-stream sequences a
whole-journal fork point is ill-defined, and a relational adapter should not
owe row-copying for a method it never calls.

### Added

- `execute_in` and `Pending`, for a journal enlisting in the caller's
transaction. `execute_in` deliberately does not evolve the aggregate: a
rollback after the append would otherwise leave it ahead of the durable log.
- Conformance properties for stream independence and out-of-range addressing;
`run_contract_forkable` for adapters that branch.

## [0.1.4](https://github.com/kassian-dev/ironstate/compare/ironstate-journal-v0.1.3...ironstate-journal-v0.1.4) - 2026-06-23

### Other
Expand Down
2 changes: 1 addition & 1 deletion app/crates/ironstate-journal/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ironstate-journal"
version = "0.2.0"
version = "0.3.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
Expand Down
49 changes: 41 additions & 8 deletions app/crates/ironstate-journal/README.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,65 @@
# →(Fe) ironstate-journal

The event journal for [ironstate](https://docs.rs/ironstate) aggregates:
append, snapshot, replay, fork, and subscribe — with the entropy position
recorded atomically beside every event, so an aggregate can be replayed and
resumed bit-identically.
append, snapshot, replay and subscribe — with the entropy position recorded
atomically beside every event, so an aggregate can be replayed and resumed
bit-identically.

Because replay consumes no entropy (only `decide` draws), the draw position
cannot be recomputed from the events — it must be stored. Every append persists
the post-`decide` position together with its events, in one atomic unit.

One journal holds **many streams**, each an aggregate instance with its own
`Seq` line, so the write side addresses history the same way `Subscription`
always has.

```rust,ignore
// The canonical persistent loop: structural checks → decide → append → evolve.
let seq = execute(&mut journal, &mut aggregate, &command, &mut ctx)?;
let stream = StreamId::new("account-42");
let seq = execute(&mut journal, &stream, &mut aggregate, &command, &mut ctx)?;

// Rebuild an aggregate (and its entropy stream) from the journal.
let (aggregate, entropy) = resume(&journal, &seed)?;
let (aggregate, entropy) = resume(&journal, &stream, &seed)?;

// A published, collision-resistant digest anyone can recompute to audit a match.
let digest = replay_hash(snapshot, &events)?;
```

## Appending inside your own transaction

A journal that owns its durability sets `type Tx<'a> = ()` and the calls above
apply unchanged. A journal living in the same database as your read models sets
`Tx` to that database's transaction, so the append and your other writes commit
together or not at all:

```rust,ignore
let mut tx = pool.begin()?;
let pending = execute_in(&mut journal, &mut tx, &stream, &aggregate, &cmd, &mut ctx)?;
write_read_model(&mut tx, ...)?; // your other writes,
enqueue_outbound_job(&mut tx, ...)?; // in the same transaction
match tx.commit() {
Ok(()) => { let seq = pending.commit(&mut aggregate); }
Err(e) => { pending.abort(&mut ctx); return Err(e.into()); }
}
```

`execute_in` deliberately does *not* evolve the aggregate — a rollback after the
append would otherwise leave it silently ahead of the durable log. The returned
`Pending` is driven once your transaction resolves.

Two capabilities are opt-in, because not every store can or should offer them:
`ForkableJournal` branches a stream's history (essential for simulation,
meaningless for a statutory record), and `RetainableJournal` truncates the
oldest records once a snapshot covers what remains.

A `Subscription` delivers one aggregate's events to another exactly once
(`(StreamId, Seq)` idempotency keys), and `Versioned` events upcast through a
`MigrateFrom` chain on load.

The reference in-memory journal (feature `memory`, default) passes a
seven-property conformance suite (`journal_contract_test!`) that every storage
adapter is judged against; storage adapters themselves live downstream. Under
The reference in-memory journal (feature `memory`, default) passes the
conformance suite (`journal_contract_test!`) that every storage adapter is
judged against — eight properties for any journal, plus two for forkable ones
and one for retainable ones; storage adapters themselves live downstream. Under
feature `sim`, `scenario_test!` drives a seeded whole-tier fault simulation
(append failures, crash/resume, fork/continue) and checks faults are invisible
to outcomes, with the `FaultInjector`/`ReferenceRun` testkit exposed for
Expand Down
Loading