Component: src/persistence/journals/DynamoDbJournal.ts
Severity (assessment): LOW
CWE: CWE-1220 (Insufficient Granularity of Access Control) / CWE-662 (Improper Synchronization)
readHead issues a Query for the highest event without ConsistentRead, then a GetItem for the compaction mark with ConsistentRead: true and an explicit comment about why strength matters. read (the recovery replay) also pages without ConsistentRead. DynamoDB's default eventually-consistent read can be served by a replica that has not yet seen the last second or so of writes, so both the head and the replayed stream can be short.
Exploit walkthrough
Preconditions: DynamoDB journal, and an entity restarting or rebalancing shortly after a write — the normal case during a rolling deploy or shard rebalance, and trivially forceable by an attacker who can make an entity restart (e.g. by driving it to a supervised failure) right after submitting a command. (1) Truncated replay: PersistentActor.preStart calls replayState, whose journal.read(pid, 1) is served by a lagging replica and omits the last event. The actor recovers with _seq and state one event behind reality and runs onRecoveryComplete(state) on that stale state — any decision taken there (emitting a notification, reconciling with an external system) is taken on state the journal has already superseded. The next persist does fail loudly (attribute_not_exists(pid) rejects it), so this is silent staleness rather than silent corruption. (2) Recovery denial: a snapshot legitimately taken at sequence 100 is loaded, assertTrustworthySnapshot calls journal.highestSeq(pid), the stale head query returns 99, and claimed > highest fires — the actor dies with a SnapshotIntegrityError accusing the store of tampering, and every restart re-rolls the same dice.
Evidence — src/persistence/journals/DynamoDbJournal.ts
src/persistence/journals/DynamoDbJournal.ts:245-266 — the two halves of one function disagree:
const head = await operations.query({
TableName: this.tableName,
KeyConditionExpression: 'pid = :pid AND seq > :meta',
...
ScanIndexForward: false,
Limit: 1,
ProjectionExpression: 'seq',
}); // <- no ConsistentRead
const headSeq = head.Items?.[0] ? readNumber(head.Items[0], 'seq') : 0;
const mark = await operations.getItem({
...
// A stale mark would let the head rewind, so this read must be strong.
ConsistentRead: true,
});
src/persistence/journals/DynamoDbJournal.ts:168-176 — the replay path, also unqualified:
const items = await this.queryAllPages(operations, {
TableName: this.tableName,
KeyConditionExpression: 'pid = :pid AND seq BETWEEN :from AND :to',
...
});
The sibling store gets it right — src/persistence/durable-state-stores/DynamoDbDurableStateStore.ts:123-129:
const found = await operations.getItem({
...
// A durable-state read feeds a CAS write, so an eventually-consistent read
// would let a caller compute its next revision from a stale one.
ConsistentRead: true,
});
and so does DynamoDbSnapshotStore only by accident — its loadLatest/loadBefore queries (DynamoDbSnapshotStore.ts:82-107) are likewise eventually consistent.
Why the existing guard does not cover it
The TransactWriteItems + attribute_not_exists(pid) conditional write (lines 122-145) is a genuine backstop for the write path: a stale head can never produce a silent overwrite, only a JournalConcurrencyError. The compaction mark's ConsistentRead: true shows the author reasoned about exactly this hazard for one of the two reads inside readHead. Neither guard covers the read path, and assertTrustworthySnapshot treats a short head as evidence of tampering rather than of replica lag.
Suggested fix
Set ConsistentRead: true on the head Query in readHead and on queryAllPages for read — the read-capacity cost is paid once per recovery, which is the right trade for the stream a recovering actor folds into its state. Consider the same for DynamoDbSnapshotStore.loadLatest, and make assertTrustworthySnapshot distinguish "snapshot ahead of head" from "store may be lagging" by re-reading the head consistently before failing.
Verification status
Found in the second, independent whole-framework security re-audit of 2026-08-02 (v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.
Verifier note
Every quoted block matches. src/persistence/journals/DynamoDbJournal.ts:245-267: the head query (:246-257) uses ScanIndexForward: false, Limit: 1, ProjectionExpression: 'seq' with no ConsistentRead, while the compaction-mark getItem fourteen lines later (:259-264) sets ConsistentRead: true under the comment "A stale mark would let the head rewind, so this read must be strong." The replay path is likewise unqualified — read calls queryAllPages (:168-176) and the helper (:291-306) only adds ExclusiveStartKey, never ConsistentRead. I confirmed the whole-tree picture with grep -rn ConsistentRead src/: exactly three hits — DynamoDbJournal.ts:263 and DynamoDbDurableStateStore.ts:128 and :154. So DynamoDbDurableStateStore.load (:121-129) does set it with its own justifying comment, and DynamoDbSnapshotStore.loadLatest/loadBefore (:80-107) do not. One function genuinely disagrees with itself and with its sibling store.
The consequence chain checks out. replayState (src/persistence/Replay.ts:67-99) calls assertTrustworthySnapshot(journal, pid, claimed) at :86, and that function (:110-135) does const highest = await journal.highestSeq(pid) — which routes to readHead (DynamoDbJournal.ts:189-196) — then throws SnapshotIntegrityError when highest > 0 && claimed > highest. A lagging head therefore converts replica lag into a tampering accusation that kills the actor on every restart. The truncated-replay branch also holds, and the finder's own limitation is correct: the write path's attribute_not_exists(pid) condition (:134) plus the head/expectedSeq compare at :114-116 mean a stale head yields a loud JournalConcurrencyError, never a silent overwrite — so the residue is stale recovery state feeding onRecoveryComplete (PersistentActor.ts:203), not corruption.
Corrections applied: the "attacker forces a restart" framing is struck (rolling deploys do the same thing and the adversary controls nothing about replica selection), the SnapshotIntegrityError case is stated as the race it is, and I noted that a compacted stream's strongly-consistent deletedTo can mask the stale head. Severity LOW: real defect, availability/staleness impact, no adversary.
Correction applied: Remove the attacker framing. "Trivially forceable by an attacker who can make an entity restart (e.g. by driving it to a supervised failure)" adds nothing — an ordinary rolling deploy or shard rebalance produces the same restart-shortly-after-write condition, and the attacker gains no control over which replica serves the read. File this as a correctness/availability defect with no adversary in the story. Also state the SnapshotIntegrityError precondition precisely: it needs the snapshot table's read to be fresh while the journal table's head read is stale — plausible because the two are independently replicated DynamoDB tables, but it is a race, not a deterministic outcome. Finally, readHead returns Math.max(headSeq, deletedTo) (DynamoDbJournal.ts:266), so on a stream that has been compacted the strongly-consistent deletedTo mark can mask a stale headSeq; the exposure is on streams whose head exceeds the compaction mark, i.e. the normal uncompacted case.
Component:
src/persistence/journals/DynamoDbJournal.tsSeverity (assessment): LOW
CWE: CWE-1220 (Insufficient Granularity of Access Control) / CWE-662 (Improper Synchronization)
readHeadissues aQueryfor the highest event withoutConsistentRead, then aGetItemfor the compaction mark withConsistentRead: trueand an explicit comment about why strength matters.read(the recovery replay) also pages withoutConsistentRead. DynamoDB's default eventually-consistent read can be served by a replica that has not yet seen the last second or so of writes, so both the head and the replayed stream can be short.Exploit walkthrough
Preconditions: DynamoDB journal, and an entity restarting or rebalancing shortly after a write — the normal case during a rolling deploy or shard rebalance, and trivially forceable by an attacker who can make an entity restart (e.g. by driving it to a supervised failure) right after submitting a command. (1) Truncated replay:
PersistentActor.preStartcallsreplayState, whosejournal.read(pid, 1)is served by a lagging replica and omits the last event. The actor recovers with_seqandstateone event behind reality and runsonRecoveryComplete(state)on that stale state — any decision taken there (emitting a notification, reconciling with an external system) is taken on state the journal has already superseded. The nextpersistdoes fail loudly (attribute_not_exists(pid)rejects it), so this is silent staleness rather than silent corruption. (2) Recovery denial: a snapshot legitimately taken at sequence 100 is loaded,assertTrustworthySnapshotcallsjournal.highestSeq(pid), the stale head query returns 99, andclaimed > highestfires — the actor dies with aSnapshotIntegrityErroraccusing the store of tampering, and every restart re-rolls the same dice.Evidence —
src/persistence/journals/DynamoDbJournal.tssrc/persistence/journals/DynamoDbJournal.ts:245-266 — the two halves of one function disagree:
src/persistence/journals/DynamoDbJournal.ts:168-176 — the replay path, also unqualified:
The sibling store gets it right — src/persistence/durable-state-stores/DynamoDbDurableStateStore.ts:123-129:
and so does DynamoDbSnapshotStore only by accident — its
loadLatest/loadBeforequeries (DynamoDbSnapshotStore.ts:82-107) are likewise eventually consistent.Why the existing guard does not cover it
The
TransactWriteItems+attribute_not_exists(pid)conditional write (lines 122-145) is a genuine backstop for the write path: a stale head can never produce a silent overwrite, only aJournalConcurrencyError. The compaction mark'sConsistentRead: trueshows the author reasoned about exactly this hazard for one of the two reads insidereadHead. Neither guard covers the read path, andassertTrustworthySnapshottreats a short head as evidence of tampering rather than of replica lag.Suggested fix
Set
ConsistentRead: trueon the headQueryinreadHeadand onqueryAllPagesforread— the read-capacity cost is paid once per recovery, which is the right trade for the stream a recovering actor folds into its state. Consider the same forDynamoDbSnapshotStore.loadLatest, and makeassertTrustworthySnapshotdistinguish "snapshot ahead of head" from "store may be lagging" by re-reading the head consistently before failing.Verification status
Found in the second, independent whole-framework security re-audit of 2026-08-02 (
v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.Verifier note
Every quoted block matches.
src/persistence/journals/DynamoDbJournal.ts:245-267: the headquery(:246-257) usesScanIndexForward: false, Limit: 1, ProjectionExpression: 'seq'with noConsistentRead, while the compaction-markgetItemfourteen lines later (:259-264) setsConsistentRead: trueunder the comment "A stale mark would let the head rewind, so this read must be strong." The replay path is likewise unqualified —readcallsqueryAllPages(:168-176) and the helper (:291-306) only addsExclusiveStartKey, neverConsistentRead. I confirmed the whole-tree picture withgrep -rn ConsistentRead src/: exactly three hits — DynamoDbJournal.ts:263 and DynamoDbDurableStateStore.ts:128 and :154. SoDynamoDbDurableStateStore.load(:121-129) does set it with its own justifying comment, andDynamoDbSnapshotStore.loadLatest/loadBefore(:80-107) do not. One function genuinely disagrees with itself and with its sibling store.The consequence chain checks out.
replayState(src/persistence/Replay.ts:67-99) callsassertTrustworthySnapshot(journal, pid, claimed)at :86, and that function (:110-135) doesconst highest = await journal.highestSeq(pid)— which routes toreadHead(DynamoDbJournal.ts:189-196) — then throwsSnapshotIntegrityErrorwhenhighest > 0 && claimed > highest. A lagging head therefore converts replica lag into a tampering accusation that kills the actor on every restart. The truncated-replay branch also holds, and the finder's own limitation is correct: the write path'sattribute_not_exists(pid)condition (:134) plus the head/expectedSeq compare at :114-116 mean a stale head yields a loudJournalConcurrencyError, never a silent overwrite — so the residue is stale recovery state feedingonRecoveryComplete(PersistentActor.ts:203), not corruption.Corrections applied: the "attacker forces a restart" framing is struck (rolling deploys do the same thing and the adversary controls nothing about replica selection), the SnapshotIntegrityError case is stated as the race it is, and I noted that a compacted stream's strongly-consistent
deletedTocan mask the stale head. Severity LOW: real defect, availability/staleness impact, no adversary.Correction applied: Remove the attacker framing. "Trivially forceable by an attacker who can make an entity restart (e.g. by driving it to a supervised failure)" adds nothing — an ordinary rolling deploy or shard rebalance produces the same restart-shortly-after-write condition, and the attacker gains no control over which replica serves the read. File this as a correctness/availability defect with no adversary in the story. Also state the SnapshotIntegrityError precondition precisely: it needs the snapshot table's read to be fresh while the journal table's head read is stale — plausible because the two are independently replicated DynamoDB tables, but it is a race, not a deterministic outcome. Finally,
readHeadreturnsMath.max(headSeq, deletedTo)(DynamoDbJournal.ts:266), so on a stream that has been compacted the strongly-consistentdeletedTomark can mask a staleheadSeq; the exposure is on streams whose head exceeds the compaction mark, i.e. the normal uncompacted case.