This document freezes the internal architecture so modules can be built independently and compose
without rework. Read it together with docs/SPEC.md (the product spec). Where this doc and SPEC
disagree on an internal detail, this doc wins (SPEC wins on externally-visible behavior).
Targets: net10.0 by default and net10.0;net9.0 with UseMultitargeting=true, pure managed,
no native deps. Namespace root ActualLab.Kvasar.
Coding style: follow the ActualLab conventions (file-scoped namespaces, var, expression-bodied
members, mixed brace style — Allman for types/methods/ctors, K&R everywhere else, 4-space indent,
120-col lines, LF, minimal comments, no Async suffix). Private fields _camelCase; private
static readonly / const PascalCase.
KvasarOptions,KvasarStats,IndexEncryption,KvasarValueKind,KvasarCorruptExceptionKvasarKey,KvasarValue(+KvasarKeyExt/KvasarValueExt) — the public key/value structs; they wrapReadOnlyMemory<byte>and carry the conversions, so the internals below stay on raw memoryCrypto/IKeyHasher,Crypto/IKeyDerivation,Crypto/IPageCipher(+IPageCipherFactory)KvasarConstants,Internal/Locator,Internal/IndexEntry,Internal/RecordFlags,Internal/Varint
A data file <base>.N.kdat on disk = a fixed 64-byte plaintext header
(KvasarConstants.SegmentHeaderSize) followed by a sequence of encrypted pages, each
PageSize + cipher.Overhead bytes on disk.
Each decrypted data page begins with the 8-byte authenticated frame described under M3. Upper
layers work in the logical stream formed by concatenating the remaining
PagePayloadSize = PageSize - 8 record-data areas: offset O means page
pageId = O / PagePayloadSize, in-page offset O % PagePayloadSize. The physical mapping (past the
header, times PageSize+Overhead) is private to the paging layer. Locator.Offset is such a
logical offset. This keeps records independent of the on-disk header size and of Overhead.
The data-format-3 superblock <base>.kvs is exactly 1536 bytes:
offset size field
0 36 magic, format tag, KCV nonce and KCV tag
36 476 reserved, zero
512 512 authenticated commit slot 0
1024 512 authenticated commit slot 1
Slot contents are unchanged from data format 2. The 512-byte header sector separates the two slot sectors, so one torn sector cannot damage both.
Decrypted pages are cached as immutable byte[]. A value that fits within a single page is returned
as a ReadOnlyMemory<byte> slice into the cached page buffer — no copy. Cache eviction just
drops the reference; a ReadOnlyMemory a caller holds keeps the buffer alive (pure GC lifetime).
Never mutate a decrypted page buffer in place after it is published to the cache.
Multi-reader, single-writer. Readers never lock and never block on the writer. The single writer serializes all mutations. Publication of an index slot is the linearization point: write record bytes to a sealed/appended page first, then publish the locator with release semantics.
Read paths take a CancellationToken; write paths take none — AppendPage, SealTail,
Flush, slot recycling, delta append and checkpoint write all run to
completion. Cancelling a write is not a safe operation on a self-describing append-only log: a
half-written multi-page record leaves a header claiming more bytes than exist, so recovery reads
the records appended after it as that record's tail, and a torn entry in the buffered .kidx delta
stream shifts every entry behind it into a garbage locator. KvasarStore's public write methods
stay cancellable for the caller by awaiting an uncancellable *Locked body through
Task.WaitAsync(cancellationToken): the token abandons the wait, the body still finishes and
releases the write lock. Compaction's copy pass is the one exception — it only adds records, so
stopping it early leaves reclaimable dead bytes, never a dangling locator.
Each module lives under src/ActualLab.Kvasar/<Folder>/. Implement exactly these public surfaces
(you may add internal helpers in the same folder). All types are in namespace
ActualLab.Kvasar.Crypto (crypto) or ActualLab.Kvasar.Internal (everything else) unless noted.
Implements the three crypto interfaces + factories + default singletons.
SipHash24Hasher : IKeyHasher— SipHash-2-4.IsKeyed=true,SecretSize=16.Hash(key, secret)uses the 16-bytesecretas the SipHash key. Pure managed, correct SipHash-2-4.XxHash3Hasher : IKeyHasher— wrapsSystem.IO.Hashing.XxHash3.IsKeyed=false,SecretSize=0.KeyHashers(static) —SipHash24andXxHash3singleton instances.HkdfSha256KeyDerivation : IKeyDerivation— HKDF-SHA256 viaSystem.Security.Cryptography.HKDF.KeyDerivations(static) —HkdfSha256singleton.AesGcmPageCipher : IPageCipher+AesGcmPageCipherFactory : IPageCipherFactory.- Factory holds the 32-byte page key and
formatVer(uint).Overhead = GcmTagSize (16). Create(fileSalt)derives a per-file nonce base and returns a cipher bound to it.- Per page: deterministic 12-byte nonce =
f(pageId, fileSalt)(e.g. HKDF/SHA over salt+pageId, or AES-CTR of pageId) — MUST be unique per (fileSalt, pageId) and never stored. AAD =pageId(8 bytes LE) || formatVer(4 bytes LE). On-disk page = ciphertext(PageSize) then 16-byte tag. Decrypt throwsKvasarCorruptExceptionon tag mismatch (tamper/wrong key). - Thread-safety:
Encryptis writer-only (single-threaded).Decryptis called concurrently by readers — must be safe (create/rent per-callAesGcmor guard appropriately;AesGcminstances are not thread-safe for concurrent use).
- Factory holds the 32-byte page key and
NoopPageCipher : IPageCipher+NoopPageCipherFactory—Overhead=0, copy plain↔onDisk. Used whenKvasarOptions.DisableEncryptionis set (tests/benchmarks).
SegmentHeaderformats and validates the 64-byte plaintext header: magic, data format, page size, file salt, and flags. Data format 3 defines flag bit 0 as AES-GCM pages; every other bit is zero. A requested encryption mode that disagrees with this flag is a configuration error, not corruption.PageCachestores immutable decryptedPageSize-byte pages by the store-assigned(fileId, pageId)incarnation key.PagedFileowns one fixed.N.kdatslot. Its physical page mapping is64 + pageId * (PageSize + cipher.Overhead). It uses positional asynchronous I/O, stages encrypted writes, exposes a zero-allocation cache-hit read, and rounds a torn physical tail upward so a nonce can never be reused.- Recycling a slot truncates it to the header, chooses a new salt and cache incarnation, and resets its page sequence. No valid superblock candidate may still reference the recycled incarnation.
Owns the record format, authenticated page framing, and two fixed data slots.
Record bytes are unchanged:
recordLen: varint (bytes after this field), flags: u8, valueKind: u8, keyLen: varint,
key: keyLen bytes, value: rest. A tombstone has no value.
Each decrypted PageSize-byte data page has this exact format:
offset size field
0 1 flags: bit 0 = continuation, bit 1 = hasRecordStart
1 3 reserved, all zero
4 4 firstRecordOffset: signed little-endian offset in recordData, or -1
8 P-8 recordData
No other flag bits are valid. hasRecordStart is equivalent to firstRecordOffset >= 0.
A page without continuation must have its first record at offset 0. A continuation page may have
no new record or may name its first new record at an offset greater than zero. Because the frame is
part of the encrypted plaintext, AES-GCM authenticates it and caller bytes cannot occupy it.
DataLog exposes PagePayloadSize = PageSize - 8; all locators and record-length arithmetic use
that logical payload geometry. A single-page record is still returned as a zero-copy slice of a cached
page. Multi-page records are split only across record-data areas. The mutable tail tracks the same frame
state that will be sealed with it.
Recovery walks each page once. It yields the known record starts from the frame and, after an unreadable
header page, resumes only at a later authenticated firstRecordOffset. Continuation data is never
decoded as a possible header. There is no tiling grammar and no candidate probing.
In-RAM open-addressing table; lock-free readers, single writer.
Each slot stores a full KeyHash, a KeyId, a packed locator, and record length. KeyId == 0 is
invalid. New logical key incarnations receive _nextKeyId++; overwrite and compaction relocation
preserve the existing id. Delete followed by reinsertion mints a new id. The next counter value is
stored in every superblock state, and recovery also raises it above every id loaded from the adopted
index. Exhaustion fails instead of wrapping.
Readers probe by hash and use (KeyHash, KeyId) plus the full on-disk key where a caller key exists.
Publication remains a release write of the packed locator; readers take a single table snapshot and
acquire-read the locator. Resize is copy-on-write.
The index layout is independently versioned; it is at 4, which introduced the block-chained commit
MAC below. Data format 3 does not change it. Each persisted IndexEntry is exactly 32 bytes:
offset size field
0 8 KeyHash
8 8 PackedLocator
16 8 KeyId
24 4 record Length
28 1 record Flags
29 3 zero padding
The 64-byte index header carries magic, data format, index layout version, entry count, data commit
length, and two generation-parity HMAC slots. A checkpoint is followed by fixed-size deltas.
Invalid, stale, absent, or unauthenticated index data is discarded and rebuilt from .kdat.
IndexMac computes those tags as a block-chained HMAC-SHA256 (Index/IndexMac.cs): the body — the
checkpoint region plus the committed delta tail — is cut into fixed 16 KiB blocks, and each block's step
is a one-shot HMAC over a 48-byte typed prefix (step kind, payload length, block index, previous chain
value) followed by that block's bytes. The commit tag is a third step kind over the chain value and the
open trailing block, truncated to 16 bytes. Three properties fall out of that shape:
- Committing is bounded work. A tag covers at most one block, so commit cost stops scaling with the index — the whole point, since commits run under the store's single write lock. Load still costs one pass over the prefix, paying only the per-block HMAC restart.
- No running digest is ever peeked. Android's
Maccannot be cloned, soIncrementalHash'sGetCurrentHash— a "hash so far without ending the computation" — is not portable there. Every step here is one-shot, which is also the fastest path on every other platform. - Per-entry work stays managed. Appending a delta memcpy's 32 bytes into the open block; the crypto call happens once per 16 KiB. On Android that turns one JNI round-trip per cached key into one per ~500 keys.
The verifier is the writer: IsMacValid runs the same Append over the whole committed prefix, so the
two paths cannot drift apart — and because that walk is the running state an appender needs, Read
adopts the verifier's IndexMac instead of rebuilding it. Rebuilding hashed a multi-megabyte index twice
per open. Each tag binds the authentication context (the active .kdat incarnation's fileSalt), the
stable header fields, block position, and the exact committed length — a truncated, extended, reordered
or edited prefix fails.
- Deriving page key + hash key from the master key via
IKeyDerivationand theKvasarConstantsinfo labels; buildingAesGcmPageCipherFactory. - Resolving option defaults (
Hasher ??= KeyHashers.SipHash24,Kdf ??= KeyDerivations.HkdfSha256). - Lock file, open/lifecycle, Get/GetMany/Set/SetMany/Scan/Clear/Flush/Compact/DisposeAsync, Stats.
- Wiring the background compactor.
- Normal open adopts authenticated superblock generations only. If none is adoptable, the last-resort
path proves the key with a current-format
.kdatpage, adopts the longest contiguous authenticating prefix, rebuilds the index, rewrites the superblock, and recordsKvasarStats.FallbackRecoveries. A wrong key or caller-requested page/encryption geometry mismatch must escape without reaching wipe.
SQLite is synchronous for historical reasons; Kvasar deliberately is not. Every storage path is async and cancellable, and the shape of that conversion is load-bearing:
ValueTaskeverywhere, notTask. Anasync ValueTaskmethod that completes without suspending never heap-allocates its state machine, so the warm path stays allocation-free.- Genuine sync fast paths.
KvasarStore.GetandPagedFile.GetPageare notasyncmethods: they resolve a cache-resident, single-page record inline and return an already-completedValueTask— no state machine, no allocation, no thread hop. Only a cache miss or a page-spanning record falls through to the awaited path (GetSlow/ReadAndCache). This matters because the cache-hit read is Kvasar's headline win over SQLCipher; paying state-machine cost on it would tax the hot path.DataLog.TryReadRecordCachedis the cache-only probe underneath, and it is conservative: it returns false rather than a wrong answer, so the async path is always a correct fallback. - The writer lock is a
SemaphoreSlim(1, 1), notlock. AMonitorcan't be held acrossawait, and the write path now awaits real I/O. Single-writer semantics are unchanged. - Async factories. Constructors can't await, so
KvasarStore.Open,SegmentSet.Create, andPagedSegment.Create/Openare static async factories. - No
outparameters on async methods.TryReadRecordreturnsRecordReadandIndexFile.TryLoadreturnsIndexCheckpoint?instead. The sync cache-only probes keep theiroutform. Span→Memoryat async boundaries. Ref structs can't cross anawait; spans are still used freely within a non-suspending region (e.g. encrypt/decrypt after the last await).fsyncis the one unavoidable blocking call. .NET has no asyncFlushToDisk, so it is offloaded off the caller's thread rather than run inline.Statsstays synchronous — a best-effort, lock-free snapshot; the numbers are advisory (compaction/diagnostics) and never used for correctness.
This absorbs the one part of ActualChat's BatchingKvas worth keeping — its LazyWriter — and does it
better, because the store can delay durability without delaying visibility:
Setappends and publishes to the in-RAM index synchronously, so the value is readable by every reader the instant it returns. Only sealing and the disk write are deferred, by up toFlushDelay. (ALazyWriterabove the store can't manage this: it delays the write itself, so a key evicted from its 256-entry cache before the flush reads back stale.)- The accepted failure mode is exactly and only this: a crash loses writes newer than the last flush. Anything already flushed survives, and the store always reopens consistent.
- Deferring the seal is what makes an unbatched
Setcheap. Sealing per call pads a whole page per record — measured at ~21× amplification (16 MB of data → 340 MB on disk). - Mandatory flush points that must never be deferred: before
RemoveSegmentin compaction (else records copied into the new segment die with the deleted source — real loss, not staleness),WriteCheckpoint,Clear, andDisposeAsync. - Ordering rule: the log must never be less durable than the index.
.kidxmay lag freely — it's a hint replayed from the HWM — but if it ever led, it would point at records that don't exist.
Two hazards this introduced, both caught by the test suite and worth remembering:
- Tail visibility. Publishing into the unsealed tail means readers slice a buffer the writer is
still appending to. Individual bytes are stable (the writer only appends past
Fill, andSealTailinstalls a new buffer), but(buffer, fill, pageId)must be read as one unit — reading them separately lets a concurrent seal swap the buffer mid-read, and the reader then slices the fresh empty one and reports a miss for a key that exists. Fixed by publishing an immutableTailSnapshotthrough a single volatile field. In durable mode this was unreachable, because the seal always preceded the publish. - Page-id reuse ⇒ nonce reuse. A crash can now lose whole unwritten pages, so
PageCountcomes back lower and appends would re-encrypt different data under an already-used(fileSalt, pageId)nonce. Recovery therefore rolls to a fresh segment (new random salt ⇒ disjoint nonce space) whenever it can't prove the previous run closed cleanly. A graceful close leaves a<base>.cleanmarker, so the common path doesn't strand a segment per launch.
Async file I/O has a materially higher fixed cost per operation than a synchronous write into the OS
cache (measured at roughly ~20 µs/page on Windows overlapped I/O — see docs/BENCHMARKS.md). Anything
that does one I/O per 4 KiB page therefore pays that cost thousands of times. Two mitigations, both of
which preserve existing semantics:
- Write-behind (
PagedSegment.AppendPage) batches staged pages into oneWriteAsyncper ~1 MiB. - Readahead (
PagedSegment.Prefetch) pulls ~1 MiB of consecutive pages per read on sequential walks (SegmentSet.ScanFromduring recovery, andKvasarStore.Scan).
KvasarStore.Scan additionally sorts the index snapshot by (SegmentId, Offset) before reading. The
snapshot is in hash order, which makes a full scan a storm of random page faults; scan order is
unspecified by SPEC §4, so walking in log order is free and is what makes readahead effective.
Correct, warning-clean C# against the contracts above, plus focused xUnit tests where the module is
independently testable (crypto vectors, varint round-trip, record codec round-trip, page cache
eviction, hash-index probe/resize, index-file round-trip). Put tests under
tests/ActualLab.Kvasar.Tests/<Module>/. Do not modify .csproj files or any file outside your
assigned folder(s).
The v1 implementation is functionally complete and passes the full test suite (unit, encryption,
crash/torn-tail recovery, concurrency, and property-based vs. a Dictionary oracle). Two conscious
gaps remain, both correctness-neutral for the target scenario:
-
64-bit hash-collision fan-out (§6.2). The in-RAM index keys each slot by the full 64-bit keyed hash and stores no key bytes (the §6.1 key-length-independent design). Two distinct keys that share a full 64-bit hash therefore collapse onto one slot — the later write shadows the earlier key, which becomes unreachable (a single-key loss). The on-disk full-key verify still guarantees a lookup never returns another key's value (it returns the right value or
null), so no wrong data is ever served, and a regenerable cache self-heals the lost entry on the next miss. Probability under the default keyed SipHash-2-4 is ~2⁻⁶⁴ (negligible at the ~10⁶-entry target). It is more reachable only with a non-keyed 64-bit hasher (the built-inXxHash3) at very large key counts. A full fix (distinct same-hash slots + on-disk key disambiguation on the write path, mirrored in.kidxload) touchesHashIndex,IndexFile, and the store write path and adds a read per colliding overwrite; deferred deliberately. Guarded by the skipped regression testEdgeCaseTests.HashCollisionFanOut_KnownBug. Recommendation: keep the default SipHash-2-4. -
Open still decrypts the log.Resolved. Open is now O(index), not O(data): the fast path loads.kidx, decrypts only the post-checkpoint gap viaSegmentSet.ScanFrom(hwm)(zero records after a graceful close), validates the key with a single-page decrypt, and seeds live/dead accounting from the loaded index (SeedAccountingFromIndex: LiveBytes = Σ entry lengths per segment; DeadBytes = segment gross − live, a slight over-count from varint prefixes + page padding that only makes compaction marginally more eager). Measured open dropped from ~2 s to ~20 ms for a 410 MB / 100k-entry store. The full-log decrypt now happens only on the fallback path (a missing or unusable.kidx). Wrong-key/tamper is still caught on open by the single-page validation decrypt.
An adversarial review of every module found these; each now has a regression test in
Store/HardeningTests.cs, Paging/PagedSegmentWriteBehindTests.cs, or the per-module *ReviewTests.cs.
- A checkpoint fired mid-batch permanently lost acknowledged writes (critical; found by the crash
fuzzer).
AppendDeltacheckpointed inline once the delta count crossed its threshold, andSetMany(and compaction) seal the whole batch before publishing any of it. A checkpoint taken from inside the publish loop therefore stamped an end-of-batch HWM onto an index containing only the entries published so far. The remaining entries' deltas went to a freshly reopened, never-fsynced buffer, andLoadIndexreplays onlyScanFrom(hwm)— from the end of the batch — so their records were never re-read. The writes were gone for good even thoughSetManyhad returned and the.klogheld them. Reproducer: 128 singleSets then one 24-keySetMany→ 23 of 24 keys missing after an abort. Compaction had the same shape and was worse, since it also deletes the drained segment. Fixed by deferring the checkpoint (MaybeCheckpoint) until after the publish/repoint loop, where the index is once again consistent with the sealed log. - AES-GCM nonce reuse after a torn tail (critical).
PagedSegment.OpenfloorsPageCount, discarding a half-written trailing page, so the next append reused thatpageId— and the nonce is a pure function of(fileSalt, pageId). Two different plaintexts under one(key, nonce)leaks their XOR and enables the GCM "forbidden attack" against the auth subkey. Fixed: a segment with a torn tail is never appended to; recovery starts a fresh segment, which gets a fresh random salt. - Unauthenticated
segmentIdbricked the store silently (critical). The 64-byte header isn't in any AAD, so a flippedsegmentIdwas adopted verbatim whileSegmentSetkept keying by filename — every later write minted a locator for an unknown segment and read back as a miss, permanently. Fixed:PagedSegment.Opencross-checks the header id against the filename. - Corrupt input threw instead of being rejected, defeating wipe-and-recreate (§12) and making a store
permanently un-openable. All from casting an attacker/corruption-controlled
ulonglength to a signed type before bounds-checking it, so the negative result sailed past every guard into a throwingSlice:IndexFile.Parse(checkpointCount * EntrySizewrapping),RecordCodec.TryParse(body and key lengths), andSegmentSet.TryReadAt/TryReadRecordCached. All now bound before narrowing. - Torn triples in
ProbeCursor.MoveNext. The acquire on the locator pinned the writes that preceded it, not the slot's generation, so a slot recycled mid-read returned key A's locator with key B's hash and key C's length — measured at ~10% of writer ops under contention (412k mismatches in 10 s). Impact was masked by the on-disk full-key verify (a false miss, never wrong data), but the documented invariant was false and the obvious "skip the verify, collisions are 2⁻⁶⁴" optimization would have turned it into silent wrong data. Fixed with seqlock validation: re-read the locator after the parallel fields. - Unkillable spins.
HashIndex.CeilPow2/CapacityForandPageCache.RoundUpPow2doubled anintpast 2³⁰ toint.MinValuethen0, leaving their loop conditions permanently true. Now bounded. - A sentinel locator could orphan a probe run.
Locator.Nonepacks to the empty-slot sentinel; the only guard was aDebug.Assert, stripped in Release.Setnow throws, andBulkLoad/Applyskip such entries (they come straight from the unvalidated.kidx, where a zero-filled tail decodes as packed 0). - Lifecycle.
DisposeAsynccould leak the store lock for the process lifetime if the buffered.kidxdispose threw (disk full), because_disposedwas already set so a retry no-oped — now afinally. Wipe-and-recreate also ran with the lock released, letting another process create a store we then deleted (on Unix the unlink succeeds silently) — the lock is now held across the wipe. - Minor:
PageCache.Addpublished into the LRU before the map (an OOM there drove the byte counter negative, permanently disabling eviction);AesGcmPageCipher.Decryptonly wrappedAuthenticationTagMismatchException, letting otherCryptographicExceptions escape to the app;Varintaccepted overlong 10-byte encodings;TryReadExistingHeaderprobed only.001.klog, so page size was mis-adopted after compaction deleted segment 1.
Stale below this line. The storage layer described in this document — segments,
SegmentSet,PagedSegment, the v1IndexFile, the.cleanmarker — has been replaced by the model inDESIGN-Durability.md: a superblock over two.kdatand two.kidxslots. Several limitations below are therefore gone with the code that caused them (notably 3 and 5, both segment-lifecycle artefacts). Read the durability design first; treat this file as the v1 record.
-
Apple
F_FULLFSYNCand the directory-entry gap — both resolved, and neither the way this file expected.SystemNative_FSyncdoes usefcntl(F_FULLFSYNC), but only underTARGET_OSX, which is desktop macOS alone — iOS, tvOS and Mac Catalyst get plainfsync(DESIGN-Durability.md§7). It no longer matters: the v2 design buys atomicity, not durability, so nothing depends on reaching the medium (§1, §6c). The directory gap is likewise gone by construction rather than byfsync— all files are created once at store creation and never renamed or unlinked while open, so no dirent is ever in the durability path, and the worst case of losing one is "the store looks uninitialized", which is the accepted wipe-and-rebuild path, never a torn state (§3.4). -
Segment-file substitution / rollback. Nothing binds a
.klogto the store or to its own filename: the per-file salt travels inside the file, so replacing a whole segment with an older copy of itself authenticates perfectly. The filename cross-check above stops a flipped id, not a wholesale swap. A real fix binds a store id + segment id into the GCM AAD (an on-disk format change). -
Page-cache budget is a soft bound. A shard never evicts its last entry, so residency has a floor of one page per touched shard; when
PageCacheBytes < shardCount * PageSizethe cache can exceed its budget several-fold (e.g. a 64 KiB budget on a 64-core box). Harmless at the 16/64 MiB defaults. -
RemoveSegmenthas no handle refcount or grace period (SPEC §9 asks for one). A read already in flight when the compactor deletes that segment fails withObjectDisposedException, which the store swallows as a miss — soGetcan returnnullfor a key that exists. Rare, and self-healing for a regenerable cache. -
The index never shrinks.
Rehashruns only on insert-driven growth, so after mass deletion the arrays and their tombstones stay resident until enough new keys trip the threshold. -
BulkLoadskips tombstones rather than applying them, so it is only correct on a pre-resolved entry set.IndexFile.Parseguarantees that (last-writer-wins perKeyHash); feeding it a raw delta stream would resurrect deleted keys. -
Crypto hot-path cost.
Decryptbuilds a freshAesGcmper call (a key schedule) and derives the nonce with a full HMAC-SHA256 per page. A per-file nonce base combined withpageIdwould remove the HMAC and make uniqueness structural rather than probabilistic, but it changes the on-disk format.
Other deliberate deviations from SPEC's open decisions:
- Crypto subkeys are derived from the master key with an empty HKDF salt (distinct info labels per subkey); per-page nonce uniqueness comes from each segment's own random salt. The master key is already a uniformly-random 256-bit secret, so a store-level KDF salt adds nothing (§5.3).
- Encrypted
.kidxis not implemented.IndexEncryption.Onis rejected atOpen;Autowith a non-keyed hasher does not persist.kidxand rebuilds the index from the log on open. - Lock contention raises
KvasarLockException(distinct fromKvasarCorruptException) so a second opener never triggers wipe-and-recreate on a live store.