Repository navigation
Conversation
…ity store [phase 1 start]
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.
…identity store [P1.9]
…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.
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
⛔ Files ignored due to path filters (3)
📒 Files selected for processing (35)
✨ Finishing Touches
Comment |
This was referenced Oct 28, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1: events, storage, crash safety, and the identity store
Record:
doc/…_phase_1_events_storage/(steps, commits, review, open items). Start commit4361a351.just crash-gate; the identity store has its own gate.idscope.Verified:
cargo test --all-features --no-fail-fastexits 0 on the head commit. Not verified: the Nix build (the p2panda git dependency may need outputHashes).Summary by CodeRabbit
New Features
Reliability
Documentation