Part 04 — Offline Event Log Architecture
Reusable P2P Communication Platform
Status: Architecture specification
Part: 04 of 24
Primary language: Rust
Primary goals: durable offline-first state, crash recovery, replayable history, deterministic projections, synchronization, idempotency, auditability, reuse across messaging/files/identity/DTN/emergency/custom products
1. Purpose
A resilient communication platform must keep working when Internet access, peers, radios, or even the process itself disappear.
The offline event log is the durable local backbone that lets the system accept work first, persist it, and perform remote side effects later.
Core rule:
Persist accepted intent before depending on the network.
The event log supports:
- messaging outbox
- delivery state
- file-transfer lifecycle
- multi-device identity changes
- group membership
- emergency reports
- DTN bundle lifecycle
- synchronization
- future product-specific domains
It must remain reusable and independent of Dioxus, Android, Kotlin, Iroh, or any one database.
2. Do Not Event-Source Everything
Use an event log where history and recovery matter:
messages
device lifecycle
group membership
transfer lifecycle
emergency state
DTN state
replication state
security-sensitive transitions
Use ordinary projections/tables for:
chat-list summaries
search indexes
cached counters
temporary routing metrics
ephemeral presence
Recommended model:
Durable Event Journal
↓
Projections / Materialized Views
↓
UI / Query APIs / Work Queues
3. Command vs Event
A command asks for change:
SendMessage
StartFileTransfer
RevokeDevice
MarkRead
CreateSOS
An event records an accepted transition:
MessageQueued
TransferCreated
DeviceRevoked
ReadAdvanced
EmergencyReportCreated
Commands may fail.
Committed events are immutable historical facts.
4. Core Event Envelope
#![allow(unused)] fn main() { pub struct EventEnvelope { pub event_id: EventId, pub stream_id: StreamId, pub stream_version: u64, pub event_type: EventTypeId, pub schema_version: u16, pub created_at: Timestamp, pub origin: EventOrigin, pub correlation_id: Option<CorrelationId>, pub causation_id: Option<EventId>, pub payload: Bytes, } }
Use strong IDs:
#![allow(unused)] fn main() { pub struct EventId([u8; 16]); pub struct StreamId([u8; 32]); pub struct CorrelationId([u8; 16]); pub struct EventTypeId(u32); }
5. Streams
Events belong to logical streams:
conversation/<id>
transfer/<id>
account/<id>
device/<id>
group/<id>
dtn/<id>
emergency/<id>
Each stream has its own monotonic version:
1
2
3
...
This gives deterministic local ordering without depending on wall-clock time.
6. Local Global Offset
Also maintain a device-local append offset:
#![allow(unused)] fn main() { pub struct LocalLogOffset(u64); }
This is useful for:
- projections
- incremental backup
- local replay
- checkpointing
It is never global truth across devices.
7. Event Origin
#![allow(unused)] fn main() { pub enum EventOrigin { LocalDevice(DeviceId), RemoteDevice(DeviceId), Imported, Recovery, System, } }
Remote-origin events are trusted only after identity, authorization, and protocol validation.
8. Correlation and Causation
Correlation ties one workflow together:
MessageCreated
MessageQueued
MessageSent
MessageDelivered
Causation records which event led to which event.
This improves:
- diagnostics
- recovery analysis
- audit trails
- testability
9. Versioned Event Schemas
Never permanently serialize current domain structs.
Use explicit schemas:
MessageQueuedV1
TransferCreatedV1
DeviceRevokedV1
If semantics change:
V1 → V2
or upcast old data deterministically.
Do not silently reinterpret old bytes using changed Rust structs.
10. Append-Only Semantics
Committed events should not be modified in ordinary operation.
Correction occurs through new events:
MessageCreated
MessageEdited
not by rewriting the old record.
Storage compaction and legal/privacy deletion are separate retention operations.
11. Atomic Append
A durable append should be transactional:
BEGIN
verify expected stream version
insert event
update stream head
assign local offset
COMMIT
Failure means no partial logical event.
12. Optimistic Concurrency
Support:
#![allow(unused)] fn main() { append(stream, expected_version, events) }
If current version differs:
ConcurrencyConflict
This protects local concurrent writers.
13. Local-First Command Flow
Correct:
User action
↓
validate
↓
append event
↓ COMMIT
update projection
↓
show UI
↓
network effect later
Wrong:
try network
↓
persist only after success
14. Transactional Outbox
Recommended hybrid:
event log = semantic history
outbox table = efficient pending-work queue
One transaction may commit:
MessageQueued
OutboxOperation
message projection
conversation summary
If the process dies, the outbox still exists.
15. Event Log Is Not a Job Queue
Do not scan millions of old events to find work.
Use dedicated projections/work tables for:
outbox
retry schedule
transfer chunks
pending DTN forwards
The log preserves meaning. The work queue optimizes execution.
16. Projection Architecture
Event Journal
↓
Projection Runner
↓
Materialized Views
Examples:
messages
conversation_summary
transfer_state
device_directory
group_state
dtn_bundle_state
emergency_state
Projections must be:
- deterministic
- idempotent
- rebuildable
- versioned
17. Projection Checkpoints
#![allow(unused)] fn main() { pub struct ProjectionCheckpoint { pub projection_id: ProjectionId, pub last_log_offset: LocalLogOffset, pub projection_version: u16, } }
After restart, a projector resumes from its checkpoint.
18. Read-Your-Writes
For core UX, accepted local operations should become queryable immediately.
Example:
SendMessage succeeds locally
→ conversation immediately shows message
This generally means critical projections update in the same transaction as the event.
19. Event Store Backend
Recommended initial backend:
SQLite
because it provides:
- ACID transactions
- WAL
- recovery
- indexes
- mature mobile/desktop support
- migration tooling
Keep the architecture backend-neutral.
20. Event Store Trait
#![allow(unused)] fn main() { pub trait EventStore: Send + Sync { async fn append( &self, request: AppendRequest, ) -> Result<AppendResult, EventStoreError>; async fn read_stream( &self, stream: StreamId, from_version: u64, limit: usize, ) -> Result<Vec<StoredEvent>, EventStoreError>; async fn read_log( &self, from_offset: LocalLogOffset, limit: usize, ) -> Result<Vec<StoredEvent>, EventStoreError>; } }
21. Batch Append
Support multiple events in one transaction:
MessageCreated
MessageQueued
AttachmentReferenced
This is both faster and more consistent.
22. Integrity
Optional integrity mechanisms:
payload checksum
record checksum
per-stream hash chain
Security-critical domains such as device identity may already use signed/hash-chained state from Part 02.
Do not sign every trivial local event unless there is a reason.
23. Remote Event Ingestion
Safe flow:
receive
↓
protocol validation
↓
identity verification
↓
authorization
↓
deduplication
↓
domain validation
↓
append
↓
projection
↓
durable ACK
For durable delivery semantics, ACK only after persistence.
24. Idempotency
Remote events may arrive repeatedly.
Require stable:
EventId
and enforce uniqueness in storage.
Duplicate valid input should normally become an idempotent no-op.
25. Out-of-Order Events
Remote events can arrive out of order.
Possible handling:
hold unresolved
request missing predecessor
apply only after dependency satisfied
Do not silently discard valid future events.
26. Gap Detection
If a stream expects:
version 44
but receives:
46
mark the stream incomplete and request reconciliation.
27. Logical Clocks
Wall clocks are useful for display, not universal ordering.
Use:
stream version
generation
domain sequence
logical clock
where necessary.
A hybrid logical clock can be added later if cross-device approximate chronology becomes valuable.
28. Offline IDs
IDs must be generated locally without server coordination.
Requirements:
- collision resistant
- offline
- portable
- stable across retries
A time-sortable 128-bit identifier can improve database locality, but correctness must not depend on accurate wall time.
29. Pure Decision Functions
Where practical:
#![allow(unused)] fn main() { fn decide( state: &State, command: Command, ) -> Result<Vec<DomainEvent>, DomainError> }
This makes domain logic easy to test.
Effects remain separate.
30. Effect Processing
Events may schedule external work:
MessageQueued
→ send effect
TransferCreated
→ transfer effect
EmergencyReportCreated
→ routing/DTN effect
Effects are asynchronous, retryable, and idempotent.
31. Exactly-Once Is Not the Goal
Across distributed systems, use:
at-least-once attempts
+
stable IDs
+
receiver deduplication
=
effectively-once logical outcome
Do not claim true exactly-once network delivery.
32. Retry Event Granularity
Do not append one durable event per tiny transport retry.
Permanent history should record meaningful transitions:
MessageDeferred
RouteChanged
TransferPaused
TransferFailed
Low-level attempts belong in tracing/metrics.
33. Messaging Events
Typical semantic events:
MessageCreated
MessageQueued
MessageReceived
MessageDelivered
MessageRead
MessageEdited
MessageDeleted
ReactionAdded
ReactionRemoved
34. File Events
Typical:
TransferCreated
TransferAccepted
TransferStarted
TransferPaused
TransferResumed
TransferCompleted
TransferCancelled
TransferFailed
BlobVerified
Chunk completion should usually live in a resumable transfer journal/projection, not as millions of permanent events.
35. Identity Events
Part 02 naturally maps to durable events:
DeviceAdded
DeviceRevoked
DeviceSuspended
RootRotated
RecoveryUsed
These may require signatures and stronger retention.
36. DTN Events
Possible:
BundleCreated
BundleStored
BundleForwarded
DestinationReached
BundleExpired
BundleEvicted
Do not permanently log every proximity encounter.
37. Emergency Events
Examples:
EmergencyReportCreated
EmergencyReportUpdated
SOSCancelled
AuthorityAlertReceived
EmergencyDeliveryConfirmed
Emergency events may have stronger audit and retention policy.
38. Snapshotting
Large streams can use snapshots:
events 1..10000
↓
snapshot @ 10000
↓
replay 10001+
Snapshot is derived state, not authoritative history.
39. Snapshot Structure
#![allow(unused)] fn main() { pub struct Snapshot { pub stream_id: StreamId, pub stream_version: u64, pub projection_version: u16, pub state: Bytes, pub checksum: Hash, } }
If invalid:
discard
rebuild from log
40. Compaction
Retention is domain-specific.
Possible classes:
#![allow(unused)] fn main() { pub enum RetentionClass { Permanent, SecurityAudit, UserControlled, OperationalShortTerm, Ephemeral, } }
Examples:
device revocation → long retention
completed transfer operational noise → compactable
typing status → never journal
41. Privacy and Deletion
Append-only design must not become an excuse to ignore deletion requirements.
Options include:
logical deletion event
projection deletion
physical retention policy
crypto-erasure for sensitive retained payloads
Exact behavior depends on product/legal requirements.
42. Event Encryption
Local database encryption is recommended.
Particularly sensitive event payloads may also be application-encrypted.
Keep cryptographic key management outside event-store internals.
43. Blob References
Large binary data never belongs directly in the journal.
Events should reference:
BlobId
ContentHash
Part 05 will define the blob subsystem.
44. Search as Projection
Full-text search is a rebuildable projection:
MessageCreated
MessageEdited
MessageDeleted
↓
FTS index
If search corrupts, rebuild it.
45. Replay Must Not Re-run Side Effects
Projection replay:
must not resend messages
must not restart transfers
must not rebroadcast SOS
Effect execution and projection replay are separate.
46. Replay Modes
#![allow(unused)] fn main() { pub enum ReplayMode { ProjectionOnly, Recovery, Live, } }
Only recovery/live mode may schedule external work according to durable pending state.
47. Startup Recovery
Recommended:
open DB
↓
verify migrations
↓
resume projections
↓
reconcile work queues
↓
load local read models
↓
render UI
↓
start networking
No remote connection is required before local state becomes usable.
48. Work Queue Reconciliation
If an event indicates a pending operation but the optimized work table is missing/corrupt:
recovery reconciler
can reconstruct it.
This is a key advantage of retaining semantic history.
49. Replication Scope
Not every local event should leave the device.
#![allow(unused)] fn main() { pub enum ReplicationScope { LocalOnly, OwnDevices, ConversationPeers, GroupMembers, ExplicitRecipients, PublicSigned, } }
Examples:
local diagnostic → LocalOnly
read state → OwnDevices/ConversationPeers
device update → OwnDevices + authorized peers
SOS → Explicit/PublicSigned depending mode
50. Own-Device Sync
Suitable event classes:
messages
read state
device state
group state
selected settings
Draft sync should remain optional.
51. Peer and Group Sync
Remote replication must check:
authorization
membership
epoch/version
history visibility policy
before sending or appending events.
52. Local Storage Envelope vs Network Envelope
Do not require:
StoredEvent == WireEvent
Recommended:
LocalStoredEvent
↓ transform
ReplicationEventV1
↓
E2EE / protocol
This keeps storage evolution independent from wire compatibility.
53. Sync Cursors
Where stream ordering supports it:
#![allow(unused)] fn main() { pub struct SyncCursor { pub peer: DeviceId, pub stream: StreamId, pub acknowledged_version: u64, } }
For more complex sparse state, later add Merkle/set reconciliation.
54. Conflicts Are Domain-Specific
Do not build one generic conflict resolver.
Examples:
two new messages → both valid
two device revocations → merge conservatively
two profile edits → explicit rule
group membership → authenticated state machine
The event store records facts; the domain resolves meaning.
55. Event Size Limits
Every event type must have a maximum size.
Do not allow:
huge file
huge recursive object
unbounded metadata
inside a journal event.
56. Durability Classes
#![allow(unused)] fn main() { pub enum DurabilityClass { Critical, Durable, BestEffort, } }
Examples:
DeviceRevoked → Critical
MessageQueued → Durable
typing → not journaled / BestEffort
Durability policy must balance safety with mobile battery/performance.
57. SQL Schema
Conceptual SQLite schema:
events(
local_offset INTEGER PRIMARY KEY,
event_id BLOB UNIQUE NOT NULL,
stream_id BLOB NOT NULL,
stream_version INTEGER NOT NULL,
event_type INTEGER NOT NULL,
schema_version INTEGER NOT NULL,
created_at INTEGER NOT NULL,
origin BLOB,
correlation_id BLOB,
causation_id BLOB,
payload BLOB NOT NULL,
checksum BLOB,
UNIQUE(stream_id, stream_version)
)
Supporting tables:
stream_heads
projection_checkpoints
snapshots
outbox
58. Indexes
Minimum:
event_id
(stream_id, stream_version)
local_offset
Avoid indexing everything by default.
59. Memory Discipline
Replay in bounded batches:
read batch
↓
apply
↓
discard
↓
next batch
Never read years of history into memory.
60. Projection Isolation
One broken secondary projection must not stop durable appends.
Example:
search index projector fails
Messaging still works.
Projection catches up later from checkpoint.
61. Internal Event Notifications
After commit:
wake projectors/effect workers
But durability never depends on the in-memory notification.
If the signal is missed, checkpointed readers catch up later.
62. Unknown Events
Unknown event handling must be explicit.
Possible:
optional unknown → store/ignore safely
required semantic unknown → block stream until upgrade
Never deserialize into arbitrary current types.
63. Namespaced Custom Events
Third-party products may register:
com.example.erp.approval_created
through a stable namespace/event registry.
This lets the event log remain reusable.
64. Multi-Tenant Isolation
For multi-tenant consumers:
TenantId
must participate in storage/stream namespace.
One tenant must never see another tenant's streams or projections.
65. Multiple Identities
Personal/work identities on one device require isolated:
stream namespace
projections
replication policy
outbox
Do not accidentally share event state.
66. Security
Protect against:
malformed imported event
duplicate/replay
rollback
oversized payload
unauthorized remote event
projection poisoning
local corruption
Security-critical event domains may use signatures/hash chains.
67. Event Store Errors
#![allow(unused)] fn main() { pub enum EventStoreError { ConcurrencyConflict, DuplicateEvent, StorageFull, Corrupt, ReadOnly, MigrationRequired, Io, Serialization, } }
Keep domain errors separate.
68. Storage Full Behavior
If storage is full:
do not report "queued"
unless the durable append succeeded.
The UI should expose a recoverable storage error.
69. Read-Only Recovery Mode
If the database is damaged:
read-only mode
may allow:
- viewing
- export
- diagnostics
without risking additional corruption.
70. Backup
Incremental backup can use:
LocalLogOffset
to export new events since last backup.
Restore should:
verify
restore log
restore blobs
rebuild projections
reconcile pending work carefully
71. Restore Safety
Do not blindly re-run all previously pending effects after restore.
Check:
expiry
delivery state
revocation
already-completed operation IDs
first.
72. Analytics Separation
Never upload the raw event journal as analytics.
Analytics must use a privacy-filtered projection.
73. Dioxus Boundary
Dioxus sends:
ApplicationCommand
and reads:
ViewModel / projection
It never writes event records directly.
74. Kotlin / iOS Boundary
Platform code may report:
NetworkChanged
AppBackgrounded
LowMemory
Rust decides whether a durable domain event is required.
Do not duplicate event semantics in Kotlin or Swift.
75. Daemon Compatibility
Part 16 may move event ownership into a daemon.
Then:
daemon = sole writer
GUI/CLI = command/query clients
Same event APIs should support in-process and daemon modes.
76. Headless Compatibility
A headless node can use the log for:
file transfers
DTN bundles
identity state
relay operations
without any UI.
77. Routing Integration
Part 03 routing consumes pending durable work.
Typical flow:
MessageQueued
↓
router selects path
↓
send attempt
↓
meaningful result event
Do not persist every RTT/path probe as semantic history.
78. File Integration
Part 05 should use:
semantic transfer events
+
high-frequency chunk journal/projection
not one permanent event per chunk.
79. DTN Integration
Part 06 should persist:
bundle creation
store
forward
delivery
expiry
while keeping peer-encounter telemetry mostly operational.
80. Emergency Integration
Emergency reports must be persisted before radio/network attempts.
Example:
SOS created
↓ COMMIT
device loses connectivity
↓
restart
↓
SOS still pending until delivered/expired/cancelled
81. Diagnostics
Expose:
latest local offset
event-store health
projection lag
pending effect count
last successful compaction
without exposing content.
82. Metrics
Useful local metrics:
append latency
commit latency
events/sec
projection lag
replay rate
event-store size
duplicate rate
projection failures
83. Property Tests
Important invariants:
duplicate event does not duplicate projection state
projection rebuild equals live projection
stream versions strictly increase
failed transaction creates no partial event
checkpoint never advances past committed projection
expired pending work is not resurrected
84. Crash Injection Tests
Inject process failure:
before append
inside transaction
after event before projection
after projection before network effect
after network effect before success marker
Then verify deterministic recovery.
85. Fuzzing
Fuzz:
event envelope decoder
event payload decoders
snapshot decoder
backup/import
event upcasters
All allocations must be bounded.
86. Golden Event Tests
Stable event schemas should have golden encodings.
Changing bytes for a stable schema should require explicit protocol/storage review.
87. Recovery Acceptance Test
MessageQueued committed
process killed
restart
projection restored
outbox reconstructed
route found
same MessageId sent
recipient deduplicates
MessageDelivered committed
This is a key production gate.
88. Multi-Device Offline Test
Phone offline
Laptop offline
Phone appends events
Laptop appends independent events
Later reconnect
↓
authorized events replicate
↓
domain conflict rules apply
↓
both converge
No central clock is required.
89. Suggested Crate Structure
crates/comm-event-log/
├── src/
│ ├── lib.rs
│ ├── event.rs
│ ├── envelope.rs
│ ├── stream.rs
│ ├── store.rs
│ ├── append.rs
│ ├── codec.rs
│ ├── registry.rs
│ ├── projection.rs
│ ├── snapshot.rs
│ ├── checkpoint.rs
│ ├── retention.rs
│ ├── replay.rs
│ ├── diagnostics.rs
│ └── error.rs
└── Cargo.toml
Optional backend crate:
comm-event-log-sqlite
90. Public API
Keep small:
EventStore
EventAppender
EventReader
ProjectionRunner
SnapshotStore
Most products should use domain services rather than appending arbitrary events directly.
91. Initial Production Scope
Implement first:
SQLite event store
stream versioning
global local offset
unique event IDs
batch append
critical projections
outbox integration
projection checkpoints
replay
basic snapshots
schema versioning
Defer initially:
Merkle replication
complex crypto-erasure
segment-file backend
full compliance mode
92. Implementation Phases
Phase 1
EventId
StreamId
EventEnvelope
LocalLogOffset
EventStore trait
Phase 2
SQLite backend
events
stream heads
transactions
indexes
Phase 3
messaging
files
identity integration
Phase 4
projections
checkpoints
rebuild
Phase 5
outbox
effects
retry
recovery
Phase 6
replication scopes
stream heads
sync hooks
Phase 7
snapshots
compaction
migration tests
crash injection
fuzzing
benchmarks
93. Definition of Done
Part 04 is complete when:
- accepted local commands survive process death
- operations can be accepted without Internet
- IDs require no central server
- stream versions provide deterministic ordering
- duplicate remote events are idempotent
- projections rebuild deterministically
- checkpoints recover after crashes
- UI reads optimized projections
- large data is referenced via blobs
- message outbox survives restart
- file semantic state survives restart
- device lifecycle remains auditable
- SOS is persisted before transmission
- DTN lifecycle is durable
- replication scope is explicit
- event schemas are versioned
- replay never accidentally re-runs external effects
- storage-full is handled safely
- no external side effect occurs before durable commit
- crash/property/fuzz tests exist
- the subsystem works outside the messenger
94. Relationship to Other Parts
Part 04 builds on:
01 — Protocol Extension System
02 — Multi-Device Identity
03 — Transport & Routing Policy Engine
It directly supports:
05 — Robust File / Blob Subsystem
06 — DTN / Store-Carry-Forward
07 — Capability Negotiation Expansion
08 — Resource Limits & Backpressure
09 — Crash Recovery
10 — Protocol Fuzzing & Test Suite
13 — Battery-Aware Scheduling
16 — Daemon & Headless Runtime
17 — Emergency Priority Architecture
18 — Network Diagnostics & Path Visualization
20 — Embedded Linux Node
23 — External Interoperability Suite
95. Final Principle
The offline event log should make this guarantee true:
If the application tells the user that a durable operation has been accepted locally, that intent survives network loss and process termination.
The architecture is:
User action
↓
validate
↓
append durable event
↓ COMMIT
update projection
↓
show local result
↓
perform network/file/DTN effects asynchronously
This is the foundation that turns the platform into a genuinely local-first, crash-recoverable, offline-capable communication system rather than an online application that merely caches some state.