Severity / Size
- Severity: MEDIUM — recovery applies events in delivery order; if the journal returns events out of seq order (whether because of journal bug, attacker tampering, or backend misbehaviour), the actor's state silently diverges from any legitimate replay. No exception thrown; the bug is invisible until later cross-actor inconsistency is observed.
- Size: S (~1d).
- Threat model: backend that returns events in wrong order — could be a buggy custom
Journal implementation, a corrupted backing store, or an attacker with write access to the journal (shared SQLite file, compromised Cassandra coordinator, S3-like backend with stale-read semantics). Companion to the snapshot-seq-corruption fix (99de741).
Affected files
src/persistence/PersistentActor.ts:193-198 — the replay loop. No per-event seq validation. Trusts journal.read() to return events in monotonically-increasing seq order.
src/persistence/Journal.ts — interface contract. Documents read() as returning events in (fromSeq, ..., toSeq] order but doesn't enforce it.
Background
PersistentActor.recover() already validates the snapshot's seq against the journal (snapshot-seq integrity fix 99de741). But the per-event seq numbers inside journal.read()'s return are trusted blindly: the loop walks them in delivery order and applies onEvent to each.
The contract says read(pid, fromSeq, toSeq?) returns events in [fromSeq, ..., toSeq] order. In-memory and SQLite journals respect this naturally — they ORDER BY seq. But:
- A custom Journal impl might forget the ORDER BY clause.
- A distributed backend (Cassandra) might return rows in a different order if the partition key isn't
seq-bound.
- An attacker with row-level write access could shuffle the journal's storage representation.
- A read replica with eventually-consistent visibility could deliver events that were committed in order but are visible in a different order during the replay window.
The pattern matches 99de741: don't trust the persistence-layer return; validate at the recovery boundary.
Exploit walkthrough
Setup: PersistentActor account-42 has journal events:
- seq=1:
{kind: 'created', balance: 0}
- seq=2:
{kind: 'deposit', amount: 100}
- seq=3:
{kind: 'withdraw', amount: 50}
Legitimate state after recovery: {balance: 50}.
Adversarial scenario — a buggy custom journal returns the events in order [1, 3, 2]:
Step 1 — recovery starts. Snapshot loaded (if any) at seq < 1 → starts from seq=1.
Step 2 — replay loop:
- event 1 (seq=1): apply → state =
{balance: 0} (created). this._seq = 1.
- event 2 (seq=3): apply → state =
{balance: -50} (withdraw before deposit → negative). this._seq = 3.
- event 3 (seq=2): apply → state =
{balance: 50} (deposit lands). this._seq = 2.
After replay, this._seq = 2 (last applied seq). The next persist() will use expectedSeq = 2 + 1 = 3 — colliding with the existing seq=3 event in the journal. Append throws JournalConcurrencyError. The actor sees a phantom concurrency error on its first persist after recovery.
State during the negative-balance window may have triggered side-effects (logging, metrics, alerts). The final state happens to match — but only by coincidence (idempotent CRDT-like ops); for non-commutative event semantics, the final state is wrong.
The harder failure mode: events that are non-commutative — final state depends on order. Example: a setPassword event followed by disableAccount. If reordered: disabled-then-password-set → account is enabled but with the old password. Privilege escalation or unintended visibility.
How the 8 already-landed security fixes inform this
- Snapshot seq integrity (
99de741): set the precedent — validate persistence-layer claims at the recovery boundary. This issue extends the same defense to per-event seqs.
- Gossip version-cap (
709431b): pattern of "monotonic invariant must hold, reject otherwise". Apply to event seqs during replay.
Fix design
One-track defense, mirroring the snapshot-seq validation.
Track 1 — per-event seq monotonicity check.
In the replay loop:
let expectedSeq = this._seq + 1;
for (const ev of events) {
if (!Number.isInteger(ev.sequenceNr) || ev.sequenceNr < expectedSeq) {
throw new Error(
`[persistence] '${this.persistenceId}' journal returned event with sequenceNr=${ev.sequenceNr}, ` +
`expected ${expectedSeq}+ (events must be strictly monotonically increasing from ${this._seq + 1})`,
);
}
if (ev.sequenceNr > expectedSeq) {
// Gap detection: ev.sequenceNr > expected means events between
// `expected` and `ev.sequenceNr` are missing. Decision: reject.
// Recovery on a missing-event journal would produce wrong state.
throw new Error(
`[persistence] '${this.persistenceId}' journal has gap: expected seq ${expectedSeq}, got ${ev.sequenceNr}`,
);
}
const decoded = decodeEvent<Event>(ev.event, evAdapter);
this._state = this.onEvent(this._state, decoded);
this._seq = ev.sequenceNr;
expectedSeq = ev.sequenceNr + 1;
}
Three properties enforced:
- Integer non-negative: seq must be a sane number.
- Monotonic non-decreasing: each event's seq ≥ expected, never goes backwards.
- No gaps: each event's seq exactly matches expected.
The "no gaps" choice is the strict reading. An alternative is to allow gaps (skip-and-warn) for journals that legitimately have holes from deleted-events / compaction. But this contradicts the documented Journal.delete(pid, toSeq) semantics — deletion compacts the prefix, not the middle. So holes in read() output indicate a real issue.
Track 2 — counter metric.
persistent_actor_replay_rejected_total{reason: 'out_of_order' | 'gap'} for operator visibility.
Track 3 — diagnostic context in the error.
Include the actor's persistenceId, the expected seq, and the offending event's seq in the error message. Operators investigating a "phantom CAS error" can trace back to the bad journal read.
API surface
No public-API change. Internal recovery loop hardened.
Backward compatibility
- Legitimate journals (InMemory, SQLite) already return monotonic events — unchanged behaviour.
- Cassandra journal: verify that the
events_by_persistence_id query orders by seq (it should, by the CQL primary-key design). Add a regression test against FakeCassandraClient to confirm.
- Custom user journals: any impl that returns events out of order will now fail loudly at recovery instead of silently corrupting state. Loud failure is an improvement.
Test plan
-
Exploit-equivalent test (tests/unit/persistence/PersistentActor-replay-order.test.ts): use a custom stub Journal that returns events [seq=1, seq=3, seq=2]; recovery throws with a clear "out-of-order" message; actor is terminated by supervisor.
-
Gap-detection test: Journal returns events [seq=1, seq=2, seq=4] (missing seq=3); recovery throws "gap" error.
-
Boundary test: Journal returns events [seq=0] while expectedSeq=0 → accepted (legit edge case); [seq=-1] rejected.
-
Backward-compat test: in-memory + SQLite journal replay produces correctly-ordered events for all existing PersistentActor tests; none of them break.
-
Regression: existing PersistentActor.test.ts + snapshot-integrity.test.ts (the existing seq-tampering test) all pass.
Acceptance criteria
Severity / Size
Journalimplementation, a corrupted backing store, or an attacker with write access to the journal (shared SQLite file, compromised Cassandra coordinator, S3-like backend with stale-read semantics). Companion to the snapshot-seq-corruption fix (99de741).Affected files
src/persistence/PersistentActor.ts:193-198— the replay loop. No per-event seq validation. Trustsjournal.read()to return events in monotonically-increasing seq order.src/persistence/Journal.ts— interface contract. Documentsread()as returning events in(fromSeq, ..., toSeq]order but doesn't enforce it.Background
PersistentActor.recover()already validates the snapshot's seq against the journal (snapshot-seq integrity fix99de741). But the per-event seq numbers insidejournal.read()'s return are trusted blindly: the loop walks them in delivery order and appliesonEventto each.The contract says
read(pid, fromSeq, toSeq?)returns events in[fromSeq, ..., toSeq]order. In-memory and SQLite journals respect this naturally — they ORDER BY seq. But:seq-bound.The pattern matches
99de741: don't trust the persistence-layer return; validate at the recovery boundary.Exploit walkthrough
Setup: PersistentActor
account-42has journal events:{kind: 'created', balance: 0}{kind: 'deposit', amount: 100}{kind: 'withdraw', amount: 50}Legitimate state after recovery:
{balance: 50}.Adversarial scenario — a buggy custom journal returns the events in order [1, 3, 2]:
Step 1 — recovery starts. Snapshot loaded (if any) at seq < 1 → starts from seq=1.
Step 2 — replay loop:
{balance: 0}(created).this._seq = 1.{balance: -50}(withdraw before deposit → negative).this._seq = 3.{balance: 50}(deposit lands).this._seq = 2.After replay,
this._seq = 2(last applied seq). The nextpersist()will useexpectedSeq = 2 + 1 = 3— colliding with the existing seq=3 event in the journal. Append throwsJournalConcurrencyError. The actor sees a phantom concurrency error on its first persist after recovery.State during the negative-balance window may have triggered side-effects (logging, metrics, alerts). The final state happens to match — but only by coincidence (idempotent CRDT-like ops); for non-commutative event semantics, the final state is wrong.
The harder failure mode: events that are non-commutative — final state depends on order. Example: a
setPasswordevent followed bydisableAccount. If reordered: disabled-then-password-set → account is enabled but with the old password. Privilege escalation or unintended visibility.How the 8 already-landed security fixes inform this
99de741): set the precedent — validate persistence-layer claims at the recovery boundary. This issue extends the same defense to per-event seqs.709431b): pattern of "monotonic invariant must hold, reject otherwise". Apply to event seqs during replay.Fix design
One-track defense, mirroring the snapshot-seq validation.
Track 1 — per-event seq monotonicity check.
In the replay loop:
Three properties enforced:
The "no gaps" choice is the strict reading. An alternative is to allow gaps (skip-and-warn) for journals that legitimately have holes from deleted-events / compaction. But this contradicts the documented
Journal.delete(pid, toSeq)semantics — deletion compacts the prefix, not the middle. So holes inread()output indicate a real issue.Track 2 — counter metric.
persistent_actor_replay_rejected_total{reason: 'out_of_order' | 'gap'}for operator visibility.Track 3 — diagnostic context in the error.
Include the actor's persistenceId, the expected seq, and the offending event's seq in the error message. Operators investigating a "phantom CAS error" can trace back to the bad journal read.
API surface
No public-API change. Internal recovery loop hardened.
Backward compatibility
events_by_persistence_idquery orders byseq(it should, by the CQL primary-key design). Add a regression test againstFakeCassandraClientto confirm.Test plan
Exploit-equivalent test (
tests/unit/persistence/PersistentActor-replay-order.test.ts): use a custom stub Journal that returns events[seq=1, seq=3, seq=2]; recovery throws with a clear "out-of-order" message; actor is terminated by supervisor.Gap-detection test: Journal returns events
[seq=1, seq=2, seq=4](missing seq=3); recovery throws "gap" error.Boundary test: Journal returns events
[seq=0]whileexpectedSeq=0→ accepted (legit edge case);[seq=-1]rejected.Backward-compat test: in-memory + SQLite journal replay produces correctly-ordered events for all existing PersistentActor tests; none of them break.
Regression: existing
PersistentActor.test.ts+snapshot-integrity.test.ts(the existing seq-tampering test) all pass.Acceptance criteria
sequenceNris finite, non-negative integer, equal to expected (no gaps, no out-of-order).persistent_actor_replay_rejected_totalmetric exposed withreasonlabel.