ADR-009: Transaction Links, Deduplication, and Reversal
Status: Accepted (TxLink + Dedup implemented; Reversal deferred) Date: 2026-04-04 Author: Taleh Ibrahimli
Amends:
- ADR-001 — adds
WalEntryKind::Link,TxLinkrecord,link_counttoTxMetadata - ADR-003 — adds
FailReason::DUPLICATE, deduplication to Transactor
Context
Three related concerns are addressed together because they share the same underlying
mechanism — TxLink — and are best understood as a coherent feature:
1. Transactions have no relationships.
A duplicate rejection carries FailReason::DUPLICATE but no reference to the
original committed transaction. A reversal is just a regular transaction with no
link to what it reverses. Audit trails are incomplete and callers must maintain
their own cross-reference tables outside the ledger.
2. No duplicate protection.
user_ref was designed as an opaque external reference but nothing enforces
uniqueness. A client that retries after a network failure or crash recovery will
create duplicate transactions — double charges, duplicate deposits. The crash
recovery resubmission pattern in ADR-008 explicitly requires retry after crash,
which without deduplication corrupts balances.
3. No reversal mechanism.
Reversing a transaction requires submitting a mirror Complex operation. The
relationship between the reversal and the original exists only in the application
layer, not in the ledger itself.
Decision
TxLink — first-class WAL record
Introduce TxLink as a new WAL record type. TxLink is a sub-record of a
transaction — it follows the TxEntry records for the same TxMetadata and is
counted in a new link_count field. The tx_id of the link is the enclosing
TxMetadata.tx_id — not stored in TxLink itself.
Deduplication — flip-flop HashMap in Transactor
The Transactor maintains two HashMap<u64, u64> maps (user_ref → tx_id) that
flip on a configurable time window. A duplicate user_ref within the window
is rejected with FailReason::DUPLICATE and a TxLink { kind: Duplicate } is
written to the WAL pointing to the original committed transaction.
Reversal Operation (deferred — not yet implemented)
A reversal will be submitted as Operation::Reversal { to_tx_id, user_ref }.
The ledger will locate the original transaction, create opposite entries for it
(debit <=> credit, credit <=> debit), and add a
TxLink { kind: Reversal, to_tx_id: original_tx_id }.
The TxLinkKind::Reversal variant is reserved in the WAL format (value = 1)
but the Operation::Reversal variant and its Transactor logic are not yet
implemented. This will be added in a future ADR or follow-up to this one.
WAL Record Changes
Updated TxMetadata (40 bytes, unchanged total size)
link_count is added at offset 2, shifting fail_reason and replacing flags.
Total size remains 40 bytes.
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct TxMetadata {
pub entry_type: u8, // 1 @ 0 — WalEntryKind::TxMetadata
pub entry_count: u8, // 1 @ 1 — number of TxEntry records following
pub link_count: u8, // 1 @ 2 — number of TxLink records after entries
pub fail_reason: FailReason, // 1 @ 3
pub crc32c: u32, // 4 @ 4
pub tx_id: u64, // 8 @ 8
pub timestamp: u64, // 8 @ 16
pub user_ref: u64, // 8 @ 24
pub tag: [u8; 8], // 8 @ 32
} // 40 bytes total
}
TxLink (40 bytes)
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct TxLink {
pub entry_type: u8, // 1 @ 0 — WalEntryKind::Link (4)
pub link_kind: TxLinkKind, // 1 @ 1
pub _pad: [u8; 6], // 6 @ 2
pub to_tx_id: u64, // 8 @ 8 — referenced transaction
pub _pad2: [u8; 24], // 24 @ 16
} // 40 bytes total
#[repr(u8)]
pub enum TxLinkKind {
Duplicate = 0, // this transaction is a duplicate of to_tx_id
Reversal = 1, // this transaction reverses to_tx_id
}
}
Updated WalEntry
#![allow(unused)]
fn main() {
pub enum WalEntry {
Metadata(TxMetadata), // kind = 0
Entry(TxEntry), // kind = 1
SegmentHeader(SegmentHeader), // kind = 2
SegmentSealed(SegmentSealed), // kind = 3
Link(TxLink), // kind = 4 ← new
}
}
WAL sequence
TxMetadata { tx_id, entry_count: N, link_count: M, ... }
TxEntry × N
TxLink × M
Reader advances through entry_count TxEntry records then link_count TxLink
records. Exact counts known from TxMetadata — no scanning needed.
CRC coverage
CRC32C in TxMetadata.crc32c covers the complete logical transaction:
CRC input:
TxMetadata bytes (crc32c field zeroed)
+ all TxEntry records (entry_count)
+ all TxLink records (link_count)
Computed by the Transactor before pushing any record to the WAL queue. Corrupt or missing links detected at WAL replay time.
Deduplication
Flip-flop HashMap in Transactor
#![allow(unused)]
fn main() {
struct DedupCache {
active: HashMap<u64, u64>, // user_ref → tx_id, current window
previous: HashMap<u64, u64>, // user_ref → tx_id, previous window
window_ms: u64, // deduplication window in milliseconds
window_start: u64, // timestamp when active window started
}
}
Flip logic — checked on every transaction:
now = current_time_ms()
if now - window_start >= window_ms:
swap(active, previous) // previous becomes active's old data
active.clear() // reuse allocation, zero re-alloc
window_start = now
Lookup:
if user_ref == 0 → skip check (no idempotency key provided)
check active[user_ref] → found → DUPLICATE
check previous[user_ref] → found → DUPLICATE
not found → proceed, insert into active after commit
found → proceed, insert into active after commit (rejected transactions also kept in wal)
Effective window: window_ms to 2 × window_ms.
An entry inserted at the start of a window lives until the end of the next window.
An entry inserted at the end lives for at least window_ms. A 10-second window
gives 10–20 seconds of deduplication coverage — sufficient for all retry scenarios.
Memory:
At 100K submissions per 10-second window:
active: 100K entries × ~50 bytes = ~5MB
previous: 100K entries × ~50 bytes = ~5MB
Total: ~10MB — bounded by submission rate, not pipeline throughput
clear() reuses the HashMap allocation. No GC pressure on flip.
On crash recovery:
Rebuild from WAL replay. For each committed TxMetadata with user_ref != 0
and timestamp within last 2 × window_ms, insert into active or previous
based on timestamp. Deduplication window fully restored from WAL after recovery.
New FailReason
#![allow(unused)]
fn main() {
pub const DUPLICATE: Self = Self(7);
// user_ref already committed within deduplication window
}
LedgerConfig additions
#![allow(unused)]
fn main() {
pub struct LedgerConfig {
// existing fields...
pub dedup_enabled: bool, // default: true
pub dedup_window_ms: u64, // default: 10_000 (10 seconds)
}
}
dedup_enabled: false disables the cache entirely — zero overhead, no check.
Useful for internal pipelines where user_ref is not used.
Duplicate Transaction Flow
Transactor detects duplicate user_ref within the window:
TxMetadata {
tx_id: new_tx_id,
entry_count: 0, // no balance change
link_count: 1,
fail_reason: DUPLICATE,
user_ref: duplicate_user_ref,
}
TxLink {
kind: Duplicate,
to_tx_id: original_tx_id,
}
What the caller sees:
submit(op, user_ref=12345) → tx_id = 441100
GetTransactionStatus(441100) → ERROR, fail_reason=DUPLICATE
GetTransaction(441100) → link: Duplicate → to_tx_id=441001
GetTransaction(441001) → original committed transaction
The caller always recovers the original tx_id regardless of how many retries
were attempted. The duplicate record in the WAL provides a permanent audit trail
of the retry attempt.
Reversal Flow (deferred)
Not yet implemented. The
TxLinkKind::Reversalvalue (1) is reserved in the WAL format so that future reversal records are forward-compatible with WAL files written today. TheOperation::Reversalvariant, Transactor reversal index, and gRPCReversalmessage will be added in a follow-up.
gRPC API (amends ADR-004)
TxLink records included in GetTransactionResponse:
message GetTransactionResponse {
uint64 tx_id = 1;
uint64 user_ref = 2;
uint64 timestamp = 3;
uint32 fail_reason = 4;
repeated TxEntryRecord entries = 5;
repeated TxLinkRecord links = 6;
}
message TxLinkRecord {
uint64 to_tx_id = 1;
LinkKind kind = 2;
}
enum LinkKind {
DUPLICATE = 0;
REVERSAL = 1;
}
CLI Impact (amends ADR-007)
roda-ctl unpack emits TxLink records as JSON Lines:
{"type":"TxMetadata","tx_id":441099,"entry_count":2,"link_count":1,...}
{"type":"TxEntry","tx_id":441099,"account_id":1,"amount":100,"kind":"Credit",...}
{"type":"TxEntry","tx_id":441099,"account_id":0,"amount":100,"kind":"Debit",...}
{"type":"TxLink","tx_id":441099,"kind":"Reversal","to_tx_id":441001}
roda-ctl verify validates:
link_countin TxMetadata matches actual TxLink records read
Consequences
Positive
- Transaction relationships are first-class WAL records — auditable, covered by CRC
- Duplicate detection protects against network retry and crash recovery
resubmission — exactly-once semantics achievable with
user_ref+ retry - Caller always recovers original
tx_idfrom a duplicate viaTxLink - Deduplication cache rebuilt from WAL on crash recovery — window survives crash
- Reversal audit trail is explicit in the WAL — no external cross-reference needed (deferred)
- Flip-flop HashMap — exact match, no false positives, bounded memory, O(1) check
link_count = 0is the common case — zero overhead for normal transactionsTxLinkKind128–255 available for user-defined link kinds
Negative
link_countadded toTxMetadata— breaking format change (alongside ADR-006 changes, one migration covers both)- New
WalEntryKind::Link = 4— all WAL readers and replay paths must handle it Operation::Reversaldeferred — requires Transactor reversal index (future work)- Deduplication cache lost on crash — rebuilt from WAL replay (startup cost)
dedup_window_msanddedup_enabledadded toLedgerConfig
Neutral
TxLinkis 40 bytes — consistent with all other WAL record typesuser_ref = 0skips deduplication check — no overhead for operations without key- Deduplication memory bounded by submission rate, not pipeline throughput (~10MB)
Alternatives Considered
Store to_tx_id as a field in TxMetadata Rejected — only one link per transaction, not extensible. Separate TxLink records allow multiple links and are consistent with the entry model.
Circular buffer for deduplication cache Rejected — hash collisions cause false positives (legitimate transactions rejected). Flip-flop HashMap gives exact match with no false positives at comparable memory cost.
Enforce reversal entries match original Rejected — the system now handles reversal entries generation automatically, ensuring they always correctly mirror the original transaction.
Separate deduplication ADR Rejected — deduplication, TxLink, and reversal are coupled: deduplication emits a TxLink on rejection, reversal is expressed as a TxLink. A single ADR covering all three is more coherent than three separate ADRs with circular references.
Links on simple operations Rejected — Deposit, Withdrawal, and Transfer are standalone by design. Links are only meaningful on Complex operations where the caller controls the full transaction structure.
References
- ADR-001 — TxMetadata, TxEntry, WalEntry, CRC coverage (amended by this ADR)
- ADR-003 — Operation enum, ComplexOperation, FailReason (amended by this ADR)
- ADR-004 — gRPC interface (amended by this ADR)
- ADR-006 — WAL segment lifecycle, Seal thread, storage layout
- ADR-007 — CLI tools (amended by this ADR)
- ADR-008 — Transaction index, GetTransaction response
src/entities.rs— TxLink, TxLinkKind, updated WalEntry, updated TxMetadatasrc/dedup.rs— DedupCache (flip-flop HashMap)src/transactor.rs— duplicate detection, DedupCache integration