Skip to content

Phase 1: events, storage, crash safety, and the identity store - #156

Merged
dezren39 merged 37 commits into
mainfrom
phase-1
Oct 12, 2026
Merged

dezren39 merged 37 commits into
mainfrom
phase-1

Conversation

@dezren39

@dezren39 dezren39 commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Phase 1: events, storage, crash safety, and the identity store

Record: doc/…_phase_1_events_storage/ (steps, commits, review, open items). Start commit 4361a351.

  • ChainExt (signed p2panda header extension, validation, golden and dcbor vectors).
  • Event store on redb: first-creation by temp file, commit, sync, rename, sync; ingest with verification, freeze, waiting, parking; fork voids only its own chain.
  • Waiting events persisted in redb (format v3), with first-arrival clock and caps (256 per author, 7 days).
  • Crash-safety gate: in-process fault model on redb, sampled in tests, full sweep via just crash-gate; the identity store has its own gate.
  • Identity store moved from libsql to redb with AES-256-GCM values; libsql and 75 packages removed.
  • p2panda-store traits over the redb store; sqlx absent. LogSync session deferred to P3.2 (its sqlite default needs a fork or feature change).
  • conform config allows the id scope.

Verified: cargo test --all-features --no-fail-fast exits 0 on the head commit. Not verified: the Nix build (the p2panda git dependency may need outputHashes).

Summary by CodeRabbit

  • New Features

    • Added event storage with signed event validation, fork tracking, dependency-aware delivery, and persisted events waiting for missing dependencies.
    • Identity records are now stored in an encrypted database, with authentication checks when reopening the store.
    • Added limits of 256 waiting events per author and a seven-day retention period.
  • Reliability

    • Added crash-recovery checks for identity and event storage, including recovery after interrupted writes.
  • Documentation

    • Documented the new storage behavior, crash checks, and regression-test formats.

The web identity store moves from libsql to redb. It has one table, keyed
by client ID. Each value is a fresh 12-byte nonce followed by the
AES-256-GCM ciphertext of the JSON record, with the client ID as the
associated data. The key is the same HKDF output the libsql encryption key
came from.

- Cargo.toml: libsql 0.9 (encryption) leaves the web feature. redb 4.3 and
  aes-gcm 0.11 join it; both were already in the lock.
- Cargo.lock: 762 to 686 packages, none added or upgraded.
- The file is now .identity.redb. An old .identity.db is left in place and
  not read. The data may be discarded, so there is no migration.
- Writes run on the blocking pool. update_name persists under the write
  lock, so the file keeps the order of in-memory updates.
- Tests: round trip; the name is not visible in the stored value; every
  bit flip, truncation and moved value is refused; a wrong key and a
  tampered file fail the open; records survive a reopen; a second process
  is refused with a clear error; nonces never repeat over random writes.
A waiting pool holds events until their dependencies arrive. Two caps
bound what a peer can make a node hold: 256 waiting events per author
and a 7-day age cap. Both are set per world through WaitingLimits.

- An event whose dependencies have all arrived is not held. It comes
  back from take_ready and takes no slot under the cap.
- Expiry is inclusive: an event is dropped once it has waited max_age.
  expire returns the dropped events so the caller can log them.
- arrived releases events in insert order and never releases an
  expired event. An arrival for a key that is itself held drops the
  held copy without releasing it.

The pool does no I/O and takes an explicit clock. It is not connected
to storage yet: P1.3 supplies the arrivals. Until then it keeps its own
set of arrived keys, which grows with arrivals.
The event header is a p2panda-core Header whose extension is ChainExt:
kind, group, after, and the domain tag id.events.v1 as a fourth key, so
the tag is signed with the rest of the header.

sign() validates the extension, then builds and signs the header through
p2panda's builder. decode() checks canonical CBOR over the whole header,
verifies the signature through AnyHeader::decode (verify_strict), and
re-encodes the extension, which refuses a key that serde would default.
The typed Header::verify is not used. The operation ID is BLAKE3 over the
received bytes.

Dependencies: p2panda-core (git, rev 3e84cac), cbor-core =0.10.1 with the
serde feature, dcbor =0.25.2 (dev only), and unicode-normalization 0.1 for
the NFC rule on kind (already in the lock, no new crate).
The S5 golden header (Header<()>, plain body map, seq 0, the test key)
keeps its operation ID 90acd5f7... and signature be31be24.... That header
has no extension, so it pins p2panda's layout and signature rule, not
ChainExt. The four other S5 canonical vectors are pinned as recorded.

Two pinned ChainExt events are checked against dcbor, which builds each
header and its signed bytes with its own encoder: one plain, one with a
body, a backlink, and a group. The bytes must match, and each signature
must verify over dcbor's signed bytes.
redb 4.3.0 was already in the lock: iroh-blobs and iroh-docs use it. The id crate now
depends on it directly, for the crash-safety tests (P1.4) and the event store (P1.3).
iroh-docs also keeps redb 3.1.3, which is unchanged.
A redb StorageBackend that loses unsynced writes at a simulated power cut, applies one of
four survival policies to them, and tears the write in flight. The workload is 50 redb
write transactions, with a model for every prefix. Every storage call is a crash point:
687 calls, 7,808 runs per sweep (12 per write, 4 per set_len, 5 per sync).

- cargo test --test crash: every 20th crash point (389 runs), the lying-sync control
  (which must fail), and the corpus replay. The fault semantics have unit tests.
- just crash-gate (release): every crash point, and the full lying-sync control.
  crash-gate is also a nix check and a nix run app.
- tests/crash/corpus.tsv: 195 crash points with their expected verdicts. Its header pins
  the storage-call count and an FNV-1a hash of the call trace, so a changed workload fails
  the replay before any verdict is compared.
…and it [P1.10]

Creation wrote the store file at its final name. A crash during the first creation could
leave a file that redb refuses to open ("Invalid magic number"), and the store could not
start again.

Creation now builds the file under <name>.creating, makes the first commit, renames it into
place, and syncs the directory before the store is returned. A stale .creating file from an
interrupted creation is overwritten. A torn file at the store's own name is still refused,
with an error.
The identity store's own redb operations (creation, create, update) run on the in-process
fault model, through a doc-hidden crash_gate seam in web::identity. A power cut leaves a disk
image, which is written where the store would have left it and reopened through the production
open. Each run checks the reopened records against the model (acked, or acked plus one), an
integrity check, and a commit after recovery that survives another reopen.

Client IDs are fixed, so the storage-call trace is deterministic and the corpus
(identity_corpus.tsv, 382 rows) can name crash points by call number.

First creation has its own tests. Every crash point of creation must reopen to an empty store,
both states of the rename must open, and an in-place creation control must fail, so the gate
sees the failure that the temporary name removes.

The sampled sweep, control and corpus replay run in cargo test under the web feature. just
crash-gate runs the full sweeps in release, and now builds with --features web.
The committed lock lists p2panda-core's dependency rand 0.8.8, but has no entry for that
package, and it has no rand_chacha 0.3.1 either. Every build rewrote the file, and
`cargo metadata --locked` refused the committed lock. This adds the two package entries and
normalizes the dependency references that depend on them. Cargo.toml is unchanged, so no crate
is added to the manifest.
The event store keeps verified events, the applied state of each author's
chain, and fork proofs in one redb file. Tables: events by (author, seq),
an ID index, chain rows (next seq, head, frozen), fork proofs per author,
a group index (multimap), and the format row. Position keys are author
then big-endian seq, so one author's events sort in sequence order.

Creation is crash-safe (N45): the file is built under events.redb.tmp,
its first commit and sync are made, the directory is synced, the file is
renamed into place, and the directory is synced again. A stale temp file
is removed, never trusted. The in-place sequence leaves a partial file
under the final name after a crash, which redb refuses to open.

Storage is a trait (Storage), so the crash model can drive the store over
a simulated directory. FileStorage is the disk. A second opener is InUse,
and a file that is not a store is NotAStore and is left alone.

tests/crash/store.rs sweeps every storage call of creation (under five
survival policies, with and without completed syncs) and every directory
operation, then reopens every crash state. The fixed creation passes
(766 states, 0 failures). The in-place control fails (300 of 601), so the
sweep does see a torn first commit.
EventStore::ingest takes received bytes and decides them in one write
transaction. Verification is chain::decode alone: AnyHeader::decode checks
the canonical encoding and the signature (verify_strict), and ChainExt
checks its own rules. Anything else is refused as Malformed.

Outcomes, in order: a stored ID, or a fork proof header, is a Duplicate.
A different event at a stored sequence number is a Fork: the chain freezes
at the lower of the two sequence numbers and both headers are stored as
the proof. A frozen author's events from the fork point on are refused as
Frozen. The next sequence number, with a backlink equal to the applied head
and every after hash applied, is Accepted. An event ahead of its chain, or
with an after hash not applied, is Waiting in the waiting pool (caps from
waiting.rs). A refused event changes nothing.

A fork unapplies the events from its fork point on. They stay stored, as
task 02 says, but they no longer apply, so the waiting pool forgets them
(WaitingPool::forget, the one addition to waiting.rs). Events that wait on
them wait again, so the applied state does not depend on arrival order.
Released events are decided again in order, and their acceptances are
reported in Accepted.released.

Tests: 16 unit tests, one per outcome and refusal and their combinations,
and a reopen. The property test delivers random chains with forks, in
three orders with repeats, to in-memory stores, and checks each reaches the
state the chains determine (512 cases in debug: 0 failures). The regression
list gets 11 seeds, replayed through the same checker. The crash model
drives creation and a seven-delivery workload, and checks that every
acknowledged delivery survives the reopen (sampled: 58 states, 0 failures).
# Conflicts:
#	pkgs/id/tests/crash/main.rs
The store traits need p2panda-store. Default features enable sqlite, which pulls sqlx, so they are off. The lock change is additive: async-stream and async-stream-impl, and p2panda-store's edge from the id crate. p2panda-sync is not added: its own p2panda-store dependency line enables the default features, so sqlite would return (explainers section 14).
SharedEventStore wraps the event store in a shared read/write lock, so it can be cloned and implements p2panda-store's LogStore and TopicStore. The log is one author's chain (ChainLog, decision N4), and the topic is a group hash. Heights, sizes, entries and the latest entry read the applied prefix, so they stop at a fork point. Methods the sync path does not use return Unsupported with a reason. Streams read a page at a time under the read lock. The new EventStore::applied_events accessor is a read-only range scan. Tests cover an empty store, several ingests, a fork, paging past one page, topic resolution, the refusals, clones, and a poisoned lock. The LogSync session test is not in this commit: p2panda-sync would bring SQLite back.
Adds the cases that fail on the current ingest. A fork does not unapply the
events applied on top of the forked event. A conflict is missed when its
backlink is not the applied head, or when the event it conflicts with is held
on a missing dependency or waits ahead of the chain. The two conflicting
events at one position can also yield different proofs by arrival order.

The property generator now makes conflicts with any backlink, forks at any
depth, and after hashes on any event, and checks every arrival order against
an order-free model of the applied state. The replay adds the minimized cases.
An ingest that spins on an event now fails its check after 20 seconds, instead
of hanging the run.

Red on this commit: the new unit tests, the property test, and six replay cases.
…P1.8]

A fork moves the applied prefix of its author's chain down to the fork point,
and now also unapplies every applied event that rests on an unapplied one. A
new dependents index (format v2) records which stored events name each hash in
their `after`, so the cascade costs what it unapplies. Voided events stay
stored; the chain's applied prefix marks them.

A conflict is found whatever its backlink or dependencies, from stored and
held events. Two events at one position give the proof the two lowest IDs
there, so the proof does not depend on arrival order.

An event whose backlink can never be the applied head is parked, not refused,
so that a conflict with it is still seen. An event ahead of its chain whose
backlink is applied elsewhere is parked too: the drain used to release it
again and again without end.

The crash model is unchanged: each decision still writes in one transaction.
Waiting events live only in memory, so a restart drops them. A fork
against a waiting event then goes unnoticed, and a dependency that
arrives after the restart does not release the event. These two tests
fail until the events are persisted.

The third test fails for another reason: held_rivals reads the pool
before the pool expires anything, so an event past its age cap still
counts as a rival. The store must expire first, or its answer depends
on when the next pool call happens to run, and a restart changes it.
Waiting and parked events are rows in a `waiting` table, with a
`waiting_by_dep` index from each dependency. The pool in memory mirrors
them. Each change to the pool and its rows runs in one write
transaction, and each ingest call is one transaction, so a crash keeps
the whole call or none of it. An event is never both applied and
waiting.

On open the pool is rebuilt from the rows. Rows due at the clock given
to open are dropped, and so are rows past an author's cap, counted in
arrival order. A failed write rebuilds the pool. If that fails too, the
store refuses calls until it is reopened.

Expiry now runs before a call looks for rivals. An event past its age
still counted as a rival until the pool happened to run, so a restart
could change the answer. The red tests are in 51a9775.

Format v3. A v2 store is refused, since there is no migration. open and
open_with take the caps and the clock, with_waiting_limits is removed,
and expire_waiting returns a Result.

Tests: restarts for hold, release, expiry, refusal by cap, and parking;
the release order of waiters kept across a restart; the crash workload
now holds and parks events, and after every crash point each
acknowledged waiting event must be held or stored; a property over random
arrival orders with restarts; the cap limit pinned as a known limit.
…t [P1-review]

The builder refuses a backlink at sequence 0 and a missing one above 0. Both
sides of the rule are now checked over every sequence in a range: the decoder
accepts exactly the headers the builder would sign, and ingest refuses the
rest as malformed, holding nothing. The layout of p2panda's decoder already
enforces the rule, so these tests pass on phase-1; they pin it.
… [P1-review]

LogSync is not wired in: p2panda-sync reaches SQLite through the default
features of p2panda-store. The module docs now say so, with the P3.2 item that
removes the default, and a test fails when the lock names SQLite or
p2panda-sync, so that change is made on purpose.
…ly [P1.8]

A fork voided every event that rests on a voided one, so an honest event of another author that named a voided event waited on its own backlink forever. Phase 1 has no authority model to void by, so the rule is: a fork voids only the forked author's own events from the fork point on, and an after hash counts as present when it is applied or a proof header. Authority-based voiding replaces this in P2.3.

The three tests that pinned the cascade now pin the new rule and fail until the fix lands. The property tests restrict after hashes to events that are present in every order, and add an honest-author liveness property.
…y [P1.8]

A fork voided every applied event that rested on a voided one, across all authors. Phase 1 has no authority model, so this voided honest events of other authors, whose next events then waited on their own backlink forever.

A fork now voids only the forked author's own events from the fork point on. An after hash is present when the store holds the event it names, applied or voided, or when it is a fork proof header. Presence never goes away, so the waiting pool's arrived set is monotone and forget is gone. Authority-based voiding replaces this rule in P2.3.

Schema: the dependents table is replaced by proof_ids, and the format is bumped from v3 to v4. A v3 store is refused, because its chain rows were written under the cascade rule.
…l time [P1.11]

A released event that is held again was given the time of its second hold, so the 7-day age cap counted from there. The cap counts the total time an event has waited, from its first arrival. This commit adds the red test for it.
The age cap counts from the time an event first arrived. A released event that waited again was held at the time of that second wait, so its cap moved later.

Mirror::hold now holds through the pool's restore with the event's first arrival time, read from its row. A released event keeps its row until it is decided, so the time is there. The stored format is unchanged: the arrived_at column already held this value, and only the value written changes. No format bump.
…ist [P1.8]

A fork makes its proof headers present, so events of other authors that waited for them may apply. Ingest::Fork keeps its shape, so those events are not listed in the outcome. The doc now says so.
…[P1.11]

A fork makes its two headers present, and a held header that a fork makes present is held
for good. The red unit test checks the header is still held after the fork. The property
checks, after every step and restart over random arrival orders, that each acknowledged
waiting event is held, stored, frozen, or expired. Both fail on the store as it stood: a
held header's row was deleted, and the header was neither held nor stored.
A fork released its two proof headers. Each release deleted the header's row and took it
out of the pool, so a header that was held was neither held nor stored. A released event
that the next decision made a proof header, or a duplicate of one, was deleted the same way.

Mirror::present_proof makes a header present and holds a held copy for good, as a parked
event is held. drop_released parks a released header whose decision does not apply it, and
removes the row of any other released event that is refused. Each change is one write
transaction, so the event stays in one state across it.
@dezren39
dezren39 requested a review from a team as a code owner October 12, 2026 05:29
@coderabbitai

coderabbitai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b64c409a-5190-4186-a2b8-ccdd2c65e8da

📥 Commits

Reviewing files that changed from the base of the PR and between c6469f9 and 613ed2d.


⛔ Files ignored due to path filters (3)
  • pkgs/id/Cargo.lock is excluded by !**/*.lock
  • pkgs/id/tests/crash/corpus.tsv is excluded by !**/*.tsv
  • pkgs/id/tests/crash/identity_corpus.tsv is excluded by !**/*.tsv

📒 Files selected for processing (35)
  • .conform.yaml
  • doc/2026-10-11T22-25-52Z_phase_1_events_storage/2026-10-11T22-25-52Z_phase_1_events_storage.md
  • pkgs/id/Cargo.toml
  • pkgs/id/flake.nix
  • pkgs/id/just-recipes.json
  • pkgs/id/justfile
  • pkgs/id/src/chain.rs
  • pkgs/id/src/commands/serve.rs
  • pkgs/id/src/event_store/ingest.rs
  • pkgs/id/src/event_store/mod.rs
  • pkgs/id/src/event_store/waiting_rows.rs
  • pkgs/id/src/lib.rs
  • pkgs/id/src/store_traits.rs
  • pkgs/id/src/waiting.rs
  • pkgs/id/src/web/identity.rs
  • pkgs/id/src/web/mod.rs
  • pkgs/id/tests/README.md
  • pkgs/id/tests/chain_properties.rs
  • pkgs/id/tests/common/chain.rs
  • pkgs/id/tests/common/ingest.rs
  • pkgs/id/tests/common/mod.rs
  • pkgs/id/tests/crash/corpus.rs
  • pkgs/id/tests/crash/faultfs.rs
  • pkgs/id/tests/crash/harness.rs
  • pkgs/id/tests/crash/identity.rs
  • pkgs/id/tests/crash/main.rs
  • pkgs/id/tests/crash/store.rs
  • pkgs/id/tests/crash/sweep.rs
  • pkgs/id/tests/crash/workload.rs
  • pkgs/id/tests/event_store_properties.rs
  • pkgs/id/tests/regression_replay.rs
  • pkgs/id/tests/regressions/README.md
  • pkgs/id/tests/regressions/chainext.txt
  • pkgs/id/tests/regressions/ingest.txt
  • pkgs/id/tests/waiting_properties.rs

 ______________________________________________________
< Review complete: I laughed, I cried, I filed issues. >
 ------------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR

🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant