{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[285b64d4] Offload Reddit payloads to RocksDB and stream event log replay.\n\nVendor durable as a workspace crate, store entity JSON in entity_db instead\nof GlobalTree, and replay events.jsonl one line at a time to cut startup RAM.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex e55d87f32ab32064686431c7082ef8c9ca872d63..8fc09f9ac978bd7ccf57f177989057b606677db8 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -11,6 +11,18 @@ dependencies = [\n \"memchr\",\n ]\n \n+[[package]]\n+name = \"anes\"\n+version = \"0.1.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299\"\n+\n+[[package]]\n+name = \"anstyle\"\n+version = \"1.0.14\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000\"\n+\n [[package]]\n name = \"anyhow\"\n version = \"1.0.102\"\n@@ -56,6 +68,12 @@ version = \"1.1.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0\"\n \n+[[package]]\n+name = \"autocfg\"\n+version = \"1.5.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53\"\n+\n [[package]]\n name = \"axum\"\n version = \"0.7.9\"\n@@ -153,6 +171,75 @@ version = \"0.22.1\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6\"\n \n+[[package]]\n+name = \"bincode\"\n+version = \"1.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad\"\n+dependencies = [\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.65.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5\"\n+dependencies = [\n+ \"bitflags 1.3.2\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"lazy_static\",\n+ \"lazycell\",\n+ \"peeking_take_while\",\n+ \"prettyplease\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 1.1.0\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.72.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895\"\n+dependencies = [\n+ \"bitflags 2.11.1\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"itertools 0.13.0\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 2.1.2\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bit-set\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3\"\n+dependencies = [\n+ \"bit-vec\",\n+]\n+\n+[[package]]\n+name = \"bit-vec\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7\"\n+\n+[[package]]\n+name = \"bitflags\"\n+version = \"1.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a\"\n+\n [[package]]\n name = \"bitflags\"\n version = \"2.11.1\"\n@@ -171,6 +258,22 @@ version = \"1.11.1\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33\"\n \n+[[package]]\n+name = \"bzip2-sys\"\n+version = \"0.1.13+1.0.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+]\n+\n+[[package]]\n+name = \"cast\"\n+version = \"0.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5\"\n+\n [[package]]\n name = \"cc\"\n version = \"1.2.62\"\n@@ -178,15 +281,89 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98\"\n dependencies = [\n \"find-msvc-tools\",\n+ \"jobserver\",\n+ \"libc\",\n \"shlex\",\n ]\n \n+[[package]]\n+name = \"cexpr\"\n+version = \"0.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766\"\n+dependencies = [\n+ \"nom\",\n+]\n+\n [[package]]\n name = \"cfg-if\"\n version = \"1.0.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801\"\n \n+[[package]]\n+name = \"ciborium\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"ciborium-ll\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"ciborium-io\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757\"\n+\n+[[package]]\n+name = \"ciborium-ll\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"half\",\n+]\n+\n+[[package]]\n+name = \"clang-sys\"\n+version = \"1.8.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4\"\n+dependencies = [\n+ \"glob\",\n+ \"libc\",\n+ \"libloading\",\n+]\n+\n+[[package]]\n+name = \"clap\"\n+version = \"4.6.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51\"\n+dependencies = [\n+ \"clap_builder\",\n+]\n+\n+[[package]]\n+name = \"clap_builder\"\n+version = \"4.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f\"\n+dependencies = [\n+ \"anstyle\",\n+ \"clap_lex\",\n+]\n+\n+[[package]]\n+name = \"clap_lex\"\n+version = \"1.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9\"\n+\n [[package]]\n name = \"cookie\"\n version = \"0.18.1\"\n@@ -224,6 +401,73 @@ version = \"0.8.7\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b\"\n \n+[[package]]\n+name = \"criterion\"\n+version = \"0.5.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f\"\n+dependencies = [\n+ \"anes\",\n+ \"cast\",\n+ \"ciborium\",\n+ \"clap\",\n+ \"criterion-plot\",\n+ \"is-terminal\",\n+ \"itertools 0.10.5\",\n+ \"num-traits\",\n+ \"once_cell\",\n+ \"oorandom\",\n+ \"plotters\",\n+ \"rayon\",\n+ \"regex\",\n+ \"serde\",\n+ \"serde_derive\",\n+ \"serde_json\",\n+ \"tinytemplate\",\n+ \"walkdir\",\n+]\n+\n+[[package]]\n+name = \"criterion-plot\"\n+version = \"0.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1\"\n+dependencies = [\n+ \"cast\",\n+ \"itertools 0.10.5\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-deque\"\n+version = \"0.8.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51\"\n+dependencies = [\n+ \"crossbeam-epoch\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-epoch\"\n+version = \"0.9.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e\"\n+dependencies = [\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-utils\"\n+version = \"0.8.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28\"\n+\n+[[package]]\n+name = \"crunchy\"\n+version = \"0.2.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5\"\n+\n [[package]]\n name = \"deranged\"\n version = \"0.5.8\"\n@@ -250,6 +494,25 @@ version = \"0.15.7\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b\"\n \n+[[package]]\n+name = \"durable\"\n+version = \"0.1.0\"\n+dependencies = [\n+ \"bincode\",\n+ \"criterion\",\n+ \"proptest\",\n+ \"rocksdb\",\n+ \"serde\",\n+ \"tempfile\",\n+ \"thiserror\",\n+]\n+\n+[[package]]\n+name = \"either\"\n+version = \"1.16.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e\"\n+\n [[package]]\n name = \"encoding_rs\"\n version = \"0.8.35\"\n@@ -373,6 +636,18 @@ dependencies = [\n \"wasi\",\n ]\n \n+[[package]]\n+name = \"getrandom\"\n+version = \"0.3.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"r-efi 5.3.0\",\n+ \"wasip2\",\n+]\n+\n [[package]]\n name = \"getrandom\"\n version = \"0.4.2\"\n@@ -381,11 +656,17 @@ checksum = \"0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555\"\n dependencies = [\n \"cfg-if\",\n \"libc\",\n- \"r-efi\",\n+ \"r-efi 6.0.0\",\n \"wasip2\",\n \"wasip3\",\n ]\n \n+[[package]]\n+name = \"glob\"\n+version = \"0.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280\"\n+\n [[package]]\n name = \"h2\"\n version = \"0.4.14\"\n@@ -405,6 +686,17 @@ dependencies = [\n \"tracing\",\n ]\n \n+[[package]]\n+name = \"half\"\n+version = \"2.7.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"crunchy\",\n+ \"zerocopy\",\n+]\n+\n [[package]]\n name = \"hashbrown\"\n version = \"0.15.5\"\n@@ -426,6 +718,12 @@ version = \"0.5.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea\"\n \n+[[package]]\n+name = \"hermit-abi\"\n+version = \"0.5.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c\"\n+\n [[package]]\n name = \"http\"\n version = \"1.4.1\"\n@@ -676,12 +974,51 @@ version = \"2.12.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2\"\n \n+[[package]]\n+name = \"is-terminal\"\n+version = \"0.4.17\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46\"\n+dependencies = [\n+ \"hermit-abi\",\n+ \"libc\",\n+ \"windows-sys 0.61.2\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.10.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473\"\n+dependencies = [\n+ \"either\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.13.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186\"\n+dependencies = [\n+ \"either\",\n+]\n+\n [[package]]\n name = \"itoa\"\n version = \"1.0.18\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682\"\n \n+[[package]]\n+name = \"jobserver\"\n+version = \"0.1.34\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33\"\n+dependencies = [\n+ \"getrandom 0.3.4\",\n+ \"libc\",\n+]\n+\n [[package]]\n name = \"js-sys\"\n version = \"0.3.99\"\n@@ -700,6 +1037,12 @@ version = \"1.5.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe\"\n \n+[[package]]\n+name = \"lazycell\"\n+version = \"1.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55\"\n+\n [[package]]\n name = \"leb128fmt\"\n version = \"0.1.0\"\n@@ -712,6 +1055,43 @@ version = \"0.2.186\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66\"\n \n+[[package]]\n+name = \"libloading\"\n+version = \"0.8.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"windows-link\",\n+]\n+\n+[[package]]\n+name = \"librocksdb-sys\"\n+version = \"0.11.0+8.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e\"\n+dependencies = [\n+ \"bindgen 0.65.1\",\n+ \"bzip2-sys\",\n+ \"cc\",\n+ \"glob\",\n+ \"libc\",\n+ \"libz-sys\",\n+ \"lz4-sys\",\n+ \"zstd-sys\",\n+]\n+\n+[[package]]\n+name = \"libz-sys\"\n+version = \"1.1.28\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+ \"vcpkg\",\n+]\n+\n [[package]]\n name = \"linux-raw-sys\"\n version = \"0.12.1\"\n@@ -730,6 +1110,16 @@ version = \"0.4.30\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5\"\n \n+[[package]]\n+name = \"lz4-sys\"\n+version = \"1.11.1+lz4-1.10.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6\"\n+dependencies = [\n+ \"cc\",\n+ \"libc\",\n+]\n+\n [[package]]\n name = \"matchers\"\n version = \"0.2.0\"\n@@ -781,6 +1171,12 @@ version = \"0.3.17\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a\"\n \n+[[package]]\n+name = \"minimal-lexical\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a\"\n+\n [[package]]\n name = \"mio\"\n version = \"1.2.0\"\n@@ -826,6 +1222,16 @@ dependencies = [\n \"tempfile\",\n ]\n \n+[[package]]\n+name = \"nom\"\n+version = \"7.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a\"\n+dependencies = [\n+ \"memchr\",\n+ \"minimal-lexical\",\n+]\n+\n [[package]]\n name = \"nu-ansi-term\"\n version = \"0.50.3\"\n@@ -841,19 +1247,34 @@ version = \"0.2.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441\"\n \n+[[package]]\n+name = \"num-traits\"\n+version = \"0.2.19\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841\"\n+dependencies = [\n+ \"autocfg\",\n+]\n+\n [[package]]\n name = \"once_cell\"\n version = \"1.21.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50\"\n \n+[[package]]\n+name = \"oorandom\"\n+version = \"11.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e\"\n+\n [[package]]\n name = \"openssl\"\n version = \"0.10.80\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"cfg-if\",\n \"foreign-types\",\n \"libc\",\n@@ -890,6 +1311,12 @@ dependencies = [\n \"vcpkg\",\n ]\n \n+[[package]]\n+name = \"peeking_take_while\"\n+version = \"0.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099\"\n+\n [[package]]\n name = \"percent-encoding\"\n version = \"2.3.2\"\n@@ -908,6 +1335,34 @@ version = \"0.3.33\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e\"\n \n+[[package]]\n+name = \"plotters\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747\"\n+dependencies = [\n+ \"num-traits\",\n+ \"plotters-backend\",\n+ \"plotters-svg\",\n+ \"wasm-bindgen\",\n+ \"web-sys\",\n+]\n+\n+[[package]]\n+name = \"plotters-backend\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a\"\n+\n+[[package]]\n+name = \"plotters-svg\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670\"\n+dependencies = [\n+ \"plotters-backend\",\n+]\n+\n [[package]]\n name = \"potential_utf\"\n version = \"0.1.5\"\n@@ -974,6 +1429,31 @@ dependencies = [\n \"unicode-ident\",\n ]\n \n+[[package]]\n+name = \"proptest\"\n+version = \"1.11.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744\"\n+dependencies = [\n+ \"bit-set\",\n+ \"bit-vec\",\n+ \"bitflags 2.11.1\",\n+ \"num-traits\",\n+ \"rand 0.9.4\",\n+ \"rand_chacha 0.9.0\",\n+ \"rand_xorshift\",\n+ \"regex-syntax\",\n+ \"rusty-fork\",\n+ \"tempfile\",\n+ \"unarray\",\n+]\n+\n+[[package]]\n+name = \"quick-error\"\n+version = \"1.2.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0\"\n+\n [[package]]\n name = \"quote\"\n version = \"1.0.45\"\n@@ -983,6 +1463,12 @@ dependencies = [\n \"proc-macro2\",\n ]\n \n+[[package]]\n+name = \"r-efi\"\n+version = \"5.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f\"\n+\n [[package]]\n name = \"r-efi\"\n version = \"6.0.0\"\n@@ -996,8 +1482,18 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\"\n dependencies = [\n \"libc\",\n- \"rand_chacha\",\n- \"rand_core\",\n+ \"rand_chacha 0.3.1\",\n+ \"rand_core 0.6.4\",\n+]\n+\n+[[package]]\n+name = \"rand\"\n+version = \"0.9.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea\"\n+dependencies = [\n+ \"rand_chacha 0.9.0\",\n+ \"rand_core 0.9.5\",\n ]\n \n [[package]]\n@@ -1007,7 +1503,17 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88\"\n dependencies = [\n \"ppv-lite86\",\n- \"rand_core\",\n+ \"rand_core 0.6.4\",\n+]\n+\n+[[package]]\n+name = \"rand_chacha\"\n+version = \"0.9.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb\"\n+dependencies = [\n+ \"ppv-lite86\",\n+ \"rand_core 0.9.5\",\n ]\n \n [[package]]\n@@ -1019,6 +1525,56 @@ dependencies = [\n \"getrandom 0.2.17\",\n ]\n \n+[[package]]\n+name = \"rand_core\"\n+version = \"0.9.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c\"\n+dependencies = [\n+ \"getrandom 0.3.4\",\n+]\n+\n+[[package]]\n+name = \"rand_xorshift\"\n+version = \"0.4.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a\"\n+dependencies = [\n+ \"rand_core 0.9.5\",\n+]\n+\n+[[package]]\n+name = \"rayon\"\n+version = \"1.12.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d\"\n+dependencies = [\n+ \"either\",\n+ \"rayon-core\",\n+]\n+\n+[[package]]\n+name = \"rayon-core\"\n+version = \"1.13.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91\"\n+dependencies = [\n+ \"crossbeam-deque\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"regex\"\n+version = \"1.12.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276\"\n+dependencies = [\n+ \"aho-corasick\",\n+ \"memchr\",\n+ \"regex-automata\",\n+ \"regex-syntax\",\n+]\n+\n [[package]]\n name = \"regex-automata\"\n version = \"0.4.14\"\n@@ -1090,13 +1646,35 @@ dependencies = [\n \"windows-sys 0.52.0\",\n ]\n \n+[[package]]\n+name = \"rocksdb\"\n+version = \"0.21.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe\"\n+dependencies = [\n+ \"libc\",\n+ \"librocksdb-sys\",\n+]\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"1.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2\"\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"2.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe\"\n+\n [[package]]\n name = \"rustix\"\n version = \"1.1.4\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"errno\",\n \"libc\",\n \"linux-raw-sys\",\n@@ -1142,12 +1720,33 @@ version = \"1.0.22\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d\"\n \n+[[package]]\n+name = \"rusty-fork\"\n+version = \"0.3.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2\"\n+dependencies = [\n+ \"fnv\",\n+ \"quick-error\",\n+ \"tempfile\",\n+ \"wait-timeout\",\n+]\n+\n [[package]]\n name = \"ryu\"\n version = \"1.0.23\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f\"\n \n+[[package]]\n+name = \"same-file\"\n+version = \"1.0.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502\"\n+dependencies = [\n+ \"winapi-util\",\n+]\n+\n [[package]]\n name = \"schannel\"\n version = \"0.1.29\"\n@@ -1163,7 +1762,7 @@ version = \"3.7.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"core-foundation 0.10.1\",\n \"core-foundation-sys\",\n \"libc\",\n@@ -1307,9 +1906,10 @@ dependencies = [\n \"axum\",\n \"axum-extra\",\n \"dotenvy\",\n+ \"durable\",\n \"futures-util\",\n \"maud\",\n- \"rand\",\n+ \"rand 0.8.6\",\n \"reqwest\",\n \"serde\",\n \"serde_json\",\n@@ -1378,7 +1978,7 @@ version = \"0.7.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"core-foundation 0.9.4\",\n \"system-configuration-sys\",\n ]\n@@ -1476,6 +2076,16 @@ dependencies = [\n \"zerovec\",\n ]\n \n+[[package]]\n+name = \"tinytemplate\"\n+version = \"1.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc\"\n+dependencies = [\n+ \"serde\",\n+ \"serde_json\",\n+]\n+\n [[package]]\n name = \"tokio\"\n version = \"1.52.3\"\n@@ -1558,7 +2168,7 @@ version = \"0.5.2\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"bytes\",\n \"http\",\n \"http-body\",\n@@ -1575,7 +2185,7 @@ version = \"0.6.11\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"bytes\",\n \"futures-util\",\n \"http\",\n@@ -1667,6 +2277,12 @@ version = \"0.2.5\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b\"\n \n+[[package]]\n+name = \"unarray\"\n+version = \"0.1.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94\"\n+\n [[package]]\n name = \"unicode-ident\"\n version = \"1.0.24\"\n@@ -1727,6 +2343,25 @@ version = \"0.9.5\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a\"\n \n+[[package]]\n+name = \"wait-timeout\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11\"\n+dependencies = [\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"walkdir\"\n+version = \"2.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b\"\n+dependencies = [\n+ \"same-file\",\n+ \"winapi-util\",\n+]\n+\n [[package]]\n name = \"want\"\n version = \"0.3.1\"\n@@ -1843,7 +2478,7 @@ version = \"0.244.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe\"\n dependencies = [\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"hashbrown 0.15.5\",\n \"indexmap\",\n \"semver\",\n@@ -1859,6 +2494,15 @@ dependencies = [\n \"wasm-bindgen\",\n ]\n \n+[[package]]\n+name = \"winapi-util\"\n+version = \"0.1.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22\"\n+dependencies = [\n+ \"windows-sys 0.61.2\",\n+]\n+\n [[package]]\n name = \"windows-link\"\n version = \"0.2.1\"\n@@ -2040,7 +2684,7 @@ source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2\"\n dependencies = [\n \"anyhow\",\n- \"bitflags\",\n+ \"bitflags 2.11.1\",\n \"indexmap\",\n \"log\",\n \"serde\",\n@@ -2184,3 +2828,14 @@ name = \"zmij\"\n version = \"1.0.21\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa\"\n+\n+[[package]]\n+name = \"zstd-sys\"\n+version = \"2.0.16+zstd.1.5.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748\"\n+dependencies = [\n+ \"bindgen 0.72.1\",\n+ \"cc\",\n+ \"pkg-config\",\n+]\ndiff --git a/Cargo.toml b/Cargo.toml\nindex 156820ec5151b709a254dafc177da10488fee566..89f57d8943de978ef6c2dc038a5dfb89420c4283 100644\n--- a/Cargo.toml\n+++ b/Cargo.toml\n@@ -1,3 +1,3 @@\n [workspace]\n-members = [\"server\"]\n+members = [\"server\", \"durable\"]\n resolver = \"2\"\ndiff --git a/Dockerfile b/Dockerfile\nindex 38e4d595f2366b6a6e9405f1e12373b70fac79a1..517aa49ea2c01ba0d06a4647259ed917a0f6665c 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -3,7 +3,7 @@ FROM rust:1.88-slim AS builder\n WORKDIR /build\n \n RUN apt-get update && \\\n- apt-get install -y pkg-config libssl-dev && \\\n+ apt-get install -y pkg-config libssl-dev clang && \\\n rm -rf /var/lib/apt/lists/*\n \n COPY . .\ndiff --git a/durable/.gitignore b/durable/.gitignore\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..90c273ef12d6313594dde541c3465f2a47739729\n--- /dev/null\n+++ b/durable/.gitignore\n@@ -0,0 +1,35 @@\n+all.txt\n+\n+# Generated by Cargo\n+# will have compiled files and executables\n+debug/\n+target/\n+\n+# These are backup files generated by rustfmt\n+**/*.rs.bk\n+\n+# MSVC Windows builds of rustc generate these, which store debugging information\n+*.pdb\n+\n+# Generated by cargo mutants\n+# Contains mutation testing data\n+**/mutants.out*/\n+\n+# RustRover\n+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n+# and can be added to the global gitignore or merged into this file. For a more nuclear\n+# option (not recommended) you can uncomment the following to ignore the entire idea folder.\n+#.idea/\n+\n+\n+# Added by cargo\n+\n+/target\n+\n+\n+# Added by cargo\n+#\n+# already existing elements were commented out\n+\n+#/target\ndiff --git a/durable/Cargo.lock b/durable/Cargo.lock\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..99d59ec2166a1e88dc4867d316b3d5fe9bb0a1b6\n--- /dev/null\n+++ b/durable/Cargo.lock\n@@ -0,0 +1,1198 @@\n+# This file is automatically @generated by Cargo.\n+# It is not intended for manual editing.\n+version = 4\n+\n+[[package]]\n+name = \"aho-corasick\"\n+version = \"1.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916\"\n+dependencies = [\n+ \"memchr\",\n+]\n+\n+[[package]]\n+name = \"anes\"\n+version = \"0.1.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299\"\n+\n+[[package]]\n+name = \"anstyle\"\n+version = \"1.0.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd\"\n+\n+[[package]]\n+name = \"autocfg\"\n+version = \"1.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8\"\n+\n+[[package]]\n+name = \"bincode\"\n+version = \"1.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad\"\n+dependencies = [\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.65.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5\"\n+dependencies = [\n+ \"bitflags 1.3.2\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"lazy_static\",\n+ \"lazycell\",\n+ \"peeking_take_while\",\n+ \"prettyplease\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 1.1.0\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bindgen\"\n+version = \"0.71.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3\"\n+dependencies = [\n+ \"bitflags 2.9.1\",\n+ \"cexpr\",\n+ \"clang-sys\",\n+ \"itertools 0.13.0\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"regex\",\n+ \"rustc-hash 2.1.1\",\n+ \"shlex\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"bit-set\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3\"\n+dependencies = [\n+ \"bit-vec\",\n+]\n+\n+[[package]]\n+name = \"bit-vec\"\n+version = \"0.8.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7\"\n+\n+[[package]]\n+name = \"bitflags\"\n+version = \"1.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a\"\n+\n+[[package]]\n+name = \"bitflags\"\n+version = \"2.9.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967\"\n+\n+[[package]]\n+name = \"bumpalo\"\n+version = \"3.18.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee\"\n+\n+[[package]]\n+name = \"bzip2-sys\"\n+version = \"0.1.13+1.0.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+]\n+\n+[[package]]\n+name = \"cast\"\n+version = \"0.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5\"\n+\n+[[package]]\n+name = \"cc\"\n+version = \"1.2.27\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc\"\n+dependencies = [\n+ \"jobserver\",\n+ \"libc\",\n+ \"shlex\",\n+]\n+\n+[[package]]\n+name = \"cexpr\"\n+version = \"0.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766\"\n+dependencies = [\n+ \"nom\",\n+]\n+\n+[[package]]\n+name = \"cfg-if\"\n+version = \"1.0.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268\"\n+\n+[[package]]\n+name = \"ciborium\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"ciborium-ll\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"ciborium-io\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757\"\n+\n+[[package]]\n+name = \"ciborium-ll\"\n+version = \"0.2.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9\"\n+dependencies = [\n+ \"ciborium-io\",\n+ \"half\",\n+]\n+\n+[[package]]\n+name = \"clang-sys\"\n+version = \"1.8.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4\"\n+dependencies = [\n+ \"glob\",\n+ \"libc\",\n+ \"libloading\",\n+]\n+\n+[[package]]\n+name = \"clap\"\n+version = \"4.5.40\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f\"\n+dependencies = [\n+ \"clap_builder\",\n+]\n+\n+[[package]]\n+name = \"clap_builder\"\n+version = \"4.5.40\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e\"\n+dependencies = [\n+ \"anstyle\",\n+ \"clap_lex\",\n+]\n+\n+[[package]]\n+name = \"clap_lex\"\n+version = \"0.7.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675\"\n+\n+[[package]]\n+name = \"criterion\"\n+version = \"0.5.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f\"\n+dependencies = [\n+ \"anes\",\n+ \"cast\",\n+ \"ciborium\",\n+ \"clap\",\n+ \"criterion-plot\",\n+ \"is-terminal\",\n+ \"itertools 0.10.5\",\n+ \"num-traits\",\n+ \"once_cell\",\n+ \"oorandom\",\n+ \"plotters\",\n+ \"rayon\",\n+ \"regex\",\n+ \"serde\",\n+ \"serde_derive\",\n+ \"serde_json\",\n+ \"tinytemplate\",\n+ \"walkdir\",\n+]\n+\n+[[package]]\n+name = \"criterion-plot\"\n+version = \"0.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1\"\n+dependencies = [\n+ \"cast\",\n+ \"itertools 0.10.5\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-deque\"\n+version = \"0.8.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51\"\n+dependencies = [\n+ \"crossbeam-epoch\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-epoch\"\n+version = \"0.9.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e\"\n+dependencies = [\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"crossbeam-utils\"\n+version = \"0.8.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28\"\n+\n+[[package]]\n+name = \"crunchy\"\n+version = \"0.2.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929\"\n+\n+[[package]]\n+name = \"durable\"\n+version = \"0.1.0\"\n+dependencies = [\n+ \"bincode\",\n+ \"criterion\",\n+ \"proptest\",\n+ \"rocksdb\",\n+ \"serde\",\n+ \"tempfile\",\n+ \"thiserror\",\n+]\n+\n+[[package]]\n+name = \"either\"\n+version = \"1.15.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719\"\n+\n+[[package]]\n+name = \"errno\"\n+version = \"0.3.13\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad\"\n+dependencies = [\n+ \"libc\",\n+ \"windows-sys 0.60.2\",\n+]\n+\n+[[package]]\n+name = \"fastrand\"\n+version = \"2.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be\"\n+\n+[[package]]\n+name = \"fnv\"\n+version = \"1.0.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1\"\n+\n+[[package]]\n+name = \"getrandom\"\n+version = \"0.3.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"libc\",\n+ \"r-efi\",\n+ \"wasi\",\n+]\n+\n+[[package]]\n+name = \"glob\"\n+version = \"0.3.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2\"\n+\n+[[package]]\n+name = \"half\"\n+version = \"2.6.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"crunchy\",\n+]\n+\n+[[package]]\n+name = \"hermit-abi\"\n+version = \"0.5.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c\"\n+\n+[[package]]\n+name = \"is-terminal\"\n+version = \"0.4.16\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9\"\n+dependencies = [\n+ \"hermit-abi\",\n+ \"libc\",\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.10.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473\"\n+dependencies = [\n+ \"either\",\n+]\n+\n+[[package]]\n+name = \"itertools\"\n+version = \"0.13.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186\"\n+dependencies = [\n+ \"either\",\n+]\n+\n+[[package]]\n+name = \"itoa\"\n+version = \"1.0.15\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c\"\n+\n+[[package]]\n+name = \"jobserver\"\n+version = \"0.1.33\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a\"\n+dependencies = [\n+ \"getrandom\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"js-sys\"\n+version = \"0.3.77\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f\"\n+dependencies = [\n+ \"once_cell\",\n+ \"wasm-bindgen\",\n+]\n+\n+[[package]]\n+name = \"lazy_static\"\n+version = \"1.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe\"\n+\n+[[package]]\n+name = \"lazycell\"\n+version = \"1.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55\"\n+\n+[[package]]\n+name = \"libc\"\n+version = \"0.2.174\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776\"\n+\n+[[package]]\n+name = \"libloading\"\n+version = \"0.8.8\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"windows-targets 0.53.2\",\n+]\n+\n+[[package]]\n+name = \"librocksdb-sys\"\n+version = \"0.11.0+8.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3386f101bcb4bd252d8e9d2fb41ec3b0862a15a62b478c355b2982efa469e3e\"\n+dependencies = [\n+ \"bindgen 0.65.1\",\n+ \"bzip2-sys\",\n+ \"cc\",\n+ \"glob\",\n+ \"libc\",\n+ \"libz-sys\",\n+ \"lz4-sys\",\n+ \"zstd-sys\",\n+]\n+\n+[[package]]\n+name = \"libz-sys\"\n+version = \"1.1.22\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d\"\n+dependencies = [\n+ \"cc\",\n+ \"pkg-config\",\n+ \"vcpkg\",\n+]\n+\n+[[package]]\n+name = \"linux-raw-sys\"\n+version = \"0.9.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12\"\n+\n+[[package]]\n+name = \"log\"\n+version = \"0.4.27\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94\"\n+\n+[[package]]\n+name = \"lz4-sys\"\n+version = \"1.11.1+lz4-1.10.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6\"\n+dependencies = [\n+ \"cc\",\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"memchr\"\n+version = \"2.7.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0\"\n+\n+[[package]]\n+name = \"minimal-lexical\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a\"\n+\n+[[package]]\n+name = \"nom\"\n+version = \"7.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a\"\n+dependencies = [\n+ \"memchr\",\n+ \"minimal-lexical\",\n+]\n+\n+[[package]]\n+name = \"num-traits\"\n+version = \"0.2.19\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841\"\n+dependencies = [\n+ \"autocfg\",\n+]\n+\n+[[package]]\n+name = \"once_cell\"\n+version = \"1.21.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d\"\n+\n+[[package]]\n+name = \"oorandom\"\n+version = \"11.1.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e\"\n+\n+[[package]]\n+name = \"peeking_take_while\"\n+version = \"0.1.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099\"\n+\n+[[package]]\n+name = \"pkg-config\"\n+version = \"0.3.32\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c\"\n+\n+[[package]]\n+name = \"plotters\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747\"\n+dependencies = [\n+ \"num-traits\",\n+ \"plotters-backend\",\n+ \"plotters-svg\",\n+ \"wasm-bindgen\",\n+ \"web-sys\",\n+]\n+\n+[[package]]\n+name = \"plotters-backend\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a\"\n+\n+[[package]]\n+name = \"plotters-svg\"\n+version = \"0.3.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670\"\n+dependencies = [\n+ \"plotters-backend\",\n+]\n+\n+[[package]]\n+name = \"ppv-lite86\"\n+version = \"0.2.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9\"\n+dependencies = [\n+ \"zerocopy\",\n+]\n+\n+[[package]]\n+name = \"prettyplease\"\n+version = \"0.2.35\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"proc-macro2\"\n+version = \"1.0.95\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778\"\n+dependencies = [\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"proptest\"\n+version = \"1.7.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f\"\n+dependencies = [\n+ \"bit-set\",\n+ \"bit-vec\",\n+ \"bitflags 2.9.1\",\n+ \"lazy_static\",\n+ \"num-traits\",\n+ \"rand\",\n+ \"rand_chacha\",\n+ \"rand_xorshift\",\n+ \"regex-syntax\",\n+ \"rusty-fork\",\n+ \"tempfile\",\n+ \"unarray\",\n+]\n+\n+[[package]]\n+name = \"quick-error\"\n+version = \"1.2.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0\"\n+\n+[[package]]\n+name = \"quote\"\n+version = \"1.0.40\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d\"\n+dependencies = [\n+ \"proc-macro2\",\n+]\n+\n+[[package]]\n+name = \"r-efi\"\n+version = \"5.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f\"\n+\n+[[package]]\n+name = \"rand\"\n+version = \"0.9.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97\"\n+dependencies = [\n+ \"rand_chacha\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_chacha\"\n+version = \"0.9.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb\"\n+dependencies = [\n+ \"ppv-lite86\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_core\"\n+version = \"0.9.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38\"\n+dependencies = [\n+ \"getrandom\",\n+]\n+\n+[[package]]\n+name = \"rand_xorshift\"\n+version = \"0.4.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a\"\n+dependencies = [\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rayon\"\n+version = \"1.10.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa\"\n+dependencies = [\n+ \"either\",\n+ \"rayon-core\",\n+]\n+\n+[[package]]\n+name = \"rayon-core\"\n+version = \"1.12.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2\"\n+dependencies = [\n+ \"crossbeam-deque\",\n+ \"crossbeam-utils\",\n+]\n+\n+[[package]]\n+name = \"regex\"\n+version = \"1.11.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191\"\n+dependencies = [\n+ \"aho-corasick\",\n+ \"memchr\",\n+ \"regex-automata\",\n+ \"regex-syntax\",\n+]\n+\n+[[package]]\n+name = \"regex-automata\"\n+version = \"0.4.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908\"\n+dependencies = [\n+ \"aho-corasick\",\n+ \"memchr\",\n+ \"regex-syntax\",\n+]\n+\n+[[package]]\n+name = \"regex-syntax\"\n+version = \"0.8.5\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c\"\n+\n+[[package]]\n+name = \"rocksdb\"\n+version = \"0.21.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"bb6f170a4041d50a0ce04b0d2e14916d6ca863ea2e422689a5b694395d299ffe\"\n+dependencies = [\n+ \"libc\",\n+ \"librocksdb-sys\",\n+]\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"1.1.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2\"\n+\n+[[package]]\n+name = \"rustc-hash\"\n+version = \"2.1.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d\"\n+\n+[[package]]\n+name = \"rustix\"\n+version = \"1.0.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266\"\n+dependencies = [\n+ \"bitflags 2.9.1\",\n+ \"errno\",\n+ \"libc\",\n+ \"linux-raw-sys\",\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"rustversion\"\n+version = \"1.0.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d\"\n+\n+[[package]]\n+name = \"rusty-fork\"\n+version = \"0.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f\"\n+dependencies = [\n+ \"fnv\",\n+ \"quick-error\",\n+ \"tempfile\",\n+ \"wait-timeout\",\n+]\n+\n+[[package]]\n+name = \"ryu\"\n+version = \"1.0.20\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f\"\n+\n+[[package]]\n+name = \"same-file\"\n+version = \"1.0.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502\"\n+dependencies = [\n+ \"winapi-util\",\n+]\n+\n+[[package]]\n+name = \"serde\"\n+version = \"1.0.219\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6\"\n+dependencies = [\n+ \"serde_derive\",\n+]\n+\n+[[package]]\n+name = \"serde_derive\"\n+version = \"1.0.219\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"serde_json\"\n+version = \"1.0.140\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373\"\n+dependencies = [\n+ \"itoa\",\n+ \"memchr\",\n+ \"ryu\",\n+ \"serde\",\n+]\n+\n+[[package]]\n+name = \"shlex\"\n+version = \"1.3.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64\"\n+\n+[[package]]\n+name = \"syn\"\n+version = \"2.0.104\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"tempfile\"\n+version = \"3.20.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1\"\n+dependencies = [\n+ \"fastrand\",\n+ \"getrandom\",\n+ \"once_cell\",\n+ \"rustix\",\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"thiserror\"\n+version = \"1.0.69\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52\"\n+dependencies = [\n+ \"thiserror-impl\",\n+]\n+\n+[[package]]\n+name = \"thiserror-impl\"\n+version = \"1.0.69\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"tinytemplate\"\n+version = \"1.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc\"\n+dependencies = [\n+ \"serde\",\n+ \"serde_json\",\n+]\n+\n+[[package]]\n+name = \"unarray\"\n+version = \"0.1.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94\"\n+\n+[[package]]\n+name = \"unicode-ident\"\n+version = \"1.0.18\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512\"\n+\n+[[package]]\n+name = \"vcpkg\"\n+version = \"0.2.15\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426\"\n+\n+[[package]]\n+name = \"wait-timeout\"\n+version = \"0.2.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11\"\n+dependencies = [\n+ \"libc\",\n+]\n+\n+[[package]]\n+name = \"walkdir\"\n+version = \"2.5.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b\"\n+dependencies = [\n+ \"same-file\",\n+ \"winapi-util\",\n+]\n+\n+[[package]]\n+name = \"wasi\"\n+version = \"0.14.2+wasi-0.2.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3\"\n+dependencies = [\n+ \"wit-bindgen-rt\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5\"\n+dependencies = [\n+ \"cfg-if\",\n+ \"once_cell\",\n+ \"rustversion\",\n+ \"wasm-bindgen-macro\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-backend\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6\"\n+dependencies = [\n+ \"bumpalo\",\n+ \"log\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+ \"wasm-bindgen-shared\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-macro\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407\"\n+dependencies = [\n+ \"quote\",\n+ \"wasm-bindgen-macro-support\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-macro-support\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+ \"wasm-bindgen-backend\",\n+ \"wasm-bindgen-shared\",\n+]\n+\n+[[package]]\n+name = \"wasm-bindgen-shared\"\n+version = \"0.2.100\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d\"\n+dependencies = [\n+ \"unicode-ident\",\n+]\n+\n+[[package]]\n+name = \"web-sys\"\n+version = \"0.3.77\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2\"\n+dependencies = [\n+ \"js-sys\",\n+ \"wasm-bindgen\",\n+]\n+\n+[[package]]\n+name = \"winapi-util\"\n+version = \"0.1.9\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb\"\n+dependencies = [\n+ \"windows-sys 0.59.0\",\n+]\n+\n+[[package]]\n+name = \"windows-sys\"\n+version = \"0.59.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b\"\n+dependencies = [\n+ \"windows-targets 0.52.6\",\n+]\n+\n+[[package]]\n+name = \"windows-sys\"\n+version = \"0.60.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb\"\n+dependencies = [\n+ \"windows-targets 0.53.2\",\n+]\n+\n+[[package]]\n+name = \"windows-targets\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973\"\n+dependencies = [\n+ \"windows_aarch64_gnullvm 0.52.6\",\n+ \"windows_aarch64_msvc 0.52.6\",\n+ \"windows_i686_gnu 0.52.6\",\n+ \"windows_i686_gnullvm 0.52.6\",\n+ \"windows_i686_msvc 0.52.6\",\n+ \"windows_x86_64_gnu 0.52.6\",\n+ \"windows_x86_64_gnullvm 0.52.6\",\n+ \"windows_x86_64_msvc 0.52.6\",\n+]\n+\n+[[package]]\n+name = \"windows-targets\"\n+version = \"0.53.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef\"\n+dependencies = [\n+ \"windows_aarch64_gnullvm 0.53.0\",\n+ \"windows_aarch64_msvc 0.53.0\",\n+ \"windows_i686_gnu 0.53.0\",\n+ \"windows_i686_gnullvm 0.53.0\",\n+ \"windows_i686_msvc 0.53.0\",\n+ \"windows_x86_64_gnu 0.53.0\",\n+ \"windows_x86_64_gnullvm 0.53.0\",\n+ \"windows_x86_64_msvc 0.53.0\",\n+]\n+\n+[[package]]\n+name = \"windows_aarch64_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3\"\n+\n+[[package]]\n+name = \"windows_aarch64_gnullvm\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764\"\n+\n+[[package]]\n+name = \"windows_aarch64_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469\"\n+\n+[[package]]\n+name = \"windows_aarch64_msvc\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c\"\n+\n+[[package]]\n+name = \"windows_i686_gnu\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b\"\n+\n+[[package]]\n+name = \"windows_i686_gnu\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3\"\n+\n+[[package]]\n+name = \"windows_i686_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66\"\n+\n+[[package]]\n+name = \"windows_i686_gnullvm\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11\"\n+\n+[[package]]\n+name = \"windows_i686_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66\"\n+\n+[[package]]\n+name = \"windows_i686_msvc\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnu\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnu\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnullvm\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d\"\n+\n+[[package]]\n+name = \"windows_x86_64_gnullvm\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57\"\n+\n+[[package]]\n+name = \"windows_x86_64_msvc\"\n+version = \"0.52.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec\"\n+\n+[[package]]\n+name = \"windows_x86_64_msvc\"\n+version = \"0.53.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486\"\n+\n+[[package]]\n+name = \"wit-bindgen-rt\"\n+version = \"0.39.0\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1\"\n+dependencies = [\n+ \"bitflags 2.9.1\",\n+]\n+\n+[[package]]\n+name = \"zerocopy\"\n+version = \"0.8.26\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f\"\n+dependencies = [\n+ \"zerocopy-derive\",\n+]\n+\n+[[package]]\n+name = \"zerocopy-derive\"\n+version = \"0.8.26\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n+[[package]]\n+name = \"zstd-sys\"\n+version = \"2.0.15+zstd.1.5.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237\"\n+dependencies = [\n+ \"bindgen 0.71.1\",\n+ \"cc\",\n+ \"pkg-config\",\n+]\ndiff --git a/durable/Cargo.toml b/durable/Cargo.toml\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..907e79cd894446e2ac76c8240b86c2927103727b\n--- /dev/null\n+++ b/durable/Cargo.toml\n@@ -0,0 +1,18 @@\n+[package]\n+name = \"durable\"\n+version = \"0.1.0\"\n+edition = \"2021\"\n+authors = [\"Durable Contributors\"]\n+description = \"RocksDB-backed persistent data structures for Rust\"\n+license = \"MIT OR Apache-2.0\"\n+\n+[dependencies]\n+rocksdb = \"0.21\"\n+serde = { version = \"1.0\", features = [\"derive\"] }\n+bincode = \"1.3\"\n+thiserror = \"1.0\"\n+\n+[dev-dependencies]\n+tempfile = \"3.8\"\n+criterion = \"0.5\"\n+proptest = \"1.4\"\ndiff --git a/durable/LICENSE b/durable/LICENSE\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..261eeb9e9f8b2b4b0d119366dda99c6fd7d35c64\n--- /dev/null\n+++ b/durable/LICENSE\n@@ -0,0 +1,201 @@\n+ Apache License\n+ Version 2.0, January 2004\n+ http://www.apache.org/licenses/\n+\n+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n+\n+ 1. Definitions.\n+\n+ \"License\" shall mean the terms and conditions for use, reproduction,\n+ and distribution as defined by Sections 1 through 9 of this document.\n+\n+ \"Licensor\" shall mean the copyright owner or entity authorized by\n+ the copyright owner that is granting the License.\n+\n+ \"Legal Entity\" shall mean the union of the acting entity and all\n+ other entities that control, are controlled by, or are under common\n+ control with that entity. For the purposes of this definition,\n+ \"control\" means (i) the power, direct or indirect, to cause the\n+ direction or management of such entity, whether by contract or\n+ otherwise, or (ii) ownership of fifty percent (50%) or more of the\n+ outstanding shares, or (iii) beneficial ownership of such entity.\n+\n+ \"You\" (or \"Your\") shall mean an individual or Legal Entity\n+ exercising permissions granted by this License.\n+\n+ \"Source\" form shall mean the preferred form for making modifications,\n+ including but not limited to software source code, documentation\n+ source, and configuration files.\n+\n+ \"Object\" form shall mean any form resulting from mechanical\n+ transformation or translation of a Source form, including but\n+ not limited to compiled object code, generated documentation,\n+ and conversions to other media types.\n+\n+ \"Work\" shall mean the work of authorship, whether in Source or\n+ Object form, made available under the License, as indicated by a\n+ copyright notice that is included in or attached to the work\n+ (an example is provided in the Appendix below).\n+\n+ \"Derivative Works\" shall mean any work, whether in Source or Object\n+ form, that is based on (or derived from) the Work and for which the\n+ editorial revisions, annotations, elaborations, or other modifications\n+ represent, as a whole, an original work of authorship. For the purposes\n+ of this License, Derivative Works shall not include works that remain\n+ separable from, or merely link (or bind by name) to the interfaces of,\n+ the Work and Derivative Works thereof.\n+\n+ \"Contribution\" shall mean any work of authorship, including\n+ the original version of the Work and any modifications or additions\n+ to that Work or Derivative Works thereof, that is intentionally\n+ submitted to Licensor for inclusion in the Work by the copyright owner\n+ or by an individual or Legal Entity authorized to submit on behalf of\n+ the copyright owner. For the purposes of this definition, \"submitted\"\n+ means any form of electronic, verbal, or written communication sent\n+ to the Licensor or its representatives, including but not limited to\n+ communication on electronic mailing lists, source code control systems,\n+ and issue tracking systems that are managed by, or on behalf of, the\n+ Licensor for the purpose of discussing and improving the Work, but\n+ excluding communication that is conspicuously marked or otherwise\n+ designated in writing by the copyright owner as \"Not a Contribution.\"\n+\n+ \"Contributor\" shall mean Licensor and any individual or Legal Entity\n+ on behalf of whom a Contribution has been received by Licensor and\n+ subsequently incorporated within the Work.\n+\n+ 2. Grant of Copyright License. Subject to the terms and conditions of\n+ this License, each Contributor hereby grants to You a perpetual,\n+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n+ copyright license to reproduce, prepare Derivative Works of,\n+ publicly display, publicly perform, sublicense, and distribute the\n+ Work and such Derivative Works in Source or Object form.\n+\n+ 3. Grant of Patent License. Subject to the terms and conditions of\n+ this License, each Contributor hereby grants to You a perpetual,\n+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n+ (except as stated in this section) patent license to make, have made,\n+ use, offer to sell, sell, import, and otherwise transfer the Work,\n+ where such license applies only to those patent claims licensable\n+ by such Contributor that are necessarily infringed by their\n+ Contribution(s) alone or by combination of their Contribution(s)\n+ with the Work to which such Contribution(s) was submitted. If You\n+ institute patent litigation against any entity (including a\n+ cross-claim or counterclaim in a lawsuit) alleging that the Work\n+ or a Contribution incorporated within the Work constitutes direct\n+ or contributory patent infringement, then any patent licenses\n+ granted to You under this License for that Work shall terminate\n+ as of the date such litigation is filed.\n+\n+ 4. Redistribution. You may reproduce and distribute copies of the\n+ Work or Derivative Works thereof in any medium, with or without\n+ modifications, and in Source or Object form, provided that You\n+ meet the following conditions:\n+\n+ (a) You must give any other recipients of the Work or\n+ Derivative Works a copy of this License; and\n+\n+ (b) You must cause any modified files to carry prominent notices\n+ stating that You changed the files; and\n+\n+ (c) You must retain, in the Source form of any Derivative Works\n+ that You distribute, all copyright, patent, trademark, and\n+ attribution notices from the Source form of the Work,\n+ excluding those notices that do not pertain to any part of\n+ the Derivative Works; and\n+\n+ (d) If the Work includes a \"NOTICE\" text file as part of its\n+ distribution, then any Derivative Works that You distribute must\n+ include a readable copy of the attribution notices contained\n+ within such NOTICE file, excluding those notices that do not\n+ pertain to any part of the Derivative Works, in at least one\n+ of the following places: within a NOTICE text file distributed\n+ as part of the Derivative Works; within the Source form or\n+ documentation, if provided along with the Derivative Works; or,\n+ within a display generated by the Derivative Works, if and\n+ wherever such third-party notices normally appear. The contents\n+ of the NOTICE file are for informational purposes only and\n+ do not modify the License. You may add Your own attribution\n+ notices within Derivative Works that You distribute, alongside\n+ or as an addendum to the NOTICE text from the Work, provided\n+ that such additional attribution notices cannot be construed\n+ as modifying the License.\n+\n+ You may add Your own copyright statement to Your modifications and\n+ may provide additional or different license terms and conditions\n+ for use, reproduction, or distribution of Your modifications, or\n+ for any such Derivative Works as a whole, provided Your use,\n+ reproduction, and distribution of the Work otherwise complies with\n+ the conditions stated in this License.\n+\n+ 5. Submission of Contributions. Unless You explicitly state otherwise,\n+ any Contribution intentionally submitted for inclusion in the Work\n+ by You to the Licensor shall be under the terms and conditions of\n+ this License, without any additional terms or conditions.\n+ Notwithstanding the above, nothing herein shall supersede or modify\n+ the terms of any separate license agreement you may have executed\n+ with Licensor regarding such Contributions.\n+\n+ 6. Trademarks. This License does not grant permission to use the trade\n+ names, trademarks, service marks, or product names of the Licensor,\n+ except as required for reasonable and customary use in describing the\n+ origin of the Work and reproducing the content of the NOTICE file.\n+\n+ 7. Disclaimer of Warranty. Unless required by applicable law or\n+ agreed to in writing, Licensor provides the Work (and each\n+ Contributor provides its Contributions) on an \"AS IS\" BASIS,\n+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n+ implied, including, without limitation, any warranties or conditions\n+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n+ PARTICULAR PURPOSE. You are solely responsible for determining the\n+ appropriateness of using or redistributing the Work and assume any\n+ risks associated with Your exercise of permissions under this License.\n+\n+ 8. Limitation of Liability. In no event and under no legal theory,\n+ whether in tort (including negligence), contract, or otherwise,\n+ unless required by applicable law (such as deliberate and grossly\n+ negligent acts) or agreed to in writing, shall any Contributor be\n+ liable to You for damages, including any direct, indirect, special,\n+ incidental, or consequential damages of any character arising as a\n+ result of this License or out of the use or inability to use the\n+ Work (including but not limited to damages for loss of goodwill,\n+ work stoppage, computer failure or malfunction, or any and all\n+ other commercial damages or losses), even if such Contributor\n+ has been advised of the possibility of such damages.\n+\n+ 9. Accepting Warranty or Additional Liability. While redistributing\n+ the Work or Derivative Works thereof, You may choose to offer,\n+ and charge a fee for, acceptance of support, warranty, indemnity,\n+ or other liability obligations and/or rights consistent with this\n+ License. However, in accepting such obligations, You may act only\n+ on Your own behalf and on Your sole responsibility, not on behalf\n+ of any other Contributor, and only if You agree to indemnify,\n+ defend, and hold each Contributor harmless for any liability\n+ incurred by, or claims asserted against, such Contributor by reason\n+ of your accepting any such warranty or additional liability.\n+\n+ END OF TERMS AND CONDITIONS\n+\n+ APPENDIX: How to apply the Apache License to your work.\n+\n+ To apply the Apache License to your work, attach the following\n+ boilerplate notice, with the fields enclosed by brackets \"[]\"\n+ replaced with your own identifying information. (Don't include\n+ the brackets!) The text should be enclosed in the appropriate\n+ comment syntax for the file format. We also recommend that a\n+ file or class name and description of purpose be included on the\n+ same \"printed page\" as the copyright notice for easier\n+ identification within third-party archives.\n+\n+ Copyright [yyyy] [name of copyright owner]\n+\n+ Licensed under the Apache License, Version 2.0 (the \"License\");\n+ you may not use this file except in compliance with the License.\n+ You may obtain a copy of the License at\n+\n+ http://www.apache.org/licenses/LICENSE-2.0\n+\n+ Unless required by applicable law or agreed to in writing, software\n+ distributed under the License is distributed on an \"AS IS\" BASIS,\n+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n+ See the License for the specific language governing permissions and\n+ limitations under the License.\ndiff --git a/durable/README.md b/durable/README.md\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..46f3b163a41d1094334ea5dc4e8add7b627f16e2\n--- /dev/null\n+++ b/durable/README.md\n@@ -0,0 +1,207 @@\n+# Durable\n+\n+RocksDB-backed persistent data structures for Rust. Think `std::collections` but on disk!\n+\n+## Features\n+\n+- **Persistent Collections**: `DurableVec`, `DurableMap`, `DurableSet` (coming soon)\n+- **Type-Safe**: Full Rust type safety with serde serialization\n+- **ACID Guarantees**: All operations are atomic and crash-safe\n+- **Zero-Copy Capable**: Efficient iteration without loading entire collections\n+- **Embedded**: No external services required - just a directory on disk\n+\n+## Quick Start\n+\n+Add to your `Cargo.toml`:\n+\n+```toml\n+[dependencies]\n+durable = \"0.1.0\"\n+```\n+\n+## Example\n+\n+### DurableVec\n+```rust\n+use durable::{Db, DurableVec};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Serialize, Deserialize)]\n+struct Task {\n+ id: u64,\n+ title: String,\n+ completed: bool,\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"my_db\")?;\n+ \n+ // Create a persistent vector\n+ let mut tasks = DurableVec::::new(&db, \"tasks\")?;\n+ \n+ // Use it like a normal Vec!\n+ tasks.push(Task {\n+ id: 1,\n+ title: \"Build something amazing\".to_string(),\n+ completed: false,\n+ })?;\n+ \n+ // Data persists across program restarts\n+ println!(\"Total tasks: {}\", tasks.len()?);\n+ \n+ Ok(())\n+}\n+```\n+\n+### DurableMap\n+```rust\n+use durable::{Db, DurableMap};\n+\n+fn main() -> Result<(), Box> {\n+ let db = Db::open(\"my_db\")?;\n+ \n+ // Create a persistent map\n+ let mut scores = DurableMap::::new(&db, \"scores\")?;\n+ \n+ // Use it like a HashMap!\n+ // Use put() when you don't need the old value (more efficient)\n+ scores.put(\"Alice\".to_string(), 100)?;\n+ scores.put(\"Bob\".to_string(), 85)?;\n+ \n+ // Use insert() when you need to know the old value\n+ if let Some(old_score) = scores.insert(\"Alice\".to_string(), 120)? {\n+ println!(\"Alice's previous score was: {}\", old_score);\n+ }\n+ \n+ // Get values\n+ if let Some(score) = scores.get(&\"Alice\".to_string())? {\n+ println!(\"Alice's score: {}\", score);\n+ }\n+ \n+ // Iterate over entries\n+ for (name, score) in scores.iter()? {\n+ println!(\"{}: {}\", name, score);\n+ }\n+ \n+ Ok(())\n+}\n+```\n+\n+### Nested Collections\n+\n+Durable supports nesting collections within each other for complex data structures:\n+\n+```rust\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ let db = Db::open(\"my_db\")?;\n+ \n+ // Create a map where each user has a list of posts\n+ let user_posts: DurableMap> = \n+ DurableMap::new_nested(&db, \"user_posts\");\n+ \n+ // Add posts for a user\n+ let mut alice_posts = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ alice_posts.push(\"Hello, world!\".to_string())?;\n+ alice_posts.push(\"Rust is awesome!\".to_string())?;\n+ \n+ // Or use chained calls for convenience\n+ user_posts.entry(\"bob\".to_string())?.or_default()?.push(\"First post!\".to_string())?;\n+ \n+ // Access nested data\n+ let alice_posts = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ println!(\"Alice has {} posts\", alice_posts.len()?);\n+ \n+ Ok(())\n+}\n+```\n+\n+The entry API automatically creates nested collections when they don't exist, providing ergonomic access patterns similar to `std::collections::HashMap::entry().or_default()`.\n+\n+## Current Status\n+\n+### Implemented\n+\n+- ✅ `DurableVec` with full test coverage including:\n+ - Basic operations: `push`, `pop`, `get`, `len`, `clear`\n+ - Batch operations: `extend`\n+ - Iteration: `iter()` returns a streaming iterator, `to_vec()` loads into memory\n+ - Property-based testing with proptest\n+ - Unicode string support\n+ - Complex type support\n+\n+- ✅ `DurableMap` with full test coverage including:\n+ - Basic operations: `insert`, `put`, `get`, `remove`, `contains_key`, `len`, `clear`\n+ - Batch operations: `extend`\n+ - Iteration: `iter()`, `keys()`, `values()` return streaming iterators\n+ - Memory loading: `to_vec()`, `keys_vec()`, `values_vec()` for convenience\n+ - Complex key and value types\n+ - Property-based testing with proptest\n+\n+- ✅ **Nested Collections** with entry API:\n+ - `DurableMap>` - Maps to vectors\n+ - `entry()` method with `or_default()` for ergonomic access\n+ - Automatic collection creation and management\n+ - Full persistence and isolation between nested collections\n+ - Type-safe compile-time enforcement\n+\n+### Coming Soon\n+\n+- 🚧 `DurableSet` - Persistent HashSet \n+- 🚧 Deep nesting (e.g., `DurableMap>>`)\n+- 🚧 Schema migration support\n+- 🚧 Batch operations across multiple collections\n+\n+## Performance\n+\n+All operations are designed to be efficient:\n+\n+- **DurableVec**:\n+ - `push`: Single atomic write with WAL flush\n+ - `get`: Direct key lookup, O(1) \n+ - `len`: Metadata lookup, O(1)\n+ - `extend`: Batched writes for efficiency\n+ - `clear`: Atomic batch deletion\n+\n+- **DurableMap**:\n+ - `insert`: Returns old value (2 ops: get + put), O(1) average\n+ - `put`: No return value (1 op: existence check + put), O(1) average\n+ - `get`: Direct key lookup, O(1) average\n+ - `remove`: Single delete with WAL flush\n+ - `len`: Metadata lookup, O(1)\n+ - `extend`: Batched writes for efficiency\n+\n+## Testing\n+\n+Run the test suite:\n+\n+```bash\n+cargo test\n+```\n+\n+Run the examples:\n+\n+```bash\n+cargo run --example vec_example\n+cargo run --example map_example\n+cargo run --example combined_example # Shows both collections working together\n+cargo run --example streaming_demo # Demonstrates efficient streaming iteration\n+cargo run --example nested_example # Shows nested collections (Map -> Vec)\n+cargo run --example simple_ranking # Gaming leaderboard from docs/motivation.md\n+cargo run --example ranking_history # Complex ranking system with persistence\n+```\n+\n+## License\n+\n+Licensed under either of:\n+\n+- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)\n+- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)\n+\n+at your option.\n+\n+## Contributing\n+\n+Contributions are welcome! Please feel free to submit a Pull Request.\ndiff --git a/durable/docs/001.md b/durable/docs/001.md\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3ad7cf9d93572de9ded367f9ce6dcc28608bba39\n--- /dev/null\n+++ b/durable/docs/001.md\n@@ -0,0 +1,272 @@\n+## Durable — RocksDB-backed Persistent Data Structures for Rust\n+\n+### *draft RFC v0.1*\n+\n+---\n+\n+### 1  Purpose\n+\n+Provide ergonomic, std-like collections (`DurableMap`, `DurableVec`, `DurableSet`, …) whose contents are **durably stored in RocksDB** yet feel in-memory:\n+\n+```rust\n+let db = durable::open(\"db\"); // single call opens RocksDB\n+let mut posts = DurableMap::::new(&db, \"posts\")?;\n+\n+posts.insert(id, post)?; // ACID write\n+let p = posts.get(&id)?; // typed read\n+```\n+\n+Target use cases:\n+\n+| Domain | Why durable? |\n+| ------------------------------ | -------------------------------- |\n+| Local-first apps / CRDT caches | crash-safe, embedded |\n+| High-write time-series blobs | append-only keys, fast iteration |\n+| ML / ranking histories | vector-within-vector patterns |\n+| Game state / async board games | snapshot + rollback |\n+| Sorter (tag → bucket → item) | exactly the motivating structure |\n+\n+---\n+\n+### 2  Design Goals\n+\n+1. **Ergonomic** – Feel like `std::collections`; no manual key mangling.\n+2. **Typed** – Keys & values are generic; (de)serialization pluggable (default = `bincode`).\n+3. **Atomic** – Multi-op `batch.commit()` gives RocksDB write-batch semantics.\n+4. **Crash-safe** – Every public write path `fsync`s RocksDB WAL first.\n+5. **Composable** – Any collection may nest another via *prefix subspaces*.\n+6. **Zero external services** – Single embedded `.sst` directory.\n+7. **Opt-in features** – Reactive diffs, LRU cache, metrics behind feature flags.\n+\n+Non-goals (v0 line):\n+\n+* Distributed replication\n+* Multi-process concurrency (one writer process is assumed; readers may open secondary Rocks instances)\n+* SQL-style ad-hoc queries – you iterate, not query-plan.\n+\n+---\n+\n+### 3  Key–Value Layout\n+\n+*Each collection owns a **prefix** inside a single Column Family (`default` by default).*\n+\n+```\n+ 0x00 0x00 [ 0x00 …]\n+```\n+\n+* `user-prefix` = crate-level namespace (allows multi-tenant apps)\n+* `col_id` = 8-byte, little-endian numeric ID assigned on `DurableMap::new(&db,\"posts\")`.\n+* `logical-key` = `serde`-encoded key (or ordinal for Vec).\n+* `subindex` = extra path segments used by nested collections (e.g., `Vec` elements in a map value).\n+\n+Because RocksDB stores keys lexicographically, all elements of a collection (and its descendants) live contiguously → range scans & prefix deletes are cheap.\n+\n+---\n+\n+### 4  Public API (surface)\n+\n+```rust\n+/// Opens (or creates) a durable database at `path`.\n+pub fn open>(path: P) -> Result;\n+\n+/// A transactional write batch\n+pub struct Batch<'db> { /* .. */ }\n+\n+impl<'db> Batch<'db> {\n+ pub fn put(&mut self, col: &impl WriteCollection, key: &K, val: &V) -> Result<()>;\n+ pub fn delete(&mut self, col: &impl WriteCollection, key: &K) -> Result<()>;\n+ pub fn commit(self) -> Result<()>;\n+}\n+\n+/// Collections ------------------------------------------------------------\n+\n+pub struct DurableMap<'db, K, V> { /* .. */ }\n+pub struct DurableVec<'db, T> { /* .. */ }\n+pub struct DurableSet<'db, T> { /* .. */ }\n+pub struct DurableIndex<'db, K, V> { /* sorted-map, range queries */ }\n+\n+/// Common read API\n+pub trait ReadCollection {\n+ fn get(&self, k: &K) -> Result>;\n+ fn contains_key(&self, k: &K) -> Result;\n+ fn len(&self) -> Result;\n+ fn iter(&self) -> Iter<'_, (K,V)>; // owns snapshot\n+}\n+\n+/// Common write API (auto batch or explicit)\n+pub trait WriteCollection: ReadCollection {\n+ fn insert(&mut self, k: K, v: V) -> Result>;\n+ fn remove(&mut self, k: &K) -> Result>;\n+ fn clear(&mut self) -> Result<()>;\n+}\n+```\n+\n+*All writes go through an internal `rocksdb::WriteBatch`* so a single logical op remains atomic even if it touches subkeys (e.g., pushing into a `DurableVec` updates `len` meta key + appends element).\n+\n+---\n+\n+### 5  Collection Semantics\n+\n+#### 5.1 `DurableVec`\n+\n+* Meta-key `__len` stores current length (`u64`).\n+* Element key = `|`\n+* `push(elem)` = `batch.put(key(len), elem); batch.put(__len, len+1)`\n+ – O(1) write, O(log N) read via prefix seek.\n+* `iter()` performs a prefix range; snapshot guarantees repeat-read.\n+\n+#### 5.2 `DurableMap`\n+\n+* Key = `|`\n+* `len` optional (feature `\"size_tracking\"`); otherwise O(prefix-scan).\n+\n+#### 5.3 Nested Collections\n+\n+```rust\n+let users = DurableMap::>::new(&db,\"users\")?;\n+users.entry(\"alice\")?.or_default()?.push(order)?;\n+```\n+\n+Internally `Entry::or_default` creates a *child prefix* off the parent’s key:\n+`|\"alice\"|0x00||…`\n+\n+Child collections store their own metadata keys beneath that path.\n+\n+---\n+\n+### 6  Transactions & Consistency\n+\n+* **Auto-batch**: default mutator methods create a WriteBatch, commit, and flush WAL.\n+* **Explicit batch**: user opens `let mut wb = db.batch();`, issues puts/deletes via collection adapters, then `wb.commit()` for cross-collection atomicity.\n+* **Crash guarantee**: after `commit` returns, updates survive power loss (`rocksdb::DB::flush_wal(true)`).\n+\n+Read operations take a **consistent snapshot** by default; advanced users can opt-out for max throughput.\n+\n+---\n+\n+### 7  Migrations (v0.2 roadmap)\n+\n+* Each collection stores a `u32 schema_version` meta key.\n+* `durable::open` accepts an optional `Schema` describing:\n+\n+ ```rust\n+ struct Schema { collections: Vec, version: u32 }\n+ ```\n+\n+ If version mismatch ⇒ run user-supplied `migrate(old, new, &db)` which gets a mutable view and may batch-rewrite keys.\n+\n+---\n+\n+### 8  Reactive Diffs (feature `\"watch\"`)\n+\n+* Behind a Tokio-aware feature; uses RocksDB’s `get_updates_since(seq)` API.\n+* `watch_prefix(prefix) -> impl Stream`\n+ – `Diff` = key, old val (Option), new val (Option).\n+* Back-pressure handled with an in-process ring buffer; user chooses lag policy.\n+\n+---\n+\n+### 9  Caching Layer (feature `\"cache\"`)\n+\n+* Probabilistic LRU over deserialized values.\n+* Configurable per-collection: `with_cache(cap_entries, ttl_ms)`.\n+* Coherent: write path invalidates cache keys on commit.\n+\n+---\n+\n+### 10  Error Model\n+\n+```rust\n+#[non_exhaustive]\n+pub enum Error {\n+ Rocks(rocksdb::Error),\n+ Serde(bincode::Error),\n+ Corruption(String),\n+ TransactionAborted,\n+ // feature-gated variants e.g. WatchLagged\n+}\n+```\n+\n+`Result = std::result::Result` everywhere.\n+\n+---\n+\n+### 11  Performance Budget (baseline targets)\n+\n+| Operation | Goal |\n+| -------------------- | -------------------------------------- |\n+| `map.insert` | < 30 µs (including WAL fsync) |\n+| `vec.push` | < 25 µs |\n+| Prefix iteration 1 M | > 120 MB/s read BW on NVMe |\n+| Concurrent readers | Linear scaling up to RocksDB read-IOPS |\n+| Watch latency | < 5 ms p50 on local SSD |\n+\n+(bench harness lives in `benches/` via Criterion.)\n+\n+---\n+\n+### 12  Dependency Footprint\n+\n+* `rocksdb` (–> FB fork) ⟹ builds C++11 static lib (\\~10 MB)\n+* `bincode` (default), with `serde` feature.\n+* `tokio-stream` only if `watch` feature enabled.\n+\n+MSRV = 1.76.\n+\n+---\n+\n+### 13  Minimum Deliverable for **v0.1-alpha**\n+\n+* [ ] `Db::open`, `Db::batch`\n+* [ ] `DurableMap`, `DurableVec`\n+* [ ] automatic serialization via `serde`\n+* [ ] atomic commit + WAL flush\n+* [ ] snapshot reads\n+* [ ] unit tests (insert/get/iter/crash-recovery using `tempdir`)\n+* [ ] criterion bench\n+\n+---\n+\n+### 14  Future Work\n+\n+* **Replicated mode** (Raft or FoundationDB layer)\n+* **CRDT merge semantics** for offline edits\n+* **DurableGraph** (adjacency lists + index)\n+* WebAssembly key-value adapters (edge workers)\n+* `tracing` instrumentation & Prometheus metrics\n+\n+---\n+\n+### 15  Licensing & Governance\n+\n+* License: **Apache-2.0 OR MIT** (standard Rust dual license)\n+* Code-of-conduct: Rust CoC template\n+* Contribution model: PR + mandatory CI (fmt, clippy, test, bench)\n+* Early roadmap guided by original author(s); transfer to an org when ≥3 maintainers.\n+\n+---\n+\n+## Appendix A — Sorter Use-Case Sketch\n+\n+```rust\n+type TagId = String;\n+type Bucket = u64; // logical Unix day or version #\n+type ItemId = String;\n+type Elo = i32;\n+\n+let hist = DurableMap::>>::new(&db,\"hist\")?;\n+\n+// update elo\n+hist.entry(\"tf2\")?\n+ .or_default()?\n+ .entry(today_bucket)?\n+ .or_default()?\n+ .push((item_id, new_elo))?;\n+\n+// stream bucket\n+let items = hist.get(\"tf2\")?.unwrap()\n+ .get(&today_bucket)?.unwrap()\n+ .iter().collect::>();\n+```\n+\n+All three layers share one RocksDB instance; you pay one WAL flush per ranking update, but reads are prefix-scans with zero allocations.\ndiff --git a/durable/docs/motivation.md b/durable/docs/motivation.md\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..50d8ee5ef9f384d908e3a86f6945e3f4ced58292\n--- /dev/null\n+++ b/durable/docs/motivation.md\n@@ -0,0 +1,248 @@\n+# Why Durable? The Missing Abstraction Layer for Persistent Storage\n+\n+## The Problem: The Abstraction Gap\n+\n+Every database forces developers to translate between how they **think** about data and how they **store** it. This translation layer is where bugs hide, performance suffers, and development slows down.\n+\n+### Example: Building a Multiplayer Game Leaderboard\n+\n+Let's say you need to store player rankings by game mode, with history by day. Here's the data model in your head:\n+\n+```\n+Game Mode → Day → List of (Player, Score)\n+```\n+\n+#### With Raw Key-Value Stores (sled, RocksDB)\n+\n+```rust\n+// Storing a score requires manual key construction\n+let key = format!(\"leaderboard:{}:{}:player:{}\", game_mode, day, player_id);\n+db.insert(key.as_bytes(), score.to_le_bytes())?;\n+\n+// Getting today's leaderboard? Manual prefix scan and deserialization\n+let prefix = format!(\"leaderboard:{}:{}:\", game_mode, day);\n+let mut scores = Vec::new();\n+for item in db.scan_prefix(prefix.as_bytes()) {\n+ let (key, value) = item?;\n+ // Parse player_id from key string... hope the format is right\n+ // Deserialize score... hope it's the right type\n+ scores.push((player_id, score));\n+}\n+scores.sort_by_key(|(_, s)| *s);\n+\n+// Want to know how many players played today? Another scan!\n+// Want to atomically update multiple scores? Write a transaction wrapper!\n+// Want to clean up old days? Manual prefix iteration and deletion!\n+```\n+\n+**Problems:**\n+- String manipulation for every operation\n+- No type safety (everything is bytes)\n+- Manual implementation of collection semantics\n+- No atomicity across related keys\n+- Performance overhead from string parsing\n+\n+#### With SQL Databases (SQLite, PostgreSQL)\n+\n+```sql\n+CREATE TABLE leaderboards (\n+ game_mode VARCHAR(50),\n+ day DATE,\n+ player_id UUID,\n+ score INTEGER,\n+ PRIMARY KEY (game_mode, day, player_id)\n+);\n+\n+-- Getting a leaderboard requires SQL\n+SELECT player_id, score \n+FROM leaderboards \n+WHERE game_mode = ? AND day = ?\n+ORDER BY score DESC;\n+```\n+\n+```rust\n+// In Rust, you need an ORM or manual query building\n+let scores: Vec<(Uuid, i32)> = sqlx::query_as(\n+ \"SELECT player_id, score FROM leaderboards WHERE game_mode = $1 AND day = $2 ORDER BY score DESC\"\n+)\n+.bind(&game_mode)\n+.bind(&day)\n+.fetch_all(&pool)\n+.await?;\n+```\n+\n+**Problems:**\n+- Impedance mismatch (relational model vs nested structures)\n+- SQL complexity for simple operations\n+- ORMs add abstraction layers and performance overhead\n+- Async runtime required even for local storage\n+- Schema migrations for every structural change\n+\n+#### With Document Stores (MongoDB)\n+\n+```javascript\n+// Document structure\n+{\n+ game_mode: \"ranked\",\n+ day: \"2024-01-15\",\n+ scores: [\n+ { player_id: \"abc\", score: 1500 },\n+ { player_id: \"def\", score: 1400 }\n+ ]\n+}\n+\n+// But now you have a different problem: updating a single score\n+// requires loading and saving the entire document!\n+```\n+\n+**Problems:**\n+- Not embedded (requires separate process)\n+- Document size limits\n+- Inefficient for partial updates\n+- Complex setup for local-first apps\n+\n+## The Solution: Native Data Structures\n+\n+With Durable, you express your data model directly:\n+\n+```rust\n+let leaderboard = DurableMap::>>::new(&db, \"leaderboard\")?;\n+\n+// Store a score - reads like natural Rust code\n+leaderboard\n+ .entry(game_mode)?\n+ .or_default()?\n+ .entry(day)?\n+ .or_default()?\n+ .push((player_id, score))?;\n+\n+// Get today's leaderboard - it's just a Vec\n+let mut today_scores = leaderboard\n+ .get(&game_mode)?\n+ .and_then(|mode| mode.get(&day).ok())\n+ .unwrap_or_default();\n+today_scores.sort_by_key(|(_, s)| *s);\n+\n+// All operations are atomic, typed, and efficient\n+```\n+\n+## Why This Matters\n+\n+### 1. **Zero Translation Overhead**\n+\n+Your mental model **is** the storage model. No more:\n+- String concatenation for keys\n+- Manual serialization/deserialization \n+- SQL query construction\n+- Document structure mapping\n+\n+### 2. **Composition Without Complexity**\n+\n+Nested data structures \"just work\":\n+\n+```rust\n+// A real-world example: user notifications by app by priority\n+let notifications = DurableMap::>>>::new(&db, \"notifs\")?;\n+\n+// Natural access patterns\n+notifications\n+ .get(&user_id)?\n+ .get(&app_id)?\n+ .get(&Priority::High)?\n+ .iter()\n+ .take(10) // Latest 10 high-priority notifications\n+```\n+\n+Try implementing this with SQL joins or KV prefixes!\n+\n+### 3. **Type Safety Throughout**\n+\n+```rust\n+// This won't compile - type safety at every level\n+let score: String = leaderboard.get(&\"chess\")?.get(&20240115)?.get(0)?;\n+// ^^^^^^ expected Score, found String\n+\n+// With raw KV stores, this is a runtime error after deserialization\n+```\n+\n+### 4. **Atomicity By Design**\n+\n+```rust\n+// Multiple operations in one atomic batch\n+let mut batch = db.batch();\n+batch.vec_push(&game.players, new_player)?;\n+batch.map_insert(&game.scores, player_id, 0)?;\n+batch.map_increment(&game.stats, \"player_count\", 1)?;\n+batch.commit()?; // All or nothing\n+```\n+\n+### 5. **Performance Without Compromise**\n+\n+- **Locality**: Related data stored contiguously (prefix design)\n+- **Zero-copy possible**: Direct memory mapping for read-heavy workloads\n+- **Streaming iteration**: No need to load entire collections\n+- **Bulk operations**: Native batch support\n+\n+## Comparison Matrix\n+\n+| Feature | Durable | sled/RocksDB | SQLite | MongoDB |\n+|---------|---------|--------------|---------|----------|\n+| **Native collections** | ✅ Built-in | ❌ DIY | ❌ Tables only | ⚠️ Documents |\n+| **Type safety** | ✅ Full | ❌ Bytes | ⚠️ ORM-dependent | ⚠️ Schema validation |\n+| **Nested structures** | ✅ Natural | ❌ Manual prefixes | ❌ Joins/JSON | ✅ Embedded docs |\n+| **Atomic operations** | ✅ Automatic | ⚠️ Manual batching | ✅ Transactions | ⚠️ Document-level |\n+| **Local/embedded** | ✅ Yes | ✅ Yes | ✅ Yes | ❌ Separate process |\n+| **Schema evolution** | ✅ Per-collection | ❌ DIY | ⚠️ Migrations | ✅ Flexible |\n+| **Memory efficiency** | ✅ Scan & stream | ✅ Manual | ⚠️ Query-dependent | ❌ Doc loading |\n+\n+## Real-World Use Cases Where Durable Shines\n+\n+### Local-First Sync Engine\n+\n+```rust\n+// Sync state with conflict tracking\n+let sync_state = DurableMap::>::new(&db, \"sync\")?;\n+\n+// Natural conflict detection\n+let versions = sync_state.get(&record_id)?;\n+if versions.values().unique().count() > 1 {\n+ // Conflict detected - handle naturally\n+}\n+```\n+\n+### Time-Series Analytics Cache\n+\n+```rust\n+// Metrics by source by minute\n+let metrics = DurableMap::>>::new(&db, \"metrics\")?;\n+\n+// Natural windowing\n+let last_hour: Vec = metrics\n+ .get(&source)?\n+ .range(now - 3600..=now)?\n+ .flat_map(|(_, minute_metrics)| minute_metrics.iter())\n+ .collect();\n+```\n+\n+### Feature Flag System with History\n+\n+```rust\n+// Flags by environment with change history\n+let flags = DurableMap::>>::new(&db, \"flags\")?;\n+\n+// Natural audit trail\n+let history = flags.get(&Env::Prod)?.get(\"new-feature\")?.iter().collect();\n+```\n+\n+## The Bottom Line\n+\n+**Durable isn't a better database - it's the missing abstraction layer that lets you use persistent storage like in-memory collections.**\n+\n+Stop translating. Start building.\n+\n+---\n+\n+*Next: Read the [RFC](001.md) for implementation details, or jump to the [Quick Start Guide](quickstart.md).* \n\\ No newline at end of file\ndiff --git a/durable/examples/combined_example.rs b/durable/examples/combined_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..626a6e1cf7e3c9c26f9f2edc58950d9bc31ec67e\n--- /dev/null\n+++ b/durable/examples/combined_example.rs\n@@ -0,0 +1,160 @@\n+use durable::{Db, DurableMap, DurableVec};\n+use serde::{Serialize, Deserialize};\n+use std::time::{SystemTime, UNIX_EPOCH};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+struct Message {\n+ id: u64,\n+ from: String,\n+ to: String,\n+ content: String,\n+ timestamp: u64,\n+}\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+struct User {\n+ username: String,\n+ display_name: String,\n+ message_count: u32,\n+}\n+\n+fn get_timestamp() -> u64 {\n+ SystemTime::now()\n+ .duration_since(UNIX_EPOCH)\n+ .unwrap()\n+ .as_secs()\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"chat_db\")?;\n+ \n+ // Create our collections\n+ let mut users = DurableMap::::new(&db, \"users\")?;\n+ let mut messages = DurableVec::::new(&db, \"messages\")?;\n+ let mut user_message_indices = DurableMap::>::new(&db, \"user_messages\")?;\n+ \n+ // Create some users\n+ users.insert(\"alice\".to_string(), User {\n+ username: \"alice\".to_string(),\n+ display_name: \"Alice Smith\".to_string(),\n+ message_count: 0,\n+ })?;\n+ \n+ users.insert(\"bob\".to_string(), User {\n+ username: \"bob\".to_string(),\n+ display_name: \"Bob Johnson\".to_string(),\n+ message_count: 0,\n+ })?;\n+ \n+ users.insert(\"charlie\".to_string(), User {\n+ username: \"charlie\".to_string(),\n+ display_name: \"Charlie Brown\".to_string(),\n+ message_count: 0,\n+ })?;\n+ \n+ // Helper to send a message\n+ let send_message = |from: &str, to: &str, content: &str, \n+ messages: &mut DurableVec,\n+ users: &mut DurableMap,\n+ indices: &mut DurableMap>| -> Result<(), Box> {\n+ // Create message\n+ let msg_id = messages.len()? as u64;\n+ let message = Message {\n+ id: msg_id,\n+ from: from.to_string(),\n+ to: to.to_string(),\n+ content: content.to_string(),\n+ timestamp: get_timestamp(),\n+ };\n+ \n+ // Store message\n+ messages.push(message)?;\n+ let msg_index = messages.len()? - 1;\n+ \n+ // Update sender's message count\n+ if let Some(mut sender) = users.get(&from.to_string())? {\n+ sender.message_count += 1;\n+ users.insert(from.to_string(), sender)?;\n+ }\n+ \n+ // Track message indices for recipient\n+ let mut recipient_indices = indices.get(&to.to_string())?.unwrap_or_default();\n+ recipient_indices.push(msg_index);\n+ indices.insert(to.to_string(), recipient_indices)?;\n+ \n+ Ok(())\n+ };\n+ \n+ // Send some messages\n+ println!(\"💬 Chat Application Demo\\n\");\n+ println!(\"Sending messages...\");\n+ \n+ send_message(\"alice\", \"bob\", \"Hey Bob, how's the Durable library coming along?\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"bob\", \"alice\", \"It's going great! We have DurableVec and DurableMap working!\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"charlie\", \"alice\", \"That sounds awesome! Can I help with testing?\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"alice\", \"charlie\", \"Absolutely! The more testing the better!\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ send_message(\"bob\", \"charlie\", \"Check out the examples directory for usage patterns\", \n+ &mut messages, &mut users, &mut user_message_indices)?;\n+ \n+ // Display all users and their message counts\n+ println!(\"\\n👥 Users:\");\n+ let mut all_users = users.to_vec()?;\n+ all_users.sort_by_key(|(username, _)| username.clone());\n+ \n+ for (username, user) in all_users {\n+ println!(\" {} ({}) - {} messages sent\", \n+ user.display_name, username, user.message_count);\n+ }\n+ \n+ // Display all messages\n+ println!(\"\\n📨 All messages:\");\n+ for (i, msg) in messages.iter()?.enumerate() {\n+ let msg = msg?;\n+ println!(\" [{}] {} → {}: {}\", i, msg.from, msg.to, msg.content);\n+ }\n+ \n+ // Show inbox for each user\n+ println!(\"\\n📥 User inboxes:\");\n+ for item in users.iter() {\n+ let (username, _) = item?;\n+ if let Some(indices) = user_message_indices.get(&username)? {\n+ println!(\"\\n {}'s inbox ({} messages):\", username, indices.len());\n+ for &idx in &indices {\n+ if let Some(msg) = messages.get(idx)? {\n+ println!(\" From {}: {}\", msg.from, msg.content);\n+ }\n+ }\n+ }\n+ }\n+ \n+ // Statistics\n+ println!(\"\\n📊 Statistics:\");\n+ println!(\" Total users: {}\", users.len()?);\n+ println!(\" Total messages: {}\", messages.len()?);\n+ \n+ // Demonstrate persistence\n+ println!(\"\\n💾 Data has been persisted to disk!\");\n+ println!(\" Database location: ./chat_db\");\n+ \n+ // Clean up\n+ drop(messages);\n+ drop(users);\n+ drop(user_message_indices);\n+ drop(db);\n+ \n+ // Remove the database for this example\n+ std::fs::remove_dir_all(\"chat_db\").ok();\n+ \n+ println!(\"\\n✅ Example completed!\");\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/examples/map_example.rs b/durable/examples/map_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..08b8f2c8826caf53c4c20b422a540c92f6624029\n--- /dev/null\n+++ b/durable/examples/map_example.rs\n@@ -0,0 +1,99 @@\n+use durable::{Db, DurableMap};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+struct UserProfile {\n+ name: String,\n+ email: String,\n+ score: u32,\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"example_db\")?;\n+ \n+ // Create a persistent map of user profiles\n+ let mut users = DurableMap::::new(&db, \"users\")?;\n+ \n+ // Insert some users\n+ // Using put() when we don't need the old value - more efficient!\n+ users.put(\n+ \"alice\".to_string(),\n+ UserProfile {\n+ name: \"Alice Smith\".to_string(),\n+ email: \"alice@example.com\".to_string(),\n+ score: 1500,\n+ },\n+ )?;\n+ \n+ users.put(\n+ \"bob\".to_string(),\n+ UserProfile {\n+ name: \"Bob Johnson\".to_string(),\n+ email: \"bob@example.com\".to_string(),\n+ score: 1200,\n+ },\n+ )?;\n+ \n+ // Using insert() when we might need the old value\n+ let old_charlie = users.insert(\n+ \"charlie\".to_string(),\n+ UserProfile {\n+ name: \"Charlie Brown\".to_string(),\n+ email: \"charlie@example.com\".to_string(),\n+ score: 1800,\n+ },\n+ )?;\n+ \n+ if old_charlie.is_some() {\n+ println!(\"Replaced existing charlie entry\");\n+ }\n+ \n+ println!(\"Total users: {}\", users.len()?);\n+ \n+ // Look up a specific user\n+ if let Some(alice) = users.get(&\"alice\".to_string())? {\n+ println!(\"\\nAlice's profile: {:?}\", alice);\n+ }\n+ \n+ // Check if a user exists\n+ println!(\"\\nDoes 'david' exist? {}\", users.contains_key(&\"david\".to_string())?);\n+ \n+ // Update a user's score\n+ if let Some(mut bob) = users.get(&\"bob\".to_string())? {\n+ bob.score += 100;\n+ // Use put() here since we don't need the old value back\n+ users.put(\"bob\".to_string(), bob)?;\n+ println!(\"Updated Bob's score!\");\n+ }\n+ \n+ // Iterate over all users\n+ println!(\"\\nAll users (sorted by username):\");\n+ let mut all_users = users.to_vec()?;\n+ all_users.sort_by_key(|(username, _)| username.clone());\n+ \n+ for (username, profile) in all_users {\n+ println!(\" {} ({}) - Score: {}\", username, profile.email, profile.score);\n+ }\n+ \n+ // Get just the usernames\n+ let mut usernames = users.keys_vec()?;\n+ usernames.sort();\n+ println!(\"\\nAll usernames: {:?}\", usernames);\n+ \n+ // Find the highest scoring user\n+ let profiles = users.values_vec()?;\n+ if let Some(top_user) = profiles.iter().max_by_key(|p| p.score) {\n+ println!(\"\\nTop scorer: {} with {} points\", top_user.name, top_user.score);\n+ }\n+ \n+ // Remove a user\n+ if let Some(removed) = users.remove(&\"charlie\".to_string())? {\n+ println!(\"\\nRemoved user: {}\", removed.name);\n+ println!(\"Users remaining: {}\", users.len()?);\n+ }\n+ \n+ println!(\"\\nData has been persisted to disk.\");\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/examples/nested_example.rs b/durable/examples/nested_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3b880f2c8b8ad2633d4b5fcf2016652216c9de8e\n--- /dev/null\n+++ b/durable/examples/nested_example.rs\n@@ -0,0 +1,64 @@\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ // Open a database\n+ let db = Db::open(\"nested_example_db\")?;\n+ \n+ // Create a map where each user has a list of posts\n+ let user_posts: DurableMap> = DurableMap::new_nested(&db, \"user_posts\");\n+ \n+ // Add posts for Alice\n+ println!(\"Adding posts for Alice...\");\n+ let mut alice_posts = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ alice_posts.push(\"Hello, world!\".to_string())?;\n+ alice_posts.push(\"Rust is awesome!\".to_string())?;\n+ alice_posts.push(\"Loving persistent data structures!\".to_string())?;\n+ \n+ // Add posts for Bob\n+ println!(\"Adding posts for Bob...\");\n+ let mut bob_posts = user_posts.entry(\"bob\".to_string())?.or_default()?;\n+ bob_posts.push(\"First post\".to_string())?;\n+ bob_posts.push(\"Learning Rust\".to_string())?;\n+ \n+ // Add a post for Charlie in a chained call\n+ println!(\"Adding post for Charlie...\");\n+ user_posts.entry(\"charlie\".to_string())?.or_default()?.push(\"One-liner post!\".to_string())?;\n+ \n+ // Read back Alice's posts\n+ println!(\"\\nAlice's posts:\");\n+ let alice_posts_read = user_posts.entry(\"alice\".to_string())?.or_default()?;\n+ for i in 0..alice_posts_read.len()? {\n+ if let Some(post) = alice_posts_read.get(i)? {\n+ println!(\" {}: {}\", i + 1, post);\n+ }\n+ }\n+ \n+ // Read back Bob's posts\n+ println!(\"\\nBob's posts:\");\n+ let bob_posts_read = user_posts.entry(\"bob\".to_string())?.or_default()?;\n+ for i in 0..bob_posts_read.len()? {\n+ if let Some(post) = bob_posts_read.get(i)? {\n+ println!(\" {}: {}\", i + 1, post);\n+ }\n+ }\n+ \n+ // Read back Charlie's posts\n+ println!(\"\\nCharlie's posts:\");\n+ let charlie_posts_read = user_posts.entry(\"charlie\".to_string())?.or_default()?;\n+ for i in 0..charlie_posts_read.len()? {\n+ if let Some(post) = charlie_posts_read.get(i)? {\n+ println!(\" {}: {}\", i + 1, post);\n+ }\n+ }\n+ \n+ println!(\"\\nDemonstration of persistence...\");\n+ println!(\"Data is now persisted to disk. You can stop and restart this program,\");\n+ println!(\"and all the posts will still be there!\");\n+ \n+ println!(\"\\nTotal users with posts: 3\");\n+ println!(\"Alice has {} posts\", alice_posts_read.len()?);\n+ println!(\"Bob has {} posts\", bob_posts_read.len()?);\n+ println!(\"Charlie has {} posts\", charlie_posts_read.len()?);\n+ \n+ Ok(())\n+}\n\\ No newline at end of file\ndiff --git a/durable/examples/ranking_history.rs b/durable/examples/ranking_history.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..4da8592d240318318ae20c834d616210edb16c8d\n--- /dev/null\n+++ b/durable/examples/ranking_history.rs\n@@ -0,0 +1,179 @@\n+use durable::{Db, DurableMap, DurableVec};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]\n+struct RankingEntry {\n+ player_id: String,\n+ score: i32,\n+ timestamp: u64,\n+}\n+\n+impl RankingEntry {\n+ fn new(player_id: &str, score: i32) -> Self {\n+ Self {\n+ player_id: player_id.to_string(),\n+ score,\n+ timestamp: std::time::SystemTime::now()\n+ .duration_since(std::time::UNIX_EPOCH)\n+ .unwrap()\n+ .as_secs(),\n+ }\n+ }\n+}\n+\n+fn main() -> Result<(), Box> {\n+ println!(\"🎮 Gaming Ranking History System (Rewritten with Nested Entry API)\");\n+ println!(\"================================================================\");\n+ \n+ let db = Db::open(\"ranking_history_db\")?;\n+ \n+ // THE CORE CHANGE: Define the truly nested data structure.\n+ // Instead of a composite key, we nest a Map within a Map.\n+ // This represents the ideal, ergonomic API.\n+ type DailyRankings = DurableVec;\n+ type GameHistory = DurableMap;\n+ type Rankings = DurableMap;\n+\n+ let rankings: Rankings = DurableMap::new_nested(&db, \"game_rankings_v2\");\n+ \n+ // Simulate some game days\n+ let today = 20241215u32;\n+ let yesterday = 20241214u32;\n+ let last_week = 20241208u32;\n+ \n+ // No more `make_key` helper function!\n+ \n+ println!(\"\\n📊 Adding ranking data using chained entry().or_default()...\");\n+ \n+ // Add rankings for CS2 today. This demonstrates the new, clean access pattern.\n+ println!(\"Adding CS2 rankings for today ({})\", today);\n+ let mut cs2_today = rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()? // Returns GameHistory (DurableMap) for \"cs2\"\n+ .entry(today)?\n+ .or_default()?; // Returns DailyRankings (DurableVec<...>) for `today`\n+\n+ cs2_today.push(RankingEntry::new(\"player1\", 2450))?;\n+ cs2_today.push(RankingEntry::new(\"player2\", 2380))?;\n+ cs2_today.push(RankingEntry::new(\"player3\", 2320))?;\n+ cs2_today.push(RankingEntry::new(\"player4\", 2280))?;\n+ \n+ // Add rankings for CS2 yesterday\n+ println!(\"Adding CS2 rankings for yesterday ({})\", yesterday);\n+ rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(yesterday)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"player1\", 2420))?;\n+ rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(yesterday)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"player2\", 2350))?;\n+ rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(yesterday)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"player5\", 2300))?;\n+\n+ // Add rankings for Valorant today\n+ println!(\"Adding Valorant rankings for today ({})\", today);\n+ let mut valorant_today = rankings\n+ .entry(\"valorant\".to_string())?\n+ .or_default()?\n+ .entry(today)?\n+ .or_default()?;\n+\n+ valorant_today.push(RankingEntry::new(\"player6\", 1850))?;\n+ valorant_today.push(RankingEntry::new(\"player7\", 1820))?;\n+ valorant_today.push(RankingEntry::new(\"player1\", 1800))?; // Same player, different game\n+ \n+ // Add TF2 rankings (matching the docs example)\n+ println!(\"Adding TF2 rankings for last week ({})\", last_week);\n+ rankings\n+ .entry(\"tf2\".to_string())?\n+ .or_default()?\n+ .entry(last_week)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"veteran_player\", 3200))?;\n+ rankings\n+ .entry(\"tf2\".to_string())?\n+ .or_default()?\n+ .entry(last_week)?\n+ .or_default()?\n+ .push(RankingEntry::new(\"old_school_gamer\", 3150))?;\n+ \n+ println!(\"\\n🏆 Reading back ranking data with the same natural API...\");\n+ \n+ // Get today's CS2 leaderboard\n+ println!(\"\\n🎯 CS2 Leaderboard for {} (today):\", today);\n+ let mut today_rankings = rankings\n+ .entry(\"cs2\".to_string())?\n+ .or_default()?\n+ .entry(today)?\n+ .or_default()?\n+ .to_vec()?;\n+\n+ // Sort by score descending\n+ today_rankings.sort_by(|a, b| b.score.cmp(&a.score));\n+ \n+ for (rank, entry) in today_rankings.iter().enumerate() {\n+ println!(\" {}. {} - {} points\", rank + 1, entry.player_id, entry.score);\n+ }\n+ \n+ // Show cross-game analysis is still easy\n+ println!(\"\\n🎮 Multi-game player analysis for player1 on {}:\", today);\n+ let cs2_player1_score = today_rankings.iter()\n+ .find(|e| e.player_id == \"player1\")\n+ .map(|e| e.score);\n+\n+ let valorant_player1_score = valorant_today.to_vec()?.iter()\n+ .find(|e| e.player_id == \"player1\")\n+ .map(|e| e.score);\n+\n+ if let Some(score) = cs2_player1_score { println!(\" CS2 Score: {}\", score); }\n+ if let Some(score) = valorant_player1_score { println!(\" Valorant Score: {}\", score); }\n+ \n+ // Showcase the power of the nested structure for stats\n+ // For nested collections, we use the keys API instead of iter()\n+ println!(\"\\n📊 Dynamic Database Statistics (discovered games):\");\n+ \n+ // Note: For nested collections, we iterate over known keys or use a different approach\n+ // since the values (nested DurableMaps) cannot be directly deserialized\n+ let games = vec![\"cs2\", \"valorant\", \"tf2\"]; // In a real app, you might track these separately\n+ \n+ for game in games {\n+ let game_history = rankings.entry(game.to_string())?.or_default()?;\n+ let active_days = game_history.len()?;\n+ \n+ if active_days > 0 {\n+ // For demonstration, let's count entries from known days\n+ let mut total_entries = 0;\n+ let days = [today, yesterday, last_week];\n+ \n+ for day in days {\n+ if let Ok(daily_rankings) = game_history.entry(day) {\n+ if let Ok(rankings_vec) = daily_rankings.or_default() {\n+ total_entries += rankings_vec.len()?;\n+ }\n+ }\n+ }\n+ \n+ if total_entries > 0 {\n+ println!(\" • {}: {} total entries across {} active day(s)\", \n+ game.to_uppercase(), total_entries, active_days);\n+ }\n+ }\n+ }\n+ \n+ println!(\"\\n✨ Key Benefits of This Rewritten Approach:\");\n+ println!(\" • No more manual key construction (`format!`) - the core goal is met!\");\n+ println!(\" • The code's structure now mirrors the mental model: `rankings[game][day]`\");\n+ println!(\" • Truly compositional API, unlocking more powerful dynamic queries (like the stats section)\");\n+ println!(\" • Demonstrates the full power of the `DurableCollection` and `entry()` design.\");\n+\n+ Ok(())\n+}\n\\ No newline at end of file\ndiff --git a/durable/examples/simple_ranking.rs b/durable/examples/simple_ranking.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3cc873c41f3d93c7fa9e6e2dfc80e815f456b1f9\n--- /dev/null\n+++ b/durable/examples/simple_ranking.rs\n@@ -0,0 +1,68 @@\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ println!(\"🏆 Simple Game Ranking Example\");\n+ println!(\"Demonstrating the pattern from docs/motivation.md\");\n+ println!(\"===============================================\");\n+ \n+ let db = Db::open(\"simple_ranking_db\")?;\n+ \n+ // This is the exact pattern from the docs: Game Mode → List of (Player, Score)\n+ // For simplicity, we're showing one day's data per game mode\n+ let rankings: DurableMap> = DurableMap::new_nested(&db, \"rankings\");\n+ \n+ println!(\"\\n📊 Adding TF2 rankings (from the docs example)...\");\n+ \n+ // This is the exact code pattern shown in docs/motivation.md\n+ let mut tf2_rankings = rankings.entry(\"tf2\".to_string())?.or_default()?;\n+ tf2_rankings.push((\"player1\".to_string(), 1500))?;\n+ tf2_rankings.push((\"player2\".to_string(), 1400))?;\n+ tf2_rankings.push((\"player3\".to_string(), 1300))?;\n+ \n+ println!(\"✅ Added TF2 rankings using the docs pattern!\");\n+ \n+ // Add some other games for comparison\n+ println!(\"\\n📊 Adding CS2 rankings...\");\n+ let mut cs2_rankings = rankings.entry(\"cs2\".to_string())?.or_default()?;\n+ cs2_rankings.push((\"pro_player\".to_string(), 2500))?;\n+ cs2_rankings.push((\"skilled_gamer\".to_string(), 2200))?;\n+ \n+ println!(\"✅ Added CS2 rankings!\");\n+ \n+ // Now read back the data\n+ println!(\"\\n🏆 Current TF2 Leaderboard:\");\n+ let tf2_data = rankings.entry(\"tf2\".to_string())?.or_default()?;\n+ \n+ // Convert to vec and sort for display\n+ let mut tf2_leaderboard = tf2_data.to_vec()?;\n+ tf2_leaderboard.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by score descending\n+ \n+ for (rank, (player, score)) in tf2_leaderboard.iter().enumerate() {\n+ println!(\" {}. {} - {} points\", rank + 1, player, score);\n+ }\n+ \n+ println!(\"\\n🏆 Current CS2 Leaderboard:\");\n+ let cs2_data = rankings.entry(\"cs2\".to_string())?.or_default()?;\n+ \n+ let mut cs2_leaderboard = cs2_data.to_vec()?;\n+ cs2_leaderboard.sort_by(|a, b| b.1.cmp(&a.1));\n+ \n+ for (rank, (player, score)) in cs2_leaderboard.iter().enumerate() {\n+ println!(\" {}. {} - {} points\", rank + 1, player, score);\n+ }\n+ \n+ println!(\"\\n📈 Database Statistics:\");\n+ println!(\" TF2 has {} players\", tf2_data.len()?);\n+ println!(\" CS2 has {} players\", cs2_data.len()?);\n+ \n+ println!(\"\\n✨ This demonstrates the exact pattern from docs/motivation.md:\");\n+ println!(\" rankings.entry(game_mode)?.or_default()?.push((player, score))?;\");\n+ println!(\" \");\n+ println!(\" Compare this to the manual key construction required with raw KV stores:\");\n+ println!(\" let key = format!(\\\"leaderboard:{{}}:{{}}:player:{{}}\\\", game_mode, day, player_id);\");\n+ println!(\" db.insert(key.as_bytes(), score.to_le_bytes())?;\");\n+ println!(\" \");\n+ println!(\" Durable provides the ergonomic, type-safe abstraction over RocksDB!\");\n+ \n+ Ok(())\n+}\n\\ No newline at end of file\ndiff --git a/durable/examples/streaming_demo.rs b/durable/examples/streaming_demo.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..3a74a5318675f94f89aaf01562a5b28f8a1b664a\n--- /dev/null\n+++ b/durable/examples/streaming_demo.rs\n@@ -0,0 +1,81 @@\n+use durable::{Db, DurableMap, DurableVec};\n+\n+fn main() -> Result<(), Box> {\n+ let db = Db::open(\"streaming_demo_db\")?;\n+ \n+ // Create collections with a moderate amount of data\n+ let mut map = DurableMap::::new(&db, \"large_map\")?;\n+ let mut vec = DurableVec::::new(&db, \"large_vec\")?;\n+ \n+ println!(\"🚀 Streaming Iterator Demo\\n\");\n+ \n+ // Add 1000 entries to demonstrate streaming\n+ println!(\"Adding 1000 entries to map and vec...\");\n+ for i in 0..1000 {\n+ map.insert(i, format!(\"Value {}\", i))?;\n+ vec.push(format!(\"Item {}\", i))?;\n+ }\n+ \n+ println!(\"\\n📊 Collection sizes:\");\n+ println!(\" Map entries: {}\", map.len()?);\n+ println!(\" Vec elements: {}\", vec.len()?);\n+ \n+ // Demonstrate streaming iteration - memory efficient\n+ println!(\"\\n✨ Streaming iteration (memory efficient):\");\n+ \n+ // Count items without loading into memory\n+ let map_count = map.iter().count();\n+ println!(\" Counted {} map entries without loading into memory\", map_count);\n+ \n+ // Find specific items efficiently\n+ let target = 500;\n+ let found = map.iter()\n+ .find(|item| {\n+ item.as_ref()\n+ .map(|(k, _)| *k == target)\n+ .unwrap_or(false)\n+ });\n+ \n+ if let Some(Ok((k, v))) = found {\n+ println!(\" Found key {} with value '{}' via streaming\", k, v);\n+ }\n+ \n+ // Process only what we need\n+ println!(\"\\n🎯 Processing first 10 items only:\");\n+ for (i, item) in vec.iter()?.take(10).enumerate() {\n+ match item {\n+ Ok(value) => println!(\" [{}] {}\", i, value),\n+ Err(e) => println!(\" [{}] Error: {:?}\", i, e),\n+ }\n+ }\n+ \n+ // Filter and process without loading all data\n+ println!(\"\\n🔍 Filtering even keys without loading all data:\");\n+ let even_count = map.keys()\n+ .filter(|item| {\n+ item.as_ref()\n+ .map(|k| k % 2 == 0)\n+ .unwrap_or(false)\n+ })\n+ .count();\n+ println!(\" Found {} even keys\", even_count);\n+ \n+ // Compare with loading everything into memory\n+ println!(\"\\n⚠️ Loading all data into memory (less efficient for large collections):\");\n+ let all_values = map.values_vec()?;\n+ println!(\" Loaded {} values into a Vec\", all_values.len());\n+ \n+ println!(\"\\n✅ Streaming iterators provide:\");\n+ println!(\" • Constant memory usage regardless of collection size\");\n+ println!(\" • Ability to process data larger than RAM\");\n+ println!(\" • Early termination when finding specific items\");\n+ println!(\" • Efficient filtering and transformation\");\n+ \n+ // Clean up\n+ drop(map);\n+ drop(vec);\n+ drop(db);\n+ std::fs::remove_dir_all(\"streaming_demo_db\").ok();\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/examples/vec_example.rs b/durable/examples/vec_example.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..07d394b5f3964ea1942a645c2f008e9e3c37d6d8\n--- /dev/null\n+++ b/durable/examples/vec_example.rs\n@@ -0,0 +1,66 @@\n+use durable::{Db, DurableVec};\n+use serde::{Serialize, Deserialize};\n+\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n+struct Task {\n+ id: u64,\n+ title: String,\n+ completed: bool,\n+}\n+\n+fn main() -> Result<(), Box> {\n+ // Open or create a database\n+ let db = Db::open(\"example_db\")?;\n+ \n+ // Create a persistent vector of tasks\n+ let mut tasks = DurableVec::::new(&db, \"tasks\")?;\n+ \n+ // Add some tasks\n+ tasks.push(Task {\n+ id: 1,\n+ title: \"Build Durable library\".to_string(),\n+ completed: true,\n+ })?;\n+ \n+ tasks.push(Task {\n+ id: 2,\n+ title: \"Write comprehensive tests\".to_string(),\n+ completed: true,\n+ })?;\n+ \n+ tasks.push(Task {\n+ id: 3,\n+ title: \"Create documentation\".to_string(),\n+ completed: false,\n+ })?;\n+ \n+ println!(\"Total tasks: {}\", tasks.len()?);\n+ \n+ // Iterate through all tasks\n+ println!(\"\\nAll tasks:\");\n+ for (i, task) in tasks.iter()?.enumerate() {\n+ let task = task?;\n+ println!(\" [{}] {} - {}\", \n+ i, \n+ task.title, \n+ if task.completed { \"✓\" } else { \"○\" }\n+ );\n+ }\n+ \n+ // Get a specific task\n+ if let Some(task) = tasks.get(1)? {\n+ println!(\"\\nTask at index 1: {:?}\", task);\n+ }\n+ \n+ // Mark the last task as completed\n+ if let Some(mut last_task) = tasks.pop()? {\n+ println!(\"\\nCompleting task: {}\", last_task.title);\n+ last_task.completed = true;\n+ tasks.push(last_task)?;\n+ }\n+ \n+ // The data persists even after the program exits!\n+ println!(\"\\nData has been persisted to disk.\");\n+ \n+ Ok(())\n+} \n\\ No newline at end of file\ndiff --git a/durable/repomix.config.json b/durable/repomix.config.json\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..bb52e4fcd383e8937245bd62566db8b7652402c2\n--- /dev/null\n+++ b/durable/repomix.config.json\n@@ -0,0 +1,27 @@\n+{\n+ \"output\": {\n+ \"filePath\": \"all.txt\",\n+ \"style\": \"plain\",\n+ \"parsableStyle\": false,\n+ \"fileSummary\": true,\n+ \"directoryStructure\": true,\n+ \"removeComments\": false,\n+ \"removeEmptyLines\": false,\n+ \"compress\": false,\n+ \"topFilesLength\": 100,\n+ \"showLineNumbers\": false,\n+ \"copyToClipboard\": false\n+ },\n+ \"include\": [],\n+ \"ignore\": {\n+ \"useGitignore\": true,\n+ \"useDefaultPatterns\": true,\n+ \"customPatterns\": []\n+ },\n+ \"security\": {\n+ \"enableSecurityCheck\": true\n+ },\n+ \"tokenCount\": {\n+ \"encoding\": \"o200k_base\"\n+ }\n+}\ndiff --git a/durable/src/lib.rs b/durable/src/lib.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..94e8823be93be1176b0a4cdc9797181b9f8caf60\n--- /dev/null\n+++ b/durable/src/lib.rs\n@@ -0,0 +1,123 @@\n+//! Durable - RocksDB-backed persistent data structures for Rust\n+\n+use std::path::Path;\n+use std::sync::Arc;\n+use rocksdb::{DB as RocksDB, Options, WriteBatch};\n+use thiserror::Error;\n+\n+pub mod vec;\n+pub mod map;\n+pub use vec::DurableVec;\n+pub use map::DurableMap;\n+\n+/// Error types for Durable operations\n+#[derive(Error, Debug)]\n+pub enum DurableError {\n+ #[error(\"RocksDB error: {0}\")]\n+ RocksDB(#[from] rocksdb::Error),\n+ \n+ #[error(\"Serialization error: {0}\")]\n+ Serialization(#[from] bincode::Error),\n+ \n+ #[error(\"Key not found\")]\n+ KeyNotFound,\n+ \n+ #[error(\"Collection not found: {0}\")]\n+ CollectionNotFound(String),\n+ \n+ #[error(\"Data corruption: {0}\")]\n+ Corruption(String),\n+}\n+\n+pub type Result = std::result::Result;\n+\n+/// A trait for types that can be used as nested collections.\n+pub trait DurableCollection {\n+ /// Creates a new instance of the collection from a database handle\n+ /// and a pre-determined, unique key prefix.\n+ /// \n+ /// This is the key method that allows `DurableMap` to instantiate\n+ /// a nested collection handle.\n+ fn from_prefix(db: Db, prefix: Vec) -> Self;\n+}\n+\n+/// The main database handle\n+#[derive(Clone)]\n+pub struct Db {\n+ inner: Arc,\n+}\n+\n+impl Db {\n+ /// Opens or creates a durable database at the given path\n+ pub fn open>(path: P) -> Result {\n+ let mut opts = Options::default();\n+ opts.create_if_missing(true);\n+ opts.create_missing_column_families(true);\n+ \n+ let db = RocksDB::open(&opts, path)?;\n+ Ok(Db { \n+ inner: Arc::new(db),\n+ })\n+ }\n+ \n+ /// Create a new write batch for atomic operations\n+ pub fn batch(&self) -> Batch {\n+ Batch {\n+ db: self.clone(),\n+ inner: WriteBatch::default(),\n+ }\n+ }\n+ \n+ /// Get the underlying RocksDB handle (for advanced usage)\n+ pub(crate) fn rocks(&self) -> &RocksDB {\n+ &self.inner\n+ }\n+ \n+ /// Get a new unique collection ID for nested collections\n+ pub fn new_collection_id(&self) -> Result {\n+ let key = b\"__global_meta:next_collection_id\";\n+ \n+ // Get current value\n+ let current_bytes = self.rocks().get(key)?;\n+ let current_id = match current_bytes {\n+ Some(bytes) => {\n+ if bytes.len() != 8 {\n+ return Err(DurableError::Corruption(\"Invalid collection ID bytes size\".into()));\n+ }\n+ let id_bytes: [u8; 8] = bytes[..8].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid collection ID bytes\".into()))?;\n+ u64::from_le_bytes(id_bytes)\n+ }\n+ None => 0,\n+ };\n+ \n+ let next_id = current_id + 1;\n+ \n+ // Try to atomically update - use compare-and-swap semantics\n+ let mut batch = WriteBatch::default();\n+ batch.put(key, &next_id.to_le_bytes());\n+ \n+ // For now, just write it directly. In a real implementation,\n+ // we'd want proper compare-and-swap to handle concurrent access\n+ self.rocks().write(batch)?;\n+ self.rocks().flush_wal(true)?;\n+ \n+ Ok(current_id)\n+ }\n+}\n+\n+/// A write batch for atomic operations\n+pub struct Batch {\n+ db: Db,\n+ inner: WriteBatch,\n+}\n+\n+impl Batch {\n+ /// Commit all operations in this batch atomically\n+ pub fn commit(self) -> Result<()> {\n+ self.db.rocks().write(self.inner)?;\n+ self.db.rocks().flush_wal(true)?;\n+ Ok(())\n+ }\n+ \n+}\ndiff --git a/durable/src/map.rs b/durable/src/map.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..2629857270b1be346a20fc67dec1cfc57c829403\n--- /dev/null\n+++ b/durable/src/map.rs\n@@ -0,0 +1,1073 @@\n+use crate::{Db, Result, DurableError, DurableCollection};\n+use rocksdb::{IteratorMode, WriteBatch, Direction};\n+use serde::{Serialize, Deserialize};\n+use std::marker::PhantomData;\n+\n+/// A persistent map backed by RocksDB\n+pub struct DurableMap {\n+ db: Db,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl DurableMap \n+where \n+ K: Serialize + for<'de> Deserialize<'de>,\n+ V: Serialize + for<'de> Deserialize<'de>,\n+{\n+ /// Create a new DurableMap with the given name\n+ pub fn new(db: &Db, name: &str) -> Result {\n+ let prefix = format!(\"map:{}\", name).into_bytes();\n+ \n+ Ok(DurableMap {\n+ db: db.clone(),\n+ prefix,\n+ _phantom: PhantomData,\n+ })\n+ }\n+ \n+ /// Insert a key-value pair into the map\n+ pub fn insert(&mut self, key: K, value: V) -> Result> {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let value_bytes = bincode::serialize(&value)?;\n+ \n+ // Get the old value if it exists\n+ let old_value = self.get(&key)?;\n+ \n+ let mut batch = WriteBatch::default();\n+ \n+ // Write the new value\n+ let db_key = self.entry_key(&key_bytes);\n+ batch.put(&db_key, &value_bytes);\n+ \n+ // Update length if this is a new key\n+ if old_value.is_none() {\n+ let new_len = self.len()? + 1;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ }\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(old_value)\n+ }\n+ \n+ /// Put a key-value pair into the map without returning the old value\n+ /// \n+ /// This is more efficient than `insert` when you don't need the old value,\n+ /// as it only checks for key existence without deserializing the value.\n+ pub fn put(&mut self, key: K, value: V) -> Result<()> {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let value_bytes = bincode::serialize(&value)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ let mut batch = WriteBatch::default();\n+ \n+ // Check if this is a new key (without deserializing the value)\n+ let is_new = self.db.rocks().get_pinned(&db_key)?.is_none();\n+ \n+ // Write the new value\n+ batch.put(&db_key, &value_bytes);\n+ \n+ // Update length if this is a new key\n+ if is_new {\n+ let new_len = self.len()? + 1;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ }\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Get a value by key\n+ pub fn get(&self, key: &K) -> Result> {\n+ let key_bytes = bincode::serialize(key)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ match self.db.rocks().get(&db_key)? {\n+ Some(bytes) => {\n+ let value = bincode::deserialize(&bytes)?;\n+ Ok(Some(value))\n+ }\n+ None => Ok(None),\n+ }\n+ }\n+ \n+ /// Check if a key exists in the map\n+ pub fn contains_key(&self, key: &K) -> Result {\n+ let key_bytes = bincode::serialize(key)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ Ok(self.db.rocks().get(&db_key)?.is_some())\n+ }\n+ \n+ /// Remove a key-value pair from the map\n+ pub fn remove(&mut self, key: &K) -> Result> {\n+ let key_bytes = bincode::serialize(key)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ // Get the old value\n+ let old_value = match self.db.rocks().get(&db_key)? {\n+ Some(bytes) => {\n+ let value = bincode::deserialize(&bytes)?;\n+ Some(value)\n+ }\n+ None => None,\n+ };\n+ \n+ // Delete the key if it existed and update length\n+ if old_value.is_some() {\n+ let mut batch = WriteBatch::default();\n+ \n+ // Delete the entry\n+ batch.delete(&db_key);\n+ \n+ // Update length\n+ let new_len = self.len()? - 1;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ }\n+ \n+ Ok(old_value)\n+ }\n+ \n+\n+ \n+ /// Clear all entries from the map\n+ pub fn clear(&mut self) -> Result<()> {\n+ let prefix = self.entry_prefix();\n+ let mut batch = WriteBatch::default();\n+ \n+ // Collect all keys to delete\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ for item in iter {\n+ let (key, _) = item?;\n+ if !key.starts_with(&prefix) {\n+ break;\n+ }\n+ batch.delete(&key);\n+ }\n+ \n+ // Reset length to 0\n+ let len_key = self.meta_key(\"len\");\n+ batch.delete(&len_key);\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Iterate over all key-value pairs using a streaming iterator\n+ pub fn iter(&self) -> MapIterator<'_, K, V> {\n+ let prefix = self.entry_prefix();\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ \n+ MapIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Load all key-value pairs into a Vec\n+ /// \n+ /// Note: This loads the entire collection into memory. For large collections,\n+ /// prefer using `iter()` which streams elements.\n+ pub fn to_vec(&self) -> Result> {\n+ let mut result = Vec::new();\n+ for item in self.iter() {\n+ result.push(item?);\n+ }\n+ Ok(result)\n+ }\n+ \n+ /// Iterate over all keys using a streaming iterator\n+ pub fn keys(&self) -> KeyIterator<'_, K, V> {\n+ let prefix = self.entry_prefix();\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ \n+ KeyIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Load all keys into a Vec\n+ /// \n+ /// Note: This loads all keys into memory. For large collections,\n+ /// prefer using `keys()` which streams elements.\n+ pub fn keys_vec(&self) -> Result> {\n+ let mut result = Vec::new();\n+ for item in self.keys() {\n+ result.push(item?);\n+ }\n+ Ok(result)\n+ }\n+ \n+ /// Iterate over all values using a streaming iterator\n+ pub fn values(&self) -> ValueIterator<'_, K, V> {\n+ let prefix = self.entry_prefix();\n+ let iter = self.db.rocks().iterator(IteratorMode::From(&prefix, Direction::Forward));\n+ \n+ ValueIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Load all values into a Vec\n+ /// \n+ /// Note: This loads all values into memory. For large collections,\n+ /// prefer using `values()` which streams elements.\n+ pub fn values_vec(&self) -> Result> {\n+ let mut result = Vec::new();\n+ for item in self.values() {\n+ result.push(item?);\n+ }\n+ Ok(result)\n+ }\n+ \n+ /// Insert multiple key-value pairs in a single batch\n+ pub fn extend(&mut self, iter: I) -> Result<()>\n+ where\n+ I: IntoIterator\n+ {\n+ let mut batch = WriteBatch::default();\n+ let current_len = self.len()?;\n+ let mut new_entries = 0;\n+ \n+ for (key, value) in iter {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let value_bytes = bincode::serialize(&value)?;\n+ let db_key = self.entry_key(&key_bytes);\n+ \n+ // Check if this is a new key\n+ if !self.contains_key(&key)? {\n+ new_entries += 1;\n+ }\n+ \n+ batch.put(&db_key, &value_bytes);\n+ }\n+ \n+ // Update length if we added new entries\n+ if new_entries > 0 {\n+ let new_len = current_len + new_entries;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(new_len as u64).to_le_bytes());\n+ }\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+\n+ \n+ // Helper methods\n+ \n+ fn entry_key(&self, key_bytes: &[u8]) -> Vec {\n+ let mut db_key = self.prefix.clone();\n+ db_key.extend_from_slice(b\":entry:\");\n+ db_key.extend_from_slice(key_bytes);\n+ db_key\n+ }\n+ \n+ fn entry_prefix(&self) -> Vec {\n+ let mut prefix = self.prefix.clone();\n+ prefix.extend_from_slice(b\":entry:\");\n+ prefix\n+ }\n+}\n+\n+// Additional implementation block for methods that don't require serialization constraints\n+impl DurableMap {\n+ /// Create a new DurableMap for nested collections (no serialization constraints)\n+ pub fn new_nested(db: &Db, name: &str) -> Self {\n+ let prefix = format!(\"map:{}\", name).into_bytes();\n+ \n+ DurableMap {\n+ db: db.clone(),\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Create a new DurableMap from a prefix (used for nested collections)\n+ pub fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ Self {\n+ db,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Get the number of entries in the map (unconstrained version for nested collections)\n+ pub fn len(&self) -> Result {\n+ let key = self.meta_key(\"len\");\n+ match self.db.rocks().get(&key)? {\n+ Some(bytes) => {\n+ if bytes.len() != 8 {\n+ return Err(DurableError::Corruption(\"Invalid length bytes size\".into()));\n+ }\n+ let len_bytes: [u8; 8] = bytes[..8].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid length bytes\".into()))?;\n+ Ok(u64::from_le_bytes(len_bytes) as usize)\n+ }\n+ None => Ok(0),\n+ }\n+ }\n+ \n+ /// Check if the map is empty (unconstrained version for nested collections)\n+ pub fn is_empty(&self) -> Result {\n+ Ok(self.len()? == 0)\n+ }\n+\n+ /// Get the entry key for nested collections (needed by entry API)\n+ pub(crate) fn make_entry_key(&self, key_bytes: &[u8]) -> Vec {\n+ let mut db_key = self.prefix.clone();\n+ db_key.extend_from_slice(b\":entry:\");\n+ db_key.extend_from_slice(key_bytes);\n+ db_key\n+ }\n+ \n+ fn meta_key(&self, meta_type: &str) -> Vec {\n+ let mut key = self.prefix.clone();\n+ key.extend_from_slice(b\":__meta:\");\n+ key.extend_from_slice(meta_type.as_bytes());\n+ key\n+ }\n+}\n+\n+// Implement the DurableCollection trait for DurableMap\n+// This implementation is used for nested collections and doesn't require\n+// serialization bounds since nested maps use collection markers, not direct serialization\n+impl DurableCollection for DurableMap {\n+ fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ DurableMap::from_prefix(db, prefix)\n+ }\n+}\n+\n+/// Entry API for DurableMap with nested collections\n+pub enum DurableEntry<'a, K, V> {\n+ Occupied(OccupiedEntry<'a, K, V>),\n+ Vacant(VacantEntry<'a, K, V>),\n+}\n+\n+impl<'a, K, V> DurableEntry<'a, K, V>\n+where\n+ K: Serialize,\n+ V: DurableCollection,\n+{\n+ /// Gets the collection, creating it if it doesn't exist\n+ pub fn or_default(self) -> Result {\n+ match self {\n+ DurableEntry::Occupied(entry) => entry.or_default(),\n+ DurableEntry::Vacant(entry) => entry.or_default(),\n+ }\n+ }\n+}\n+\n+/// Represents an entry that already exists\n+pub struct OccupiedEntry<'a, K, V> {\n+ map: &'a DurableMap,\n+ key_bytes: Vec,\n+ value_marker: Vec, // The bytes read from RocksDB, e.g., [0x02, ...]\n+}\n+\n+impl<'a, K, V> OccupiedEntry<'a, K, V> \n+where\n+ V: DurableCollection,\n+{\n+ /// Gets a handle to the existing nested collection\n+ pub fn get(self) -> Result {\n+ // 1. Parse the collection_id from self.value_marker\n+ if self.value_marker.len() != 9 || self.value_marker[0] != 0x02 {\n+ return Err(DurableError::Corruption(\"Invalid collection marker\".into()));\n+ }\n+ \n+ let col_id_bytes: [u8; 8] = self.value_marker[1..9].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid collection ID\".into()))?;\n+ let col_id = u64::from_le_bytes(col_id_bytes);\n+\n+ // 2. Re-construct the unique prefix for the child collection\n+ let parent_key_prefix = self.map.make_entry_key(&self.key_bytes);\n+ let child_prefix = [parent_key_prefix.as_slice(), &[0x00], &col_id.to_le_bytes()].concat();\n+\n+ // 3. Create the collection handle using the trait method\n+ Ok(V::from_prefix(self.map.db.clone(), child_prefix))\n+ }\n+ \n+ /// Gets a handle to the existing nested collection (same as get but consumes self)\n+ pub fn or_default(self) -> Result {\n+ self.get()\n+ }\n+}\n+\n+/// Represents a slot that is empty\n+pub struct VacantEntry<'a, K, V> {\n+ map: &'a DurableMap,\n+ key: K, // The original key from the user\n+}\n+\n+impl<'a, K, V> VacantEntry<'a, K, V> \n+where\n+ K: Serialize,\n+ V: DurableCollection,\n+{\n+ /// Inserts a new default collection and returns a handle to it\n+ pub fn or_default(self) -> Result {\n+ // 1. Atomically get a new unique ID for the collection\n+ let new_col_id = self.map.db.new_collection_id()?;\n+\n+ // 2. Create the marker value that points to our new collection\n+ let mut value_marker = vec![0x02_u8];\n+ value_marker.extend_from_slice(&new_col_id.to_le_bytes());\n+\n+ // 3. Get the key bytes and construct the full parent entry key\n+ let key_bytes = bincode::serialize(&self.key)?;\n+ let parent_db_key = self.map.make_entry_key(&key_bytes);\n+\n+ // 4. ATOMICALLY write the marker to the parent map\n+ self.map.db.rocks().put(&parent_db_key, &value_marker)?;\n+ self.map.db.rocks().flush_wal(true)?;\n+\n+ // 5. Construct the unique prefix for our new child collection\n+ let child_prefix = [parent_db_key.as_slice(), &[0x00], &new_col_id.to_le_bytes()].concat();\n+\n+ // 6. Create and return the new collection handle\n+ Ok(V::from_prefix(self.map.db.clone(), child_prefix))\n+ }\n+}\n+\n+// API for nested collections\n+impl DurableMap {\n+ /// The entry point for creating or accessing a nested collection.\n+ /// This method is only available when `V` is a `DurableCollection`.\n+ pub fn entry(&self, key: K) -> Result>\n+ where\n+ V: DurableCollection,\n+ K: Serialize,\n+ {\n+ let key_bytes = bincode::serialize(&key)?;\n+ let db_key = self.make_entry_key(&key_bytes);\n+\n+ match self.db.rocks().get(&db_key)? {\n+ Some(value_marker) => {\n+ // Key exists. The value should be a collection marker.\n+ Ok(DurableEntry::Occupied(OccupiedEntry {\n+ map: self,\n+ key_bytes,\n+ value_marker,\n+ }))\n+ }\n+ None => {\n+ // Key doesn't exist.\n+ Ok(DurableEntry::Vacant(VacantEntry { map: self, key }))\n+ }\n+ }\n+ }\n+}\n+\n+/// Iterator over key-value pairs in a DurableMap\n+pub struct MapIterator<'a, K, V> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl<'a, K, V> Iterator for MapIterator<'a, K, V>\n+where\n+ K: for<'de> Deserialize<'de>,\n+ V: for<'de> Deserialize<'de>,\n+{\n+ type Item = Result<(K, V)>;\n+ \n+ fn next(&mut self) -> Option {\n+ match self.inner.next() {\n+ Some(Ok((db_key, value_bytes))) => {\n+ // Check if we're still within our prefix\n+ if !db_key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Extract the key part (skip prefix)\n+ let key_start = self.prefix.len();\n+ let key_bytes = &db_key[key_start..];\n+ \n+ // Deserialize key and value\n+ match (bincode::deserialize(key_bytes), bincode::deserialize(&value_bytes)) {\n+ (Ok(key), Ok(value)) => Some(Ok((key, value))),\n+ (Err(e), _) | (_, Err(e)) => Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => Some(Err(e.into())),\n+ None => None,\n+ }\n+ }\n+}\n+\n+/// Iterator over keys in a DurableMap\n+pub struct KeyIterator<'a, K, V> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl<'a, K, V> Iterator for KeyIterator<'a, K, V>\n+where\n+ K: for<'de> Deserialize<'de>,\n+{\n+ type Item = Result;\n+ \n+ fn next(&mut self) -> Option {\n+ match self.inner.next() {\n+ Some(Ok((db_key, _))) => {\n+ // Check if we're still within our prefix\n+ if !db_key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Extract the key part (skip prefix)\n+ let key_start = self.prefix.len();\n+ let key_bytes = &db_key[key_start..];\n+ \n+ // Deserialize key\n+ match bincode::deserialize(key_bytes) {\n+ Ok(key) => Some(Ok(key)),\n+ Err(e) => Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => Some(Err(e.into())),\n+ None => None,\n+ }\n+ }\n+}\n+\n+/// Iterator over values in a DurableMap\n+pub struct ValueIterator<'a, K, V> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData<(K, V)>,\n+}\n+\n+impl<'a, K, V> Iterator for ValueIterator<'a, K, V>\n+where\n+ V: for<'de> Deserialize<'de>,\n+{\n+ type Item = Result;\n+ \n+ fn next(&mut self) -> Option {\n+ match self.inner.next() {\n+ Some(Ok((db_key, value_bytes))) => {\n+ // Check if we're still within our prefix\n+ if !db_key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Deserialize value\n+ match bincode::deserialize(&value_bytes) {\n+ Ok(value) => Some(Ok(value)),\n+ Err(e) => Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => Some(Err(e.into())),\n+ None => None,\n+ }\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use tempfile::TempDir;\n+ use std::collections::HashMap;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ #[test]\n+ fn test_insert_and_get() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"test_map\").unwrap();\n+ \n+ // Insert some values\n+ assert_eq!(map.insert(\"one\".to_string(), 1).unwrap(), None);\n+ assert_eq!(map.insert(\"two\".to_string(), 2).unwrap(), None);\n+ assert_eq!(map.insert(\"three\".to_string(), 3).unwrap(), None);\n+ \n+ // Get values\n+ assert_eq!(map.get(&\"one\".to_string()).unwrap(), Some(1));\n+ assert_eq!(map.get(&\"two\".to_string()).unwrap(), Some(2));\n+ assert_eq!(map.get(&\"three\".to_string()).unwrap(), Some(3));\n+ assert_eq!(map.get(&\"four\".to_string()).unwrap(), None);\n+ \n+ // Update existing value\n+ assert_eq!(map.insert(\"two\".to_string(), 22).unwrap(), Some(2));\n+ assert_eq!(map.get(&\"two\".to_string()).unwrap(), Some(22));\n+ }\n+ \n+ #[test]\n+ fn test_remove() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"remove_map\").unwrap();\n+ \n+ // Insert and remove\n+ map.insert(\"key\".to_string(), \"value\".to_string()).unwrap();\n+ assert_eq!(map.remove(&\"key\".to_string()).unwrap(), Some(\"value\".to_string()));\n+ assert_eq!(map.remove(&\"key\".to_string()).unwrap(), None);\n+ assert_eq!(map.get(&\"key\".to_string()).unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn test_contains_key() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"contains_map\").unwrap();\n+ \n+ map.insert(42, \"answer\".to_string()).unwrap();\n+ \n+ assert!(map.contains_key(&42).unwrap());\n+ assert!(!map.contains_key(&43).unwrap());\n+ }\n+ \n+ #[test]\n+ fn test_len_and_clear() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"len_map\").unwrap();\n+ \n+ // Empty map\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ \n+ // Add items\n+ for i in 0..10 {\n+ map.insert(i, i * 2).unwrap();\n+ }\n+ assert_eq!(map.len().unwrap(), 10);\n+ assert!(!map.is_empty().unwrap());\n+ \n+ // Clear\n+ map.clear().unwrap();\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ }\n+ \n+ #[test]\n+ fn test_persistence() {\n+ let (temp_dir, db) = setup_test_db();\n+ \n+ // Create and populate map\n+ {\n+ let mut map = DurableMap::>::new(&db, \"persist_map\").unwrap();\n+ map.insert(\"binary\".to_string(), vec![1, 2, 3, 4, 5]).unwrap();\n+ map.insert(\"data\".to_string(), vec![10, 20, 30]).unwrap();\n+ }\n+ \n+ // Drop the database\n+ drop(db);\n+ \n+ // Reopen and verify data persists\n+ {\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ let map = DurableMap::>::new(&db, \"persist_map\").unwrap();\n+ \n+ assert_eq!(map.get(&\"binary\".to_string()).unwrap(), Some(vec![1, 2, 3, 4, 5]));\n+ assert_eq!(map.get(&\"data\".to_string()).unwrap(), Some(vec![10, 20, 30]));\n+ assert_eq!(map.len().unwrap(), 2);\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_iteration() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"iter_map\").unwrap();\n+ \n+ // Insert data\n+ let data = vec![\n+ (\"apple\".to_string(), 1),\n+ (\"banana\".to_string(), 2),\n+ (\"cherry\".to_string(), 3),\n+ ];\n+ \n+ for (k, v) in &data {\n+ map.insert(k.clone(), *v).unwrap();\n+ }\n+ \n+ // Test iter()\n+ let mut items = map.to_vec().unwrap();\n+ items.sort_by_key(|(k, _)| k.clone());\n+ assert_eq!(items, data);\n+ \n+ // Test keys()\n+ let mut keys = map.keys_vec().unwrap();\n+ keys.sort();\n+ assert_eq!(keys, vec![\"apple\", \"banana\", \"cherry\"]);\n+ \n+ // Test values()\n+ let mut values = map.values_vec().unwrap();\n+ values.sort();\n+ assert_eq!(values, vec![1, 2, 3]);\n+ }\n+ \n+ #[test]\n+ fn test_extend() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"extend_map\").unwrap();\n+ \n+ // Extend from iterator\n+ let data: HashMap = vec![\n+ (1, \"one\".to_string()),\n+ (2, \"two\".to_string()),\n+ (3, \"three\".to_string()),\n+ ].into_iter().collect();\n+ \n+ map.extend(data.clone()).unwrap();\n+ \n+ // Verify all items were inserted\n+ for (k, v) in data {\n+ assert_eq!(map.get(&k).unwrap(), Some(v));\n+ }\n+ assert_eq!(map.len().unwrap(), 3);\n+ }\n+ \n+ #[test]\n+ fn test_complex_keys() {\n+ use serde::{Serialize, Deserialize};\n+ \n+ #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]\n+ struct ComplexKey {\n+ id: u64,\n+ name: String,\n+ }\n+ \n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"complex_map\").unwrap();\n+ \n+ let key1 = ComplexKey { id: 1, name: \"first\".to_string() };\n+ let key2 = ComplexKey { id: 2, name: \"second\".to_string() };\n+ \n+ map.insert(key1.clone(), \"value1\".to_string()).unwrap();\n+ map.insert(key2.clone(), \"value2\".to_string()).unwrap();\n+ \n+ assert_eq!(map.get(&key1).unwrap(), Some(\"value1\".to_string()));\n+ assert_eq!(map.get(&key2).unwrap(), Some(\"value2\".to_string()));\n+ }\n+ \n+ #[test]\n+ fn test_multiple_maps_same_db() {\n+ let (_temp, db) = setup_test_db();\n+ \n+ let mut map1 = DurableMap::::new(&db, \"map1\").unwrap();\n+ let mut map2 = DurableMap::::new(&db, \"map2\").unwrap();\n+ \n+ // Insert different data\n+ map1.insert(\"shared_key\".to_string(), 100).unwrap();\n+ map2.insert(\"shared_key\".to_string(), 200).unwrap();\n+ \n+ // Verify isolation\n+ assert_eq!(map1.get(&\"shared_key\".to_string()).unwrap(), Some(100));\n+ assert_eq!(map2.get(&\"shared_key\".to_string()).unwrap(), Some(200));\n+ }\n+ \n+ #[test]\n+ fn test_streaming_iterators() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"stream_map\").unwrap();\n+ \n+ // Insert test data\n+ let data = vec![\n+ (\"alice\".to_string(), 100),\n+ (\"bob\".to_string(), 200),\n+ (\"charlie\".to_string(), 300),\n+ ];\n+ \n+ for (k, v) in &data {\n+ map.insert(k.clone(), *v).unwrap();\n+ }\n+ \n+ // Test streaming iteration\n+ let mut collected = Vec::new();\n+ for item in map.iter() {\n+ let (k, v) = item.unwrap();\n+ collected.push((k, v));\n+ }\n+ collected.sort_by_key(|(k, _)| k.clone());\n+ assert_eq!(collected, data);\n+ \n+ // Test keys iterator\n+ let mut keys = Vec::new();\n+ for key in map.keys() {\n+ keys.push(key.unwrap());\n+ }\n+ keys.sort();\n+ assert_eq!(keys, vec![\"alice\", \"bob\", \"charlie\"]);\n+ \n+ // Test values iterator\n+ let mut values = Vec::new();\n+ for value in map.values() {\n+ values.push(value.unwrap());\n+ }\n+ values.sort();\n+ assert_eq!(values, vec![100, 200, 300]);\n+ \n+ // Test that iterators properly handle prefix boundaries\n+ let mut map2 = DurableMap::::new(&db, \"stream_map2\").unwrap();\n+ map2.insert(\"dave\".to_string(), 400).unwrap();\n+ \n+ // Each iterator should only see its own data\n+ let collected1: Vec<_> = map.iter().map(Result::unwrap).collect();\n+ let collected2: Vec<_> = map2.iter().map(Result::unwrap).collect();\n+ \n+ assert_eq!(collected1.len(), 3);\n+ assert_eq!(collected2.len(), 1);\n+ assert_eq!(collected2[0], (\"dave\".to_string(), 400));\n+ }\n+ \n+ #[test]\n+ fn test_metadata_length_tracking() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"length_map\").unwrap();\n+ \n+ // Empty map\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ \n+ // Insert operations should update length\n+ map.insert(\"key1\".to_string(), \"value1\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 1);\n+ \n+ map.insert(\"key2\".to_string(), \"value2\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 2);\n+ \n+ // Updating existing key should not change length\n+ map.insert(\"key1\".to_string(), \"new_value1\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 2);\n+ \n+ // Remove operations should update length\n+ map.remove(&\"key1\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 1);\n+ \n+ // Removing non-existent key should not change length\n+ map.remove(&\"non_existent\".to_string()).unwrap();\n+ assert_eq!(map.len().unwrap(), 1);\n+ \n+ // Extend should update length correctly\n+ let data = vec![\n+ (\"key3\".to_string(), \"value3\".to_string()),\n+ (\"key4\".to_string(), \"value4\".to_string()),\n+ (\"key5\".to_string(), \"value5\".to_string()),\n+ ];\n+ map.extend(data).unwrap();\n+ assert_eq!(map.len().unwrap(), 4); // key2 + 3 new keys\n+ \n+ // Extend with existing keys should only count new ones\n+ let mixed_data = vec![\n+ (\"key2\".to_string(), \"updated_value2\".to_string()), // existing\n+ (\"key6\".to_string(), \"value6\".to_string()), // new\n+ ];\n+ map.extend(mixed_data).unwrap();\n+ assert_eq!(map.len().unwrap(), 5); // only key6 was new\n+ \n+ // Clear should reset length to 0\n+ map.clear().unwrap();\n+ assert_eq!(map.len().unwrap(), 0);\n+ assert!(map.is_empty().unwrap());\n+ }\n+ \n+ #[test]\n+ fn test_put_method() {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"put_map\").unwrap();\n+ \n+ // Put new entries\n+ map.put(\"a\".to_string(), 1).unwrap();\n+ map.put(\"b\".to_string(), 2).unwrap();\n+ map.put(\"c\".to_string(), 3).unwrap();\n+ \n+ // Verify entries exist and length is correct\n+ assert_eq!(map.get(&\"a\".to_string()).unwrap(), Some(1));\n+ assert_eq!(map.get(&\"b\".to_string()).unwrap(), Some(2));\n+ assert_eq!(map.get(&\"c\".to_string()).unwrap(), Some(3));\n+ assert_eq!(map.len().unwrap(), 3);\n+ \n+ // Update existing entry with put\n+ map.put(\"b\".to_string(), 20).unwrap();\n+ assert_eq!(map.get(&\"b\".to_string()).unwrap(), Some(20));\n+ assert_eq!(map.len().unwrap(), 3); // Length should not change\n+ \n+ // Compare put vs insert performance characteristics\n+ // put() doesn't return old value but is more efficient\n+ map.put(\"d\".to_string(), 4).unwrap();\n+ assert_eq!(map.len().unwrap(), 4);\n+ \n+ // insert() returns old value\n+ let old = map.insert(\"d\".to_string(), 40).unwrap();\n+ assert_eq!(old, Some(4));\n+ assert_eq!(map.len().unwrap(), 4);\n+ }\n+}\n+\n+#[cfg(all(test, not(miri)))]\n+mod proptests {\n+ use super::*;\n+ use proptest::prelude::*;\n+ use tempfile::TempDir;\n+ use std::collections::HashMap;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ proptest! {\n+ #[test]\n+ fn prop_insert_get_consistency(data: HashMap) {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"prop_map\").unwrap();\n+ \n+ // Insert all pairs\n+ for (k, v) in &data {\n+ map.insert(k.clone(), *v).unwrap();\n+ }\n+ \n+ // Verify all can be retrieved\n+ for (k, v) in &data {\n+ prop_assert_eq!(map.get(k).unwrap(), Some(*v));\n+ }\n+ \n+ // Verify length\n+ prop_assert_eq!(map.len().unwrap(), data.len());\n+ }\n+ \n+ #[test]\n+ fn prop_remove_consistency(data: HashMap) {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"remove_map\").unwrap();\n+ \n+ // Insert all\n+ map.extend(data.clone()).unwrap();\n+ \n+ // Remove all and verify\n+ for (k, v) in data {\n+ prop_assert_eq!(map.remove(&k).unwrap(), Some(v));\n+ prop_assert_eq!(map.remove(&k).unwrap(), None);\n+ prop_assert!(!map.contains_key(&k).unwrap());\n+ }\n+ \n+ prop_assert!(map.is_empty().unwrap());\n+ }\n+ \n+ #[test]\n+ fn prop_clear_makes_empty(data: HashMap) {\n+ let (_temp, db) = setup_test_db();\n+ let mut map = DurableMap::::new(&db, \"clear_map\").unwrap();\n+ \n+ map.extend(data).unwrap();\n+ map.clear().unwrap();\n+ \n+ prop_assert_eq!(map.len().unwrap(), 0);\n+ prop_assert!(map.is_empty().unwrap());\n+ prop_assert_eq!(map.to_vec().unwrap(), vec![]);\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_nested_collections() {\n+ use crate::DurableVec;\n+ \n+ let (_temp, db) = setup_test_db();\n+ \n+ // Create a map where values are DurableVec\n+ let users_posts: DurableMap> = DurableMap::new_nested(&db, \"user_posts\");\n+ \n+ // Test creating nested collections through the entry API\n+ let mut alice_posts = users_posts.entry(\"alice\".to_string()).unwrap().or_default().unwrap();\n+ alice_posts.push(101).unwrap();\n+ alice_posts.push(102).unwrap();\n+ alice_posts.push(103).unwrap();\n+ \n+ // Test accessing the same collection again\n+ let alice_posts_again = users_posts.entry(\"alice\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(alice_posts_again.len().unwrap(), 3);\n+ assert_eq!(alice_posts_again.get(0).unwrap(), Some(101));\n+ assert_eq!(alice_posts_again.get(1).unwrap(), Some(102));\n+ assert_eq!(alice_posts_again.get(2).unwrap(), Some(103));\n+ \n+ // Test creating a different nested collection\n+ let mut bob_posts = users_posts.entry(\"bob\".to_string()).unwrap().or_default().unwrap();\n+ bob_posts.push(201).unwrap();\n+ bob_posts.push(202).unwrap();\n+ \n+ // Verify isolation between nested collections\n+ assert_eq!(alice_posts_again.len().unwrap(), 3);\n+ assert_eq!(bob_posts.len().unwrap(), 2);\n+ \n+ // Test chained calls\n+ users_posts.entry(\"charlie\".to_string()).unwrap().or_default().unwrap().push(301).unwrap();\n+ let charlie_posts = users_posts.entry(\"charlie\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(charlie_posts.len().unwrap(), 1);\n+ assert_eq!(charlie_posts.get(0).unwrap(), Some(301));\n+ }\n+ \n+ // Note: Nested DurableMap-in-DurableMap requires implementing Serialize/Deserialize \n+ // for DurableMap, which is not straightforward since it contains database handles.\n+ // For now, let's focus on the more common case of Map-to-Vec nesting.\n+ \n+ // Deep nesting with Map -> Map -> Vec also requires DurableMap serialization\n+ // Let's skip this for now and focus on the fundamental Map -> Vec case\n+ \n+ #[test]\n+ fn test_nested_collection_persistence() {\n+ use crate::DurableVec;\n+ \n+ let (temp_dir, db) = setup_test_db();\n+ \n+ // Create nested structure and populate it\n+ {\n+ let users_data: DurableMap> = DurableMap::new_nested(&db, \"users\");\n+ let mut user1_data = users_data.entry(\"user1\".to_string()).unwrap().or_default().unwrap();\n+ user1_data.push(\"data1\".to_string()).unwrap();\n+ user1_data.push(\"data2\".to_string()).unwrap();\n+ \n+ let mut user2_data = users_data.entry(\"user2\".to_string()).unwrap().or_default().unwrap();\n+ user2_data.push(\"other_data\".to_string()).unwrap();\n+ }\n+ \n+ // Drop the database\n+ drop(db);\n+ \n+ // Reopen and verify persistence\n+ {\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ let users_data: DurableMap> = DurableMap::new_nested(&db, \"users\");\n+ \n+ let user1_data = users_data.entry(\"user1\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(user1_data.len().unwrap(), 2);\n+ assert_eq!(user1_data.get(0).unwrap(), Some(\"data1\".to_string()));\n+ assert_eq!(user1_data.get(1).unwrap(), Some(\"data2\".to_string()));\n+ \n+ let user2_data = users_data.entry(\"user2\".to_string()).unwrap().or_default().unwrap();\n+ assert_eq!(user2_data.len().unwrap(), 1);\n+ assert_eq!(user2_data.get(0).unwrap(), Some(\"other_data\".to_string()));\n+ }\n+ }\n+} \n\\ No newline at end of file\ndiff --git a/durable/src/vec.rs b/durable/src/vec.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..28d08fd0786df241aaf9c01b279708e547e4c034\n--- /dev/null\n+++ b/durable/src/vec.rs\n@@ -0,0 +1,658 @@\n+use crate::{Db, Result, DurableError, DurableCollection};\n+use rocksdb::WriteBatch;\n+use serde::{Serialize, Deserialize};\n+use std::marker::PhantomData;\n+\n+/// A persistent vector backed by RocksDB\n+pub struct DurableVec {\n+ db: Db,\n+ prefix: Vec,\n+ _phantom: PhantomData,\n+}\n+\n+impl DurableVec \n+where \n+ T: Serialize + for<'de> Deserialize<'de>\n+{\n+ /// Create a new DurableVec with the given name\n+ pub fn new(db: &Db, name: &str) -> Result {\n+ let prefix = format!(\"vec:{}\", name).into_bytes();\n+ \n+ Ok(DurableVec {\n+ db: db.clone(),\n+ prefix,\n+ _phantom: PhantomData,\n+ })\n+ }\n+ \n+ /// Create a new DurableVec from a prefix (used for nested collections)\n+ pub fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ Self {\n+ db,\n+ prefix,\n+ _phantom: PhantomData,\n+ }\n+ }\n+ \n+ /// Get the length of the vector\n+ pub fn len(&self) -> Result {\n+ let key = self.meta_key(\"len\");\n+ match self.db.rocks().get(&key)? {\n+ Some(bytes) => {\n+ if bytes.len() != 8 {\n+ return Err(DurableError::Corruption(\"Invalid length bytes size\".into()));\n+ }\n+ let len_bytes: [u8; 8] = bytes[..8].try_into()\n+ .map_err(|_| DurableError::Corruption(\"Invalid length bytes\".into()))?;\n+ Ok(u64::from_le_bytes(len_bytes) as usize)\n+ }\n+ None => Ok(0),\n+ }\n+ }\n+ \n+ /// Check if the vector is empty\n+ pub fn is_empty(&self) -> Result {\n+ Ok(self.len()? == 0)\n+ }\n+ \n+ /// Push an element to the end of the vector\n+ pub fn push(&mut self, value: T) -> Result<()> {\n+ let len = self.len()?;\n+ let mut batch = WriteBatch::default();\n+ \n+ // Serialize the value\n+ let value_bytes = bincode::serialize(&value)?;\n+ \n+ // Write the element\n+ let elem_key = self.element_key(len);\n+ batch.put(&elem_key, &value_bytes);\n+ \n+ // Update the length\n+ let new_len = (len + 1) as u64;\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &new_len.to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Get an element at the given index\n+ pub fn get(&self, index: usize) -> Result> {\n+ let len = self.len()?;\n+ if index >= len {\n+ return Ok(None);\n+ }\n+ \n+ let key = self.element_key(index);\n+ match self.db.rocks().get(&key)? {\n+ Some(bytes) => {\n+ let value = bincode::deserialize(&bytes)?;\n+ Ok(Some(value))\n+ }\n+ None => Err(DurableError::Corruption(\n+ format!(\"Element at index {} not found but index < len\", index)\n+ )),\n+ }\n+ }\n+ \n+ /// Clear all elements from the vector\n+ pub fn clear(&mut self) -> Result<()> {\n+ let len = self.len()?;\n+ let mut batch = WriteBatch::default();\n+ \n+ // Delete all elements\n+ for i in 0..len {\n+ let key = self.element_key(i);\n+ batch.delete(&key);\n+ }\n+ \n+ // Delete the length meta key\n+ let len_key = self.meta_key(\"len\");\n+ batch.delete(&len_key);\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Create a streaming iterator over the vector\n+ pub fn iter(&self) -> Result> + '_> {\n+ let prefix = self.element_prefix();\n+ let iter = self.db.rocks().iterator(rocksdb::IteratorMode::From(&prefix, rocksdb::Direction::Forward));\n+ \n+ Ok(VecIterator {\n+ inner: iter,\n+ prefix,\n+ _phantom: PhantomData,\n+ })\n+ }\n+ \n+ /// Convert the entire vector to a Vec in memory\n+ /// \n+ /// Note: This loads the entire collection into memory. For large collections,\n+ /// prefer using `iter()` which streams elements.\n+ pub fn to_vec(&self) -> Result> {\n+ let len = self.len()?;\n+ let mut result = Vec::with_capacity(len);\n+ \n+ for item in self.iter()? {\n+ result.push(item?);\n+ }\n+ \n+ Ok(result)\n+ }\n+ \n+ /// Push multiple elements in a single batch\n+ pub fn extend(&mut self, iter: I) -> Result<()>\n+ where\n+ I: IntoIterator\n+ {\n+ let mut batch = WriteBatch::default();\n+ let mut len = self.len()?;\n+ \n+ for value in iter {\n+ let value_bytes = bincode::serialize(&value)?;\n+ let elem_key = self.element_key(len);\n+ batch.put(&elem_key, &value_bytes);\n+ len += 1;\n+ }\n+ \n+ // Update length\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(len as u64).to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(())\n+ }\n+ \n+ /// Remove and return the last element\n+ pub fn pop(&mut self) -> Result> {\n+ let len = self.len()?;\n+ if len == 0 {\n+ return Ok(None);\n+ }\n+ \n+ let last_idx = len - 1;\n+ let value = self.get(last_idx)?;\n+ \n+ let mut batch = WriteBatch::default();\n+ \n+ // Delete the last element\n+ let elem_key = self.element_key(last_idx);\n+ batch.delete(&elem_key);\n+ \n+ // Update length\n+ let len_key = self.meta_key(\"len\");\n+ batch.put(&len_key, &(last_idx as u64).to_le_bytes());\n+ \n+ // Commit atomically\n+ self.db.rocks().write(batch)?;\n+ self.db.rocks().flush_wal(true)?;\n+ \n+ Ok(value)\n+ }\n+ \n+ // Helper methods\n+ \n+ fn element_key(&self, index: usize) -> Vec {\n+ let mut key = self.prefix.clone();\n+ key.push(b':');\n+ key.extend_from_slice(&(index as u64).to_be_bytes());\n+ key\n+ }\n+ \n+ fn meta_key(&self, meta_type: &str) -> Vec {\n+ let mut key = self.prefix.clone();\n+ key.extend_from_slice(b\":__meta:\");\n+ key.extend_from_slice(meta_type.as_bytes());\n+ key\n+ }\n+ \n+ fn element_prefix(&self) -> Vec {\n+ let mut prefix = self.prefix.clone();\n+ prefix.push(b':');\n+ prefix\n+ }\n+}\n+\n+// Implement the DurableCollection trait for DurableVec\n+impl DurableCollection for DurableVec \n+where\n+ T: Serialize + for<'de> Deserialize<'de>\n+{\n+ fn from_prefix(db: Db, prefix: Vec) -> Self {\n+ DurableVec::from_prefix(db, prefix)\n+ }\n+}\n+\n+/// Iterator over a DurableVec\n+pub struct VecIterator<'a, T> {\n+ inner: rocksdb::DBIterator<'a>,\n+ prefix: Vec,\n+ _phantom: PhantomData,\n+}\n+\n+impl<'a, T> Iterator for VecIterator<'a, T>\n+where\n+ T: for<'de> Deserialize<'de>\n+{\n+ type Item = Result;\n+ \n+ fn next(&mut self) -> Option {\n+ loop {\n+ match self.inner.next() {\n+ Some(Ok((key, value))) => {\n+ // Check if we're still within our prefix\n+ if !key.starts_with(&self.prefix) {\n+ return None;\n+ }\n+ \n+ // Check if this is a meta key (skip it)\n+ // The key pattern is: prefix:element_index or prefix:__meta:type\n+ // We want to skip any key that contains \"__meta:\"\n+ if key.windows(7).any(|w| w == b\"__meta:\") {\n+ continue; // Skip this key and try the next one\n+ }\n+ \n+ // Deserialize the value\n+ match bincode::deserialize(&value) {\n+ Ok(item) => return Some(Ok(item)),\n+ Err(e) => return Some(Err(e.into())),\n+ }\n+ }\n+ Some(Err(e)) => return Some(Err(e.into())),\n+ None => return None,\n+ }\n+ }\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use tempfile::TempDir;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ #[test]\n+ fn test_push_and_get() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"test_vec\").unwrap();\n+ \n+ // Push some values\n+ vec.push(\"first\".to_string()).unwrap();\n+ vec.push(\"second\".to_string()).unwrap();\n+ vec.push(\"third\".to_string()).unwrap();\n+ \n+ // Check length\n+ assert_eq!(vec.len().unwrap(), 3);\n+ \n+ // Get values\n+ assert_eq!(vec.get(0).unwrap(), Some(\"first\".to_string()));\n+ assert_eq!(vec.get(1).unwrap(), Some(\"second\".to_string()));\n+ assert_eq!(vec.get(2).unwrap(), Some(\"third\".to_string()));\n+ assert_eq!(vec.get(3).unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn test_persistence() {\n+ let (temp_dir, db) = setup_test_db();\n+ \n+ // Create and populate vector\n+ {\n+ let mut vec = DurableVec::::new(&db, \"persist_vec\").unwrap();\n+ vec.push(42).unwrap();\n+ vec.push(100).unwrap();\n+ vec.push(-7).unwrap();\n+ }\n+ \n+ // Drop the database\n+ drop(db);\n+ \n+ // Reopen and verify data persists\n+ {\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ let vec = DurableVec::::new(&db, \"persist_vec\").unwrap();\n+ \n+ assert_eq!(vec.len().unwrap(), 3);\n+ assert_eq!(vec.get(0).unwrap(), Some(42));\n+ assert_eq!(vec.get(1).unwrap(), Some(100));\n+ assert_eq!(vec.get(2).unwrap(), Some(-7));\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_clear() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"clear_vec\").unwrap();\n+ \n+ // Add some elements\n+ vec.extend(vec![1, 2, 3, 4, 5]).unwrap();\n+ assert_eq!(vec.len().unwrap(), 5);\n+ \n+ // Clear\n+ vec.clear().unwrap();\n+ assert_eq!(vec.len().unwrap(), 0);\n+ assert!(vec.is_empty().unwrap());\n+ \n+ // Should be able to push again\n+ vec.push(42).unwrap();\n+ assert_eq!(vec.len().unwrap(), 1);\n+ assert_eq!(vec.get(0).unwrap(), Some(42));\n+ }\n+ \n+ #[test]\n+ fn test_pop() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"pop_vec\").unwrap();\n+ \n+ // Empty pop\n+ assert_eq!(vec.pop().unwrap(), None);\n+ \n+ // Push and pop\n+ vec.push(\"a\".to_string()).unwrap();\n+ vec.push(\"b\".to_string()).unwrap();\n+ vec.push(\"c\".to_string()).unwrap();\n+ \n+ assert_eq!(vec.pop().unwrap(), Some(\"c\".to_string()));\n+ assert_eq!(vec.len().unwrap(), 2);\n+ assert_eq!(vec.pop().unwrap(), Some(\"b\".to_string()));\n+ assert_eq!(vec.len().unwrap(), 1);\n+ assert_eq!(vec.pop().unwrap(), Some(\"a\".to_string()));\n+ assert_eq!(vec.len().unwrap(), 0);\n+ assert_eq!(vec.pop().unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn test_iteration() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"iter_vec\").unwrap();\n+ \n+ // Add elements\n+ let values = vec![10, 20, 30, 40, 50];\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Iterate and collect\n+ let collected = vec.to_vec().unwrap();\n+ \n+ assert_eq!(collected, values);\n+ }\n+ \n+ #[test]\n+ fn test_extend() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"extend_vec\").unwrap();\n+ \n+ // Extend with iterator\n+ vec.extend(vec![\"a\", \"b\", \"c\"].into_iter().map(String::from)).unwrap();\n+ assert_eq!(vec.len().unwrap(), 3);\n+ \n+ // Extend again\n+ vec.extend(vec![\"d\", \"e\"].into_iter().map(String::from)).unwrap();\n+ assert_eq!(vec.len().unwrap(), 5);\n+ \n+ // Verify all elements\n+ let all = vec.to_vec().unwrap();\n+ assert_eq!(all, vec![\"a\", \"b\", \"c\", \"d\", \"e\"]);\n+ }\n+ \n+ #[test]\n+ fn test_large_dataset() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"large_vec\").unwrap();\n+ \n+ // Push many elements\n+ let count = 1000;\n+ for i in 0..count {\n+ vec.push(i).unwrap();\n+ }\n+ \n+ assert_eq!(vec.len().unwrap(), count as usize);\n+ \n+ // Verify some random accesses\n+ assert_eq!(vec.get(0).unwrap(), Some(0));\n+ assert_eq!(vec.get(500).unwrap(), Some(500));\n+ assert_eq!(vec.get(999).unwrap(), Some(999));\n+ assert_eq!(vec.get(1000).unwrap(), None);\n+ \n+ // Verify iteration count\n+ let all_values = vec.to_vec().unwrap();\n+ assert_eq!(all_values.len(), count as usize);\n+ }\n+ \n+ #[test] \n+ fn test_complex_types() {\n+ use serde::{Serialize, Deserialize};\n+ \n+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+ struct User {\n+ id: u64,\n+ name: String,\n+ email: String,\n+ active: bool,\n+ }\n+ \n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"users\").unwrap();\n+ \n+ let user1 = User {\n+ id: 1,\n+ name: \"Alice\".to_string(),\n+ email: \"alice@example.com\".to_string(),\n+ active: true,\n+ };\n+ \n+ let user2 = User {\n+ id: 2,\n+ name: \"Bob\".to_string(),\n+ email: \"bob@example.com\".to_string(),\n+ active: false,\n+ };\n+ \n+ vec.push(user1.clone()).unwrap();\n+ vec.push(user2.clone()).unwrap();\n+ \n+ assert_eq!(vec.get(0).unwrap(), Some(user1));\n+ assert_eq!(vec.get(1).unwrap(), Some(user2));\n+ }\n+ \n+ #[test]\n+ fn test_empty_vec_operations() {\n+ let (_temp, db) = setup_test_db();\n+ let vec = DurableVec::::new(&db, \"empty_vec\").unwrap();\n+ \n+ // Test operations on empty vec\n+ assert_eq!(vec.len().unwrap(), 0);\n+ assert!(vec.is_empty().unwrap());\n+ assert_eq!(vec.get(0).unwrap(), None);\n+ assert_eq!(vec.get(100).unwrap(), None);\n+ assert_eq!(vec.to_vec().unwrap(), Vec::::new());\n+ }\n+ \n+ #[test]\n+ fn test_multiple_vecs_same_db() {\n+ let (_temp, db) = setup_test_db();\n+ \n+ // Create multiple vectors with different names\n+ let mut vec1 = DurableVec::::new(&db, \"vec1\").unwrap();\n+ let mut vec2 = DurableVec::::new(&db, \"vec2\").unwrap();\n+ \n+ // Push different data to each\n+ vec1.push(\"vec1_data\".to_string()).unwrap();\n+ vec2.push(\"vec2_data\".to_string()).unwrap();\n+ \n+ // Verify they don't interfere\n+ assert_eq!(vec1.get(0).unwrap(), Some(\"vec1_data\".to_string()));\n+ assert_eq!(vec2.get(0).unwrap(), Some(\"vec2_data\".to_string()));\n+ assert_eq!(vec1.len().unwrap(), 1);\n+ assert_eq!(vec2.len().unwrap(), 1);\n+ }\n+ \n+ #[test]\n+ fn test_batch_atomicity() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"batch_vec\").unwrap();\n+ \n+ // Add initial data\n+ vec.push(1).unwrap();\n+ vec.push(2).unwrap();\n+ vec.push(3).unwrap();\n+ \n+ // Verify initial state\n+ assert_eq!(vec.len().unwrap(), 3);\n+ \n+ // Clear should be atomic - either all elements deleted or none\n+ vec.clear().unwrap();\n+ assert_eq!(vec.len().unwrap(), 0);\n+ \n+ // Extend should be atomic - either all elements added or none\n+ vec.extend(vec![10, 20, 30, 40, 50]).unwrap();\n+ assert_eq!(vec.len().unwrap(), 5);\n+ let all = vec.to_vec().unwrap();\n+ assert_eq!(all, vec![10, 20, 30, 40, 50]);\n+ }\n+ \n+ #[test]\n+ fn test_unicode_strings() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"unicode_vec\").unwrap();\n+ \n+ let test_strings = vec![\n+ \"Hello, 世界!\".to_string(),\n+ \"🦀 Rust 🚀\".to_string(),\n+ \"Ñoño\".to_string(),\n+ \"🏴‍☠️ Pirates\".to_string(),\n+ ];\n+ \n+ vec.extend(test_strings.clone()).unwrap();\n+ \n+ let retrieved = vec.to_vec().unwrap();\n+ assert_eq!(retrieved, test_strings);\n+ }\n+ \n+ #[test]\n+ fn test_streaming_iterator() {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"stream_vec\").unwrap();\n+ \n+ // Add test data\n+ let values = vec![1, 2, 3, 4, 5];\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Test streaming iteration\n+ let mut collected = Vec::new();\n+ for item in vec.iter().unwrap() {\n+ collected.push(item.unwrap());\n+ }\n+ \n+ assert_eq!(collected, values);\n+ \n+ // Test that iterator properly handles prefix boundaries\n+ let mut vec2 = DurableVec::::new(&db, \"stream_vec2\").unwrap();\n+ vec2.extend(vec![10, 20, 30]).unwrap();\n+ \n+ // Each iterator should only see its own data\n+ let collected1: Vec<_> = vec.iter().unwrap().collect::>>().unwrap();\n+ let collected2: Vec<_> = vec2.iter().unwrap().collect::>>().unwrap();\n+ \n+ assert_eq!(collected1, values);\n+ assert_eq!(collected2, vec![10, 20, 30]);\n+ }\n+}\n+\n+#[cfg(all(test, not(miri)))] // Skip proptest under miri\n+mod proptests {\n+ use super::*;\n+ use proptest::prelude::*;\n+ use tempfile::TempDir;\n+ \n+ fn setup_test_db() -> (TempDir, Db) {\n+ let temp_dir = TempDir::new().unwrap();\n+ let db = Db::open(temp_dir.path()).unwrap();\n+ (temp_dir, db)\n+ }\n+ \n+ proptest! {\n+ #[test]\n+ fn prop_push_get_consistency(values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"prop_vec\").unwrap();\n+ \n+ // Push all values\n+ for value in &values {\n+ vec.push(*value).unwrap();\n+ }\n+ \n+ // Verify length\n+ prop_assert_eq!(vec.len().unwrap(), values.len());\n+ \n+ // Verify all values can be retrieved correctly\n+ for (i, expected) in values.iter().enumerate() {\n+ prop_assert_eq!(vec.get(i).unwrap(), Some(*expected));\n+ }\n+ }\n+ \n+ #[test]\n+ fn prop_extend_iter_roundtrip(values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"extend_vec\").unwrap();\n+ \n+ // Extend with all values\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Get back via iteration\n+ let retrieved = vec.to_vec().unwrap();\n+ \n+ prop_assert_eq!(retrieved, values);\n+ }\n+ \n+ #[test]\n+ fn prop_pop_removes_last(mut values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"pop_vec\").unwrap();\n+ \n+ // Add all values\n+ vec.extend(values.clone()).unwrap();\n+ \n+ // Pop values and verify\n+ while let Some(expected) = values.pop() {\n+ let popped = vec.pop().unwrap();\n+ prop_assert_eq!(popped, Some(expected));\n+ prop_assert_eq!(vec.len().unwrap(), values.len());\n+ }\n+ \n+ // Vector should be empty\n+ prop_assert!(vec.is_empty().unwrap());\n+ prop_assert_eq!(vec.pop().unwrap(), None);\n+ }\n+ \n+ #[test]\n+ fn prop_clear_makes_empty(values: Vec) {\n+ let (_temp, db) = setup_test_db();\n+ let mut vec = DurableVec::::new(&db, \"clear_vec\").unwrap();\n+ \n+ // Add values\n+ vec.extend(values).unwrap();\n+ \n+ // Clear\n+ vec.clear().unwrap();\n+ \n+ // Should be empty\n+ prop_assert_eq!(vec.len().unwrap(), 0);\n+ prop_assert!(vec.is_empty().unwrap());\n+ prop_assert_eq!(vec.get(0).unwrap(), None);\n+ }\n+ }\n+} \n\\ No newline at end of file\ndiff --git a/durable/todo.tdsl b/durable/todo.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..6c9df9cbb25bb72d17aac07296a0c935ccbe89e7\n--- /dev/null\n+++ b/durable/todo.tdsl\n@@ -0,0 +1,4 @@\n+Add streaming iterators to avoid loading entire collections\n+Implement collection nesting (e.g., DurableMap>)\n+Add benchmarks to measure performance\n+Implement schema versioning and migrations\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 6fb7bf52fa58f46f5e8fb0f7fd395247b57506d7..34672bd7acb9d165eea290e779c548fa4d2b8e3d 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -22,6 +22,7 @@ async-stream = \"0.3\"\n futures-util = { version = \"0.3\", default-features = false, features = [\"std\"] }\n rand = \"0.8\"\n urlencoding = \"2\"\n+durable = { path = \"../durable\" }\n \n [dev-dependencies]\n reqwest = { version = \"0.12\", features = [\"json\"] }\ndiff --git a/server/src/entity_store.rs b/server/src/entity_store.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..be3300529e97801a4d42f06f3f669b78ddd6856d\n--- /dev/null\n+++ b/server/src/entity_store.rs\n@@ -0,0 +1,90 @@\n+//! Off-heap storage for full entity payloads (Reddit API JSON).\n+//!\n+//! Derived [`crate::reducer::EntityData`] stays in the in-memory tree; raw JSON\n+//! lives in RocksDB via the workspace `durable` crate.\n+\n+use std::path::Path;\n+use std::sync::{Arc, Mutex};\n+\n+use durable::{Db, DurableMap};\n+use serde_json::Value;\n+\n+use crate::path_types::ItemId;\n+\n+#[derive(Debug, thiserror::Error)]\n+pub enum EntityStoreError {\n+ #[error(\"durable error: {0}\")]\n+ Durable(#[from] durable::DurableError),\n+ #[error(\"json error: {0}\")]\n+ Json(#[from] serde_json::Error),\n+ #[error(\"io error: {0}\")]\n+ Io(#[from] std::io::Error),\n+ #[error(\"store lock poisoned\")]\n+ Poisoned,\n+}\n+\n+struct EntityStoreInner {\n+ _db: Db,\n+ payloads: DurableMap,\n+}\n+\n+/// Disk-backed map of entity id → raw JSON payload.\n+#[derive(Clone)]\n+pub struct EntityStore {\n+ inner: Arc>,\n+}\n+\n+impl EntityStore {\n+ /// Open (or create) the entity database under `dir`.\n+ pub fn open(dir: &Path) -> Result {\n+ std::fs::create_dir_all(dir)?;\n+ let db = Db::open(dir)?;\n+ let payloads = DurableMap::new(&db, \"entity_payloads\")?;\n+ Ok(Self {\n+ inner: Arc::new(Mutex::new(EntityStoreInner { _db: db, payloads })),\n+ })\n+ }\n+\n+ /// Persist a payload for `id` (overwrites any existing entry).\n+ pub fn put(&self, id: &ItemId, payload: &Value) -> Result<(), EntityStoreError> {\n+ let json = serde_json::to_string(payload)?;\n+ let mut inner = self\n+ .inner\n+ .lock()\n+ .map_err(|_| EntityStoreError::Poisoned)?;\n+ inner\n+ .payloads\n+ .put(id.as_str().to_string(), json)\n+ .map_err(EntityStoreError::from)\n+ }\n+\n+ /// Load a stored payload, if present.\n+ pub fn get(&self, id: &ItemId) -> Result, EntityStoreError> {\n+ let inner = self\n+ .inner\n+ .lock()\n+ .map_err(|_| EntityStoreError::Poisoned)?;\n+ match inner.payloads.get(&id.as_str().to_string())? {\n+ Some(json) => Ok(Some(serde_json::from_str(&json)?)),\n+ None => Ok(None),\n+ }\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use serde_json::json;\n+\n+ #[test]\n+ fn round_trip_payload() {\n+ let tmp = tempfile::tempdir().unwrap();\n+ let store = EntityStore::open(tmp.path()).unwrap();\n+ let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ let payload = json!({\"kind\": \"t5\", \"data\": {\"display_name\": \"rust\"}});\n+\n+ store.put(&id, &payload).unwrap();\n+ let loaded = store.get(&id).unwrap().unwrap();\n+ assert_eq!(loaded, payload);\n+ }\n+}\ndiff --git a/server/src/event_log.rs b/server/src/event_log.rs\nindex 03c86f7f028cdf167b4943951d724da486b1b82c..838c8830b265f28a03442681575271eef539508c 100644\n--- a/server/src/event_log.rs\n+++ b/server/src/event_log.rs\n@@ -7,6 +7,12 @@ use tokio::{\n \n use crate::events::Event;\n \n+#[derive(Debug, Default)]\n+pub struct ReplayStats {\n+ pub applied: usize,\n+ pub bad_lines: usize,\n+}\n+\n #[derive(Debug, thiserror::Error)]\n pub enum EventLogError {\n #[error(\"io error: {0}\")]\n@@ -51,30 +57,87 @@ impl EventLog {\n Ok(())\n }\n \n- pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {\n+ /// Stream the log one line at a time — parse each [`Event`], apply, drop before the next line.\n+ pub async fn replay(&self, mut apply: F) -> Result\n+ where\n+ F: FnMut(Event) -> Result<(), EventLogError>,\n+ {\n+ let mut stats = ReplayStats::default();\n if !fs::try_exists(&self.path).await? {\n- return Ok((vec![], vec![]));\n+ return Ok(stats);\n }\n \n let f = fs::File::open(&self.path).await?;\n let mut reader = BufReader::new(f).lines();\n \n- let mut events = Vec::new();\n- let mut bad_lines = Vec::new();\n-\n- let mut line_no: usize = 0;\n while let Some(line) = reader.next_line().await? {\n- line_no += 1;\n let trimmed = line.trim();\n if trimmed.is_empty() {\n continue;\n }\n match serde_json::from_str::(trimmed) {\n- Ok(ev) => events.push(ev),\n- Err(_) => bad_lines.push((line_no, line)),\n+ Ok(ev) => match apply(ev) {\n+ Ok(()) => stats.applied += 1,\n+ Err(e) => return Err(e),\n+ },\n+ Err(_) => stats.bad_lines += 1,\n }\n }\n \n- Ok((events, bad_lines))\n+ Ok(stats)\n+ }\n+\n+ /// Load every event into memory. Prefer [`Self::replay`] for startup.\n+ pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {\n+ let mut events = Vec::new();\n+ let stats = self\n+ .replay(|ev| {\n+ events.push(ev);\n+ Ok(())\n+ })\n+ .await?;\n+ let _ = stats;\n+ Ok((events, vec![]))\n+ }\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use crate::events::Event;\n+\n+ #[tokio::test]\n+ async fn replay_applies_one_line_at_a_time() {\n+ let tmp = tempfile::tempdir().unwrap();\n+ let path = tmp.path().join(\"events.jsonl\");\n+ let log = EventLog::new(&path);\n+ log.append(&Event::NodeEnsured {\n+ id: \"reddit.com/r/rust\".into(),\n+ })\n+ .await\n+ .unwrap();\n+ log.append(&Event::VoteRecorded {\n+ ts: 1,\n+ a: \"a\".into(),\n+ b: \"b\".into(),\n+ ratio_left: 2,\n+ ratio_right: 1,\n+ scope: String::new(),\n+ })\n+ .await\n+ .unwrap();\n+\n+ let mut seen = Vec::new();\n+ let stats = log\n+ .replay(|ev| {\n+ seen.push(ev);\n+ Ok(())\n+ })\n+ .await\n+ .unwrap();\n+\n+ assert_eq!(stats.applied, 2);\n+ assert_eq!(stats.bad_lines, 0);\n+ assert_eq!(seen.len(), 2);\n }\n }\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 7f7e28c8ac3758de87f1f8e073b24be4132d38da..c9440a1019cb05c410eeb07b0b3ba09e4c6a6c6a 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -1,4 +1,5 @@\n pub mod api;\n+pub mod entity_store;\n pub mod event_log;\n pub mod events;\n pub mod fetch;\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 626454e5a2f638734193b5190a2beea286af85b6..28fcb83a2dfef01c76b802cec850624197b4b686 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -10,6 +10,7 @@ use serde_json::Value;\n use tokio::sync::{mpsc, oneshot, RwLock};\n \n use crate::{\n+ entity_store::EntityStore,\n event_log::EventLog,\n events::Event,\n fetch::now_ms,\n@@ -80,6 +81,7 @@ impl RedditBroker {\n pub fn spawn(\n tree: Arc>,\n event_log: Arc,\n+ entity_store: EntityStore,\n config: RedditApiConfig,\n ) -> Self {\n let (tx, rx) = mpsc::channel(100);\n@@ -104,7 +106,7 @@ impl RedditBroker {\n \"reddit worker started\"\n );\n \n- tokio::spawn(reddit_worker(rx, tree, event_log, client, config));\n+ tokio::spawn(reddit_worker(rx, tree, event_log, entity_store, client, config));\n \n Self { tx }\n }\n@@ -193,9 +195,16 @@ pub fn entity_view_from_payload(id: &ItemId, payload: &Value) -> Option Result<(), String> {\n let view = entity_view_from_payload(id, &payload);\n- tree.apply_entity_raw(id, payload, view);\n+ store.put(id, &payload).map_err(|e| e.to_string())?;\n+ tree.apply_entity(id, view);\n+ Ok(())\n }\n \n fn notify(done: Option>, result: FetchJobResult) {\n@@ -208,6 +217,7 @@ async fn reddit_worker(\n mut rx: mpsc::Receiver,\n tree: Arc>,\n event_log: Arc,\n+ entity_store: EntityStore,\n client: Client,\n config: RedditApiConfig,\n ) {\n@@ -302,14 +312,16 @@ async fn reddit_worker(\n let mut tree = tree.write().await;\n if kind == FetchKind::Children {\n let view = entity_view_from_payload(&child_id, &child_payload);\n- tree.apply_entity_under_parent(\n- &fetch_id,\n- &child_id,\n- child_payload,\n- view,\n- );\n- } else {\n- apply_entity_import(&mut tree, &child_id, child_payload);\n+ if let Err(e) = entity_store.put(&child_id, &child_payload) {\n+ write_err = Some(e.to_string());\n+ break;\n+ }\n+ tree.apply_entity_under_parent(&fetch_id, &child_id, view);\n+ } else if let Err(e) =\n+ apply_entity_import(&mut tree, &entity_store, &child_id, child_payload)\n+ {\n+ write_err = Some(e);\n+ break;\n }\n }\n written += 1;\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex fc23f41137d7df33997afe19c533505c250cc305..cf00f235d02e487439f8767d532929fe329a0084 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -1,7 +1,6 @@\n use std::collections::{HashMap, HashSet, VecDeque};\n \n use serde::{Deserialize, Serialize};\n-use serde_json::Value;\n \n use crate::path_types::ItemId;\n \n@@ -134,9 +133,8 @@ pub struct EntityData {\n #[derive(Debug, Clone, Default)]\n pub struct NodeState {\n pub id: ItemId,\n- /// Full imported API JSON (persisted in the event log).\n- pub entity_raw: Option,\n- /// Domain-specific view derived from `entity_raw` (e.g. Reddit title/author).\n+ /// Domain-specific view derived from imported payload (e.g. Reddit title/author).\n+ /// Raw JSON lives in [`crate::entity_store::EntityStore`].\n pub data: Option,\n pub children: HashSet,\n pub local_ranking: GroupState,\n@@ -206,28 +204,25 @@ impl GlobalTree {\n }\n }\n \n- pub fn apply_entity_raw(&mut self, id: &ItemId, payload: Value, view: Option) {\n+ pub fn apply_entity(&mut self, id: &ItemId, view: Option) {\n self.ensure_path(id);\n if let Some(node) = self.nodes.get_mut(id) {\n- node.entity_raw = Some(payload);\n node.data = view;\n }\n }\n \n- /// Import entity data for `id` and attach it as a direct child of `parent`\n+ /// Import entity view for `id` and attach it as a direct child of `parent`\n /// without running [`Self::ensure_path`] on `id` (avoids Reddit `/comments/`\n /// parent rules pulling intermediate path segments into the subreddit).\n pub fn apply_entity_under_parent(\n &mut self,\n parent: &ItemId,\n id: &ItemId,\n- payload: Value,\n view: Option,\n ) {\n self.ensure_path(parent);\n self.ensure_node(id);\n if let Some(node) = self.nodes.get_mut(id) {\n- node.entity_raw = Some(payload);\n node.data = view;\n }\n if let Some(p) = self.nodes.get_mut(parent) {\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 4c3008e73cc74d8483a2064a0a280e73d82a01ec..dce04022de7ad9b0ffe0bac05ea79182b616945a 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -3,6 +3,7 @@ use std::sync::Arc;\n use tokio::sync::RwLock;\n \n use crate::{\n+ entity_store::EntityStore,\n event_log::EventLog,\n events::Event,\n journal::JournalClient,\n@@ -43,6 +44,42 @@ fn parent_from_event_scope(scope: &str) -> ItemId {\n }\n }\n \n+fn apply_event(\n+ ev: Event,\n+ tree: &mut GlobalTree,\n+ entity_store: &EntityStore,\n+) -> Result<(), crate::event_log::EventLogError> {\n+ match ev {\n+ Event::VoteRecorded {\n+ ts,\n+ a,\n+ b,\n+ ratio_left,\n+ ratio_right,\n+ scope,\n+ } => {\n+ if let Some(vote) = VoteData::from_recorded(ts, &a, &b, ratio_left, ratio_right) {\n+ let parent = parent_from_event_scope(&scope);\n+ tree.apply_vote(&parent, vote);\n+ }\n+ }\n+ Event::ViewRecorded { .. } => {}\n+ Event::NodeEnsured { id } => {\n+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n+ tree.ensure_path(&parsed);\n+ }\n+ }\n+ Event::EntityImported { id, payload, .. } => {\n+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n+ if let Err(e) = apply_entity_import(tree, entity_store, &parsed, payload) {\n+ tracing::warn!(item = %id, err = %e, \"entity replay failed\");\n+ }\n+ }\n+ }\n+ }\n+ Ok(())\n+}\n+\n #[derive(Clone)]\n pub struct AppConfig {\n pub data_dir: String,\n@@ -71,6 +108,7 @@ impl AppConfig {\n pub struct AppState {\n pub cfg: Arc,\n pub event_log: Arc,\n+ pub entity_store: EntityStore,\n pub views: ViewStore,\n pub tree: Arc>,\n journal: JournalClient,\n@@ -82,48 +120,31 @@ impl AppState {\n let event_log = Arc::new(EventLog::new(cfg.event_log_path.clone()));\n let views_path = format!(\"{}/views.json\", cfg.data_dir);\n let views = ViewStore::new(&views_path);\n+ let entity_db_path = format!(\"{}/entity_db\", cfg.data_dir);\n+ let entity_store =\n+ EntityStore::open(std::path::Path::new(&entity_db_path)).expect(\"entity store\");\n \n let mut tree = GlobalTree::new();\n- if let Ok((events, _)) = event_log.load_all().await {\n- for ev in events {\n- match ev {\n- Event::VoteRecorded {\n- ts,\n- a,\n- b,\n- ratio_left,\n- ratio_right,\n- scope,\n- } => {\n- if let Some(vote) =\n- VoteData::from_recorded(ts, &a, &b, ratio_left, ratio_right)\n- {\n- let parent = parent_from_event_scope(&scope);\n- tree.apply_vote(&parent, vote);\n- }\n- }\n- Event::ViewRecorded { .. } => {}\n- Event::NodeEnsured { id } => {\n- if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n- tree.ensure_path(&parsed);\n- }\n- }\n- Event::EntityImported { id, payload, .. } => {\n- if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n- apply_entity_import(&mut tree, &parsed, payload);\n- }\n- }\n- }\n- }\n+ if let Err(e) = event_log\n+ .replay(|ev| apply_event(ev, &mut tree, &entity_store))\n+ .await\n+ {\n+ tracing::warn!(err = %e, \"event log replay failed\");\n }\n \n let tree = Arc::new(RwLock::new(tree));\n let journal = JournalClient::spawn(tree.clone(), event_log.clone());\n- let reddit = RedditBroker::spawn(tree.clone(), event_log.clone(), RedditApiConfig::from_env());\n+ let reddit = RedditBroker::spawn(\n+ tree.clone(),\n+ event_log.clone(),\n+ entity_store.clone(),\n+ RedditApiConfig::from_env(),\n+ );\n \n Self {\n cfg: Arc::new(cfg),\n event_log,\n+ entity_store,\n views,\n tree,\n journal,\n@@ -184,10 +205,10 @@ impl AppState {\n mod tests {\n use super::{normalize_scope, parse_item_param};\n use crate::{\n+ entity_store::EntityStore,\n event_log::EventLog,\n events::Event,\n path_types::ItemId,\n- reddit::apply_entity_import,\n reducer::GlobalTree,\n };\n use serde_json::json;\n@@ -197,6 +218,7 @@ mod tests {\n let tmp = tempfile::tempdir().unwrap();\n let log_path = tmp.path().join(\"events.jsonl\");\n let log = EventLog::new(log_path.to_string_lossy().into_owned());\n+ let entity_store = EntityStore::open(&tmp.path().join(\"entity_db\")).unwrap();\n let payload = json!({\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\"}});\n log.append(&Event::EntityImported {\n id: \"reddit.com/r/rust\".into(),\n@@ -207,19 +229,16 @@ mod tests {\n .unwrap();\n \n let mut tree = GlobalTree::new();\n- let (events, _) = log.load_all().await.unwrap();\n- for ev in events {\n- if let Event::EntityImported { id, payload, .. } = ev {\n- let parsed = ItemId::parse(&id).unwrap();\n- apply_entity_import(&mut tree, &parsed, payload);\n- }\n- }\n+ log.replay(|ev| super::apply_event(ev, &mut tree, &entity_store))\n+ .await\n+ .unwrap();\n let node = tree.get(&ItemId::parse(\"reddit.com/r/rust\").unwrap()).unwrap();\n assert_eq!(node.data.as_ref().unwrap().title, \"Rust\");\n- assert_eq!(\n- node.entity_raw.as_ref().unwrap()[\"data\"][\"display_name\"],\n- \"rust\"\n- );\n+ let stored = entity_store\n+ .get(&ItemId::parse(\"reddit.com/r/rust\").unwrap())\n+ .unwrap()\n+ .unwrap();\n+ assert_eq!(stored[\"data\"][\"display_name\"], \"rust\");\n }\n \n #[test]\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex 56d2db313b313ffce07eff38a8ccdaa2fbb9498f..32da054b58b97a4751a038c677d213d6c3fa7cdc 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -104,9 +104,17 @@ async fn post_ui_record_vote_morphs_ranking_and_persists() {\n let log = std::fs::read_to_string(tmp.path().join(\"events.jsonl\")).unwrap();\n assert!(log.contains(\"vote_recorded\"));\n \n+ // Replay in a fresh data dir (RocksDB locks entity_db while the server runs).\n+ let replay_tmp = TempDir::new().unwrap();\n+ std::fs::copy(\n+ tmp.path().join(\"events.jsonl\"),\n+ replay_tmp.path().join(\"events.jsonl\"),\n+ )\n+ .unwrap();\n+ let replay_data = replay_tmp.path().to_string_lossy().into_owned();\n let cfg = AppConfig {\n- data_dir: tmp.path().to_string_lossy().into_owned(),\n- event_log_path: tmp.path().join(\"events.jsonl\").to_string_lossy().into_owned(),\n+ data_dir: replay_data.clone(),\n+ event_log_path: format!(\"{replay_data}/events.jsonl\"),\n port: 0,\n };\n let state = create_app_state(cfg).await;\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[80ad7753] refactor: split canonical_path and identity; strict wire identity without @\n\n- Add canonical_path.rs (tag + item URL normalization) and identity.rs\n (parse_username/parse_agent; reject @ in API input).\n- Slim events.rs to event types only; reducer applies no identity rewriting.\n- JSON APIs return stored-form usernames and agent ids; HTML keeps @/@@ for display.\n- Optional delegate on ingest; CLI and tests use naked uuid:rig:model.\n\nMade-with: Cursor\n\nSide B — unified diff (full patch):\ndiff --git a/cli/src/main.rs b/cli/src/main.rs\nindex 5ac8e289f2b7a366b5959d9338d02f9b546f408a..630c5dea1f78c0ec9bc53e6b96234a0dc75bb705 100644\n--- a/cli/src/main.rs\n+++ b/cli/src/main.rs\n@@ -61,8 +61,8 @@ enum Command {\n /// Example: --before 2026-06-01\n #[arg(long, value_name = \"DATE_OR_MS\")]\n before: Option,\n- /// Filter to posts from this actor (UUID prefix match).\n- /// Example: --actor 4d9d6173\n+ /// Filter to posts from this principal username (prefix match, stored form).\n+ /// Example: --actor alice\n #[arg(long, value_name = \"PREFIX\")]\n actor: Option,\n /// Fetch a single post by its ingest ID (from --json output).\n@@ -75,9 +75,8 @@ enum Command {\n ///\n /// SYNTAX:\n ///\n- /// Actor (required, once per document):\n- /// @::\n- /// Example: @7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet\n+ /// Identity: human comes from the bearer token; optional AI delegate from `--delegate`\n+ /// (`uuid:rig:provider/model`). The document body is DSL only (items, votes, prose) — no `@` lines.\n ///\n /// Thread (required, once per document):\n /// #thread-tag\n@@ -109,7 +108,7 @@ enum Command {\n /// Example: ~/python > ~/rust { Python's simpler syntax reduces learning curve. }\n ///\n /// Prose (optional, anywhere):\n- /// Any line that doesn't start with @, #, or ~ is prose.\n+ /// Any line that doesn't start with # or ~ (or `http`) is prose.\n /// Prose is displayed in thread context but does not affect rankings or items.\n /// Use prose to write blog posts, reasoning, or notes within your ingest.\n ///\n@@ -125,8 +124,7 @@ enum Command {\n /// EXAMPLES:\n ///\n /// # From heredoc (recommended for agents)\n- /// npx slugsocial ingest << 'EOF'\n- /// @7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet\n+ /// npx slugsocial ingest --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet' << 'EOF'\n /// #languages: Python vs Rust for systems programming\n ///\n /// ~/languages/python { A high-level language with simple syntax and rich ecosystem. }\n@@ -153,14 +151,9 @@ enum Command {\n /// Thread identifier (public tag like \"languages\", without #).\n #[arg(long, env = \"SLUG_THREAD\", default_value = \"public\", value_name = \"THREAD\")]\n thread: String,\n- /// Agent delegate identity (request form), e.g. @@uuid:rig:provider/model\n- #[arg(\n- long,\n- env = \"SLUG_DELEGATE\",\n- default_value = \"@@00000000-0000-0000-0000-000000000000:cli:local/dev\",\n- value_name = \"DELEGATE\"\n- )]\n- delegate: String,\n+ /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests.\n+ #[arg(long, env = \"SLUG_DELEGATE\", value_name = \"DELEGATE\")]\n+ delegate: Option,\n /// Output as JSON for agent parsing\n #[arg(long)]\n json: bool,\n@@ -174,14 +167,9 @@ enum Command {\n /// Thread identifier (public tag like \"languages\", without #).\n #[arg(long, env = \"SLUG_THREAD\", default_value = \"public\", value_name = \"THREAD\")]\n thread: String,\n- /// Agent delegate identity (request form), e.g. @@uuid:rig:provider/model\n- #[arg(\n- long,\n- env = \"SLUG_DELEGATE\",\n- default_value = \"@@00000000-0000-0000-0000-000000000000:cli:local/dev\",\n- value_name = \"DELEGATE\"\n- )]\n- delegate: String,\n+ /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests.\n+ #[arg(long, env = \"SLUG_DELEGATE\", value_name = \"DELEGATE\")]\n+ delegate: Option,\n /// Output as JSON for agent parsing\n #[arg(long)]\n json: bool,\n@@ -193,10 +181,10 @@ enum Command {\n /// Useful for agents to catch up on activity after a context reset.\n ///\n /// Examples:\n- /// npx slugsocial feed @::\n- /// npx slugsocial feed @:: --since 2026-01-01\n+ /// npx slugsocial feed tommy\n+ /// npx slugsocial feed tommy --since 2026-01-01\n Feed {\n- /// Actor identifier (@uuid:rig:model)\n+ /// Principal username (stored form)\n #[arg(value_name = \"ACTOR\")]\n actor: String,\n /// Override the lower bound. Accepts Unix ms or YYYY-MM-DD.\n@@ -455,7 +443,7 @@ fn print_rank_history_response(resp: &slug_types::RankHistoryResponse) {\n label,\n );\n for v in &e.caused_by {\n- println!(\" {} {} {} {}\", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!(\" (@{})\", a)).unwrap_or_default());\n+ println!(\" {} {} {} {}\", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!(\" ({})\", a)).unwrap_or_default());\n if !v.body.is_empty() {\n println!(\" {}\", v.body.lines().next().unwrap_or(&v.body).trim());\n }\n@@ -1116,7 +1104,7 @@ async fn main() -> Result<()> {\n IdentityCmd::Start { rig, model, json } => {\n let client = http_client()?;\n let uuid = uuid::Uuid::new_v4().to_string();\n- let delegate = format!(\"@@{}:{}:{}\", uuid, rig, model);\n+ let delegate = format!(\"{uuid}:{rig}:{model}\");\n \n let start: PendingSessionStartResponse = expect_json(\n client\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex cb0faa29b834931e2c2b2f5c174c875e2e2e9346..995ce4a61d29b024c399c656134f541ecfd880cf 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -12,10 +12,8 @@ use tokio::sync::RwLock;\n \n use crate::{\n api::helpers::{api_error, now_ms, sha256_hex},\n- events::{\n- canonicalize_username, validate_agent_format, validate_username,\n- Event, TokenIssued, UserRegistered,\n- },\n+ events::{Event, TokenIssued, UserRegistered},\n+ identity::{parse_agent, parse_username},\n html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},\n state::{AppState, PendingSession},\n };\n@@ -77,9 +75,9 @@ fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result<\n Ok(username)\n }\n \n-fn issue_token_for_user(username: &str) -> (String, TokenIssued, String) {\n- // Returns: (bearer, event, canonical_username)\n- let canonical_user = canonicalize_username(username);\n+/// `stored_username` must already be in persisted shape (lowercase slug, no `@`).\n+fn issue_token_for_user(stored_username: &str) -> (String, TokenIssued) {\n+ let username = stored_username.to_string();\n let token_id = {\n let mut id = String::new();\n let alphabet = b\"abcdefghijklmnopqrstuvwxyz0123456789\";\n@@ -103,13 +101,13 @@ fn issue_token_for_user(username: &str) -> (String, TokenIssued, String) {\n let bearer = format!(\"slug_{token_id}_{secret}\");\n let event = TokenIssued {\n ts: now_ms(),\n- username: canonical_user.clone(),\n+ username: username.clone(),\n token_id,\n token_hash,\n salt,\n issued_via: \"oauth\".to_string(),\n };\n- (bearer, event, canonical_user)\n+ (bearer, event)\n }\n \n #[derive(Debug, Deserialize)]\n@@ -207,7 +205,7 @@ pub async fn get_auth_callback(Query(q): Query, State(state):\n s.provider = Some(\"google\".to_string());\n s.provider_id = Some(sub.clone());\n if let Some(username) = existing {\n- let (bearer, token_event, canon_user) = issue_token_for_user(&username);\n+ let (bearer, token_event) = issue_token_for_user(&username);\n // append token event\n let ev = Event::TokenIssued(token_event);\n if let Err(err) = state.event_log.append(&ev).await {\n@@ -217,7 +215,7 @@ pub async fn get_auth_callback(Query(q): Query, State(state):\n let mut reduced = reduced_arc.write().await;\n reduced.apply_event(ev);\n }\n- s.complete = Some((canon_user, bearer));\n+ s.complete = Some((username, bearer));\n return Redirect::temporary(&format!(\"{public_url}/auth/complete\")).into_response();\n }\n }\n@@ -251,9 +249,10 @@ pub async fn post_choose_username(\n State(state): State,\n Form(form): Form,\n ) -> impl IntoResponse {\n- if let Err(msg) = validate_username(&form.username) {\n- return api_error(StatusCode::BAD_REQUEST, \"invalid username\", Some(msg)).into_response();\n- }\n+ let canon_user = match parse_username(&form.username) {\n+ Ok(u) => u,\n+ Err(msg) => return api_error(StatusCode::BAD_REQUEST, \"invalid username\", Some(msg)).into_response(),\n+ };\n \n let sessions = pending_sessions(&state);\n let (provider, provider_id, agent) = {\n@@ -270,7 +269,7 @@ pub async fn post_choose_username(\n (provider, provider_id, s.agent.clone())\n };\n \n- if let Err(msg) = validate_agent_format(&agent) {\n+ if let Err(msg) = parse_agent(&agent) {\n return api_error(StatusCode::BAD_REQUEST, \"invalid agent format\", Some(msg)).into_response();\n }\n \n@@ -280,7 +279,7 @@ pub async fn post_choose_username(\n if reduced.users_by_provider.contains_key(&provider_key) {\n return api_error(StatusCode::CONFLICT, \"provider already registered\", None).into_response();\n }\n- if reduced.users_by_provider.values().any(|u| u == &canonicalize_username(&form.username)) {\n+ if reduced.users_by_provider.values().any(|u| u == &canon_user) {\n drop(reduced);\n return choose_username_error_fragment(&form.session, \"that username is taken — try another\").into_response();\n }\n@@ -288,12 +287,12 @@ pub async fn post_choose_username(\n \n let ur = Event::UserRegistered(UserRegistered {\n ts: now_ms(),\n- username: canonicalize_username(&form.username),\n+ username: canon_user.clone(),\n provider: provider.to_lowercase(),\n provider_id: provider_id.clone(),\n });\n \n- let (bearer, ti, canon_user) = issue_token_for_user(&form.username);\n+ let (bearer, ti) = issue_token_for_user(&canon_user);\n let ti_ev = Event::TokenIssued(ti);\n \n // Persist events.\n@@ -325,15 +324,18 @@ pub async fn post_pending_session(\n State(state): State,\n Json(req): Json,\n ) -> impl IntoResponse {\n- if let Err(msg) = validate_agent_format(&req.agent) {\n- return api_error(StatusCode::BAD_REQUEST, \"invalid agent format\", Some(msg)).into_response();\n- }\n+ let agent_naked = match parse_agent(&req.agent) {\n+ Ok(a) => a,\n+ Err(msg) => {\n+ return api_error(StatusCode::BAD_REQUEST, \"invalid agent format\", Some(msg)).into_response();\n+ }\n+ };\n let session = format!(\"p_{}\", uuid::Uuid::new_v4().simple());\n let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n let login_url = format!(\"{public_url}/auth/login?session={}\", urlencoding::encode(&session));\n let poll_url = format!(\"/api/v0/pending-session/{}\", session);\n let s = PendingSession {\n- agent: req.agent.clone(),\n+ agent: agent_naked,\n created_ts: now_ms(),\n provider: None,\n provider_id: None,\n@@ -359,7 +361,7 @@ pub async fn get_pending_session(\n return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n };\n let (complete, user, token) = match &s.complete {\n- Some((u, t)) => (true, Some(format!(\"@{}\", u)), Some(t.clone())),\n+ Some((u, t)) => (true, Some(u.clone()), Some(t.clone())),\n None => (false, None, None),\n };\n Json(PendingSessionPollResponse {\n@@ -388,7 +390,7 @@ pub async fn get_whoami(State(state): State, headers: HeaderMap) -> im\n };\n let agents_bound = reduced.agent_bindings.values().filter(|u| *u == &username).count();\n Json(WhoamiResponse {\n- user: format!(\"@{}\", username),\n+ user: username,\n agents_bound,\n })\n .into_response()\ndiff --git a/server/src/api/feed.rs b/server/src/api/feed.rs\nindex f665d34aef12f2bd6e71d1fb4a3d0b4d509aa775..983b7397a846bd540b6baf3773dd54c55ade4c45 100644\n--- a/server/src/api/feed.rs\n+++ b/server/src/api/feed.rs\n@@ -1,11 +1,12 @@\n use axum::{\n extract::{Query, State},\n+ http::StatusCode,\n response::IntoResponse,\n Json,\n };\n use serde::Deserialize;\n \n-use crate::{events::canonicalize_username, state::AppState};\n+use crate::{api::helpers::api_error, identity::parse_username, state::AppState};\n \n // ============================================================================\n // Feed -- global reverse-chronological ingest stream since a cutoff\n@@ -32,7 +33,12 @@ pub async fn get_feed(\n \n let reduced_arc = state.reduced.clone();\n let reduced = reduced_arc.read().await;\n- let actor = canonicalize_username(&q.actor);\n+ let actor = match parse_username(&q.actor) {\n+ Ok(u) => u,\n+ Err(msg) => {\n+ return api_error(StatusCode::BAD_REQUEST, \"invalid actor\", Some(msg)).into_response();\n+ }\n+ };\n let since = q.since.or_else(|| reduced.actor_last_post_ts.get(&actor).copied());\n let cutoff = since.unwrap_or(0);\n let limit = q.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);\n@@ -65,7 +71,7 @@ pub async fn get_feed(\n .collect();\n \n Json(slug_types::FeedResponse {\n- actor: format!(\"@{}\", actor),\n+ actor,\n since,\n posts,\n total,\ndiff --git a/server/src/api/forum.rs b/server/src/api/forum.rs\nindex cf134e97a3aa9f2cea5efcf5bfa2564b9fc2ea46..ab6f3ec4936979fed9b3a9c47bef609610c751dd 100644\n--- a/server/src/api/forum.rs\n+++ b/server/src/api/forum.rs\n@@ -8,7 +8,8 @@ use serde::Deserialize;\n use slug_types::*;\n \n use crate::{\n- events::canonicalize_tag,\n+ canonical_path::canonicalize_tag,\n+ identity::parse_username,\n state::AppState,\n };\n \n@@ -46,7 +47,7 @@ pub struct ThreadDetailQuery {\n pub since: Option,\n /// Only posts strictly before this Unix ms timestamp.\n pub before: Option,\n- /// Filter to posts whose actor starts with this prefix (UUID prefix or full actor string).\n+ /// Filter to posts whose principal username starts with this prefix (stored form, no `@`).\n pub actor: Option,\n /// Return the single post with this ingest ID.\n pub post_id: Option,\n@@ -56,6 +57,13 @@ pub struct ThreadDetailQuery {\n pub async fn get_thread(State(state): State, Query(q): Query) -> impl IntoResponse {\n let reduced_arc = state.reduced.clone();\n let tag = canonicalize_tag(&q.tag);\n+ let actor_prefix = match q.actor.as_deref().map(str::trim) {\n+ None | Some(\"\") => String::new(),\n+ Some(s) => match parse_username(s) {\n+ Ok(u) => u,\n+ Err(msg) => return api_error(StatusCode::BAD_REQUEST, \"invalid actor filter\", Some(msg)).into_response(),\n+ },\n+ };\n let reduced = reduced_arc.read().await;\n \n // Single post lookup by ingest ID -- return full body untruncated.\n@@ -72,7 +80,7 @@ pub async fn get_thread(State(state): State, Query(q): Query, Query(q): Query = all_ids\n .into_iter()\n .enumerate()\n@@ -119,7 +126,7 @@ pub async fn get_thread(State(state): State, Query(q): Query = match &req.delegate {\n+ None => None,\n+ Some(s) if s.trim().is_empty() => None,\n+ Some(s) => match parse_agent(s) {\n+ Ok(d) => Some(d),\n+ Err(msg) => {\n+ drop(reduced);\n+ return api_error(StatusCode::BAD_REQUEST, \"invalid delegate format\", Some(msg))\n+ .into_response();\n+ }\n+ },\n+ };\n let thread_id = canonicalize_tag(&req.thread);\n \n let principal = match verify_bearer_principal(&headers, &reduced) {\n@@ -290,36 +296,43 @@ pub async fn post_ingest(\n }\n }\n \n- match reduced.agent_bindings.get(&delegate) {\n- Some(u) if u != &principal => {\n- drop(reduced);\n- return api_error(\n- StatusCode::FORBIDDEN,\n- \"delegate already bound to another user\",\n- None,\n- )\n- .into_response();\n+ if let Some(ref d) = delegate {\n+ match reduced.agent_bindings.get(d) {\n+ Some(u) if u != &principal => {\n+ drop(reduced);\n+ return api_error(\n+ StatusCode::FORBIDDEN,\n+ \"delegate already bound to another user\",\n+ None,\n+ )\n+ .into_response();\n+ }\n+ _ => {}\n }\n- _ => {}\n }\n- let need_agent_bind = reduced.agent_bindings.get(&delegate).is_none();\n+ let need_agent_bind = delegate\n+ .as_ref()\n+ .map(|d| reduced.agent_bindings.get(d).is_none())\n+ .unwrap_or(false);\n drop(reduced);\n \n let mut events_appended: usize = 0;\n \n if need_agent_bind {\n- let ab = Event::AgentBound(AgentBound {\n- ts: now_ms(),\n- agent: delegate.clone(),\n- username: principal.clone(),\n- });\n- if let Err(err) = event_log.append(&ab).await {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, format!(\"{err}\"), None);\n- }\n- events_appended += 1;\n- {\n- let mut reduced = reduced_arc.write().await;\n- reduced.apply_event(ab);\n+ if let Some(agent_id) = delegate.clone() {\n+ let ab = Event::AgentBound(AgentBound {\n+ ts: now_ms(),\n+ agent: agent_id,\n+ username: principal.clone(),\n+ });\n+ if let Err(err) = event_log.append(&ab).await {\n+ return api_error(StatusCode::INTERNAL_SERVER_ERROR, format!(\"{err}\"), None);\n+ }\n+ events_appended += 1;\n+ {\n+ let mut reduced = reduced_arc.write().await;\n+ reduced.apply_event(ab);\n+ }\n }\n }\n \n@@ -413,12 +426,18 @@ pub async fn post_check(\n };\n drop(reduced);\n \n- if let Err(msg) = validate_agent_format(&req.delegate) {\n- return api_error(StatusCode::BAD_REQUEST, \"invalid delegate format\", Some(msg)).into_response();\n- }\n- let delegate = canonicalize_agent(&req.delegate);\n+ let delegate: Option = match &req.delegate {\n+ None => None,\n+ Some(s) if s.trim().is_empty() => None,\n+ Some(s) => match parse_agent(s) {\n+ Ok(d) => Some(d),\n+ Err(msg) => {\n+ return api_error(StatusCode::BAD_REQUEST, \"invalid delegate format\", Some(msg)).into_response();\n+ }\n+ },\n+ };\n let thread_id = canonicalize_tag(&req.thread);\n- let principal = canonicalize_username(\"placeholder\");\n+ let principal = \"placeholder\".to_string();\n \n let event = Event::Ingest(Ingest {\n ts: v.ts,\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex f1d5c139b875c268cc46c3085c332d8de31b092e..f42e907775645166c60aeae2d98c5855223a71be 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -65,7 +65,7 @@ mod tests {\n id: format!(\"test-{ts}\"),\n raw: raw.to_string(),\n principal: \"test\".to_string(),\n- delegate: \"@00000000-0000-0000-0000-000000000000:test:local/test\".to_string(),\n+ delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n thread_id: \"t\".to_string(),\n }));\n }\ndiff --git a/server/src/api/rank.rs b/server/src/api/rank.rs\nindex 059095bb512096da5e9699ff9c4b92cb7de7fa1c..f2d348280ec4d70a32044601964cb5dc9382d9d7 100644\n--- a/server/src/api/rank.rs\n+++ b/server/src/api/rank.rs\n@@ -226,7 +226,7 @@ pub async fn get_rank_history(\n let reduced = reduced_arc.read().await;\n let content = reduced.public();\n \n- let item_str = crate::events::canonicalize_item(&q.item);\n+ let item_str = crate::canonical_path::canonicalize_item(&q.item);\n let item = CanonicalItemUrl(item_str.clone());\n \n let entries = content.rank_history.get(&item).cloned().unwrap_or_default();\n@@ -238,15 +238,15 @@ pub async fn get_rank_history(\n .map(|doc| {\n doc.statements.into_iter().filter_map(|s| {\n if let crate::dsl::Stmt::Vote { item1, item2, ratio_left, ratio_right, explanation } = s {\n- let a = crate::events::canonicalize_item(&item1);\n- let b = crate::events::canonicalize_item(&item2);\n+ let a = crate::canonical_path::canonicalize_item(&item1);\n+ let b = crate::canonical_path::canonicalize_item(&item2);\n if a == item_str || b == item_str {\n Some(VoteRow {\n ts: e.ts,\n a: item_path_for_api(&a),\n b: item_path_for_api(&b),\n ratio: format!(\"{}:{}\", ratio_left, ratio_right),\n- actor: reduced.ingests_by_id.get(&e.post_id).map(|ing| format!(\"@{}\", ing.principal)),\n+ actor: reduced.ingests_by_id.get(&e.post_id).map(|ing| ing.principal.clone()),\n body: explanation,\n thread: Some(format!(\"#{}\", e.thread)),\n })\ndiff --git a/server/src/api/search.rs b/server/src/api/search.rs\nindex 8028c3117b9b22460fb9677ca969741f6849d2d0..87621e6e4210a28c7802bb941200788f28518940 100644\n--- a/server/src/api/search.rs\n+++ b/server/src/api/search.rs\n@@ -119,7 +119,7 @@ pub async fn get_search(\n .unwrap_or_else(|| \"#unknown\".to_string());\n scored_posts.push((score, ingest.ts, slug_types::SearchPostHit {\n thread,\n- actor: format!(\"@{}\", ingest.principal),\n+ actor: ingest.principal.clone(),\n snippet: snippet_around(&ingest.raw, &words, 160),\n ts: ingest.ts,\n }));\ndiff --git a/server/src/api/thread.rs b/server/src/api/thread.rs\nindex 576b0b5be2767b0c6cba5e0701e4ffc9f215029d..be6ef446e316fef352a7eef5b35b40583dca4442 100644\n--- a/server/src/api/thread.rs\n+++ b/server/src/api/thread.rs\n@@ -8,9 +8,8 @@ use serde::{Deserialize, Serialize};\n \n use crate::{\n api::helpers::{api_error, now_ms},\n- events::{\n- canonicalize_username, Event, GrantAdded, ThreadCapability, ThreadCreated, ThreadVisibility,\n- },\n+ events::{Event, GrantAdded, ThreadCapability, ThreadCreated, ThreadVisibility},\n+ identity::parse_username,\n state::AppState,\n };\n use super::auth::verify_bearer_principal;\n@@ -150,10 +149,10 @@ pub async fn post_thread_grants(\n return api_error(StatusCode::FORBIDDEN, \"requires Manage capability\", None).into_response();\n }\n \n- let target = canonicalize_username(&req.username);\n- if target.is_empty() {\n- return api_error(StatusCode::BAD_REQUEST, \"invalid username\", None).into_response();\n- }\n+ let target = match parse_username(&req.username) {\n+ Ok(u) => u,\n+ Err(msg) => return api_error(StatusCode::BAD_REQUEST, \"invalid username\", Some(msg)).into_response(),\n+ };\n if !reduced.users_by_provider.values().any(|u| u == &target) {\n return api_error(StatusCode::NOT_FOUND, format!(\"user @{target} not found\"), None).into_response();\n }\ndiff --git a/server/src/canonical_path.rs b/server/src/canonical_path.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..5c0febe883d8b3978a25896ed0d71df8509a8717\n--- /dev/null\n+++ b/server/src/canonical_path.rs\n@@ -0,0 +1,92 @@\n+//! Normalization for thread tags and ontology item URLs (DSL ↔ stored canonical form).\n+//! Not event types — see `events` and `path_types`.\n+\n+/// Thread / public tag: stored without leading `#`, lowercase.\n+pub fn canonicalize_tag(input: &str) -> String {\n+ input.trim().trim_start_matches('#').to_lowercase()\n+}\n+\n+/// Ontology item reference → canonical absolute URL on the slug host.\n+pub fn canonicalize_item(input: &str) -> String {\n+ let s = input.trim();\n+ if s.is_empty() {\n+ return String::new();\n+ }\n+\n+ if let Some(rest) = s.strip_prefix(\"https://\") {\n+ let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n+ let host = host.trim().to_lowercase();\n+ if tail.is_empty() {\n+ return format!(\"https://{}\", host);\n+ } else {\n+ return format!(\"https://{}/{}\", host, tail);\n+ }\n+ }\n+ if let Some(rest) = s.strip_prefix(\"http://\") {\n+ let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n+ let host = host.trim().to_lowercase();\n+ if tail.is_empty() {\n+ return format!(\"http://{}\", host);\n+ } else {\n+ return format!(\"http://{}/{}\", host, tail);\n+ }\n+ }\n+\n+ let is_tilde = s.starts_with(\"~/\");\n+ let rest = s.strip_prefix(\"~/\").or_else(|| s.strip_prefix(\"/\")).unwrap_or(s);\n+\n+ let tail = rest\n+ .split('/')\n+ .filter_map(|seg| {\n+ let t = seg.trim();\n+ if t.is_empty() {\n+ None\n+ } else {\n+ Some(t.to_lowercase())\n+ }\n+ })\n+ .collect::>()\n+ .join(\"/\");\n+\n+ if is_tilde {\n+ format!(\"https://slug.social/~/{}\", tail)\n+ } else if tail.is_empty() {\n+ \"https://slug.social\".to_string()\n+ } else {\n+ format!(\"https://slug.social/{}\", tail)\n+ }\n+}\n+\n+pub fn item_path_segments(input: &str) -> Vec {\n+ let canonical = canonicalize_item(input);\n+ if canonical.is_empty() {\n+ return vec![];\n+ }\n+\n+ if let Some(rest) = canonical.strip_prefix(\"https://\") {\n+ let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n+ let mut out = vec![format!(\"https://{}\", host)];\n+ out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()));\n+ return out;\n+ }\n+ if let Some(rest) = canonical.strip_prefix(\"http://\") {\n+ let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n+ let mut out = vec![format!(\"http://{}\", host)];\n+ out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()));\n+ return out;\n+ }\n+\n+ canonical\n+ .split('/')\n+ .filter(|s| !s.is_empty())\n+ .map(|s| s.to_string())\n+ .collect()\n+}\n+\n+pub fn item_parent_path(input: &str) -> Option {\n+ let segs = item_path_segments(input);\n+ if segs.len() <= 1 {\n+ return None;\n+ }\n+ Some(segs[..segs.len() - 1].join(\"/\"))\n+}\ndiff --git a/server/src/events.rs b/server/src/events.rs\nindex 7775e59974a521f5fea9361a600802898d05c6ab..26edac7505ce4e67a042a5511113efba5fea4950 100644\n--- a/server/src/events.rs\n+++ b/server/src/events.rs\n@@ -1,150 +1,5 @@\n use serde::{Deserialize, Serialize};\n \n-/// Canonical identifiers stored without sigils.\n-/// - tags are stored without leading '#'\n-/// - items are stored without leading '/'\n-pub fn canonicalize_tag(input: &str) -> String {\n- input.trim().trim_start_matches('#').to_lowercase()\n-}\n-\n-pub fn canonicalize_item(input: &str) -> String {\n- let s = input.trim();\n- if s.is_empty() {\n- return String::new();\n- }\n-\n- if let Some(rest) = s.strip_prefix(\"https://\") {\n- let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n- let host = host.trim().to_lowercase();\n- if tail.is_empty() {\n- return format!(\"https://{}\", host);\n- } else {\n- return format!(\"https://{}/{}\", host, tail);\n- }\n- }\n- if let Some(rest) = s.strip_prefix(\"http://\") {\n- let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n- let host = host.trim().to_lowercase();\n- if tail.is_empty() {\n- return format!(\"http://{}\", host);\n- } else {\n- return format!(\"http://{}/{}\", host, tail);\n- }\n- }\n-\n- let is_tilde = s.starts_with(\"~/\");\n- let rest = s.strip_prefix(\"~/\").or_else(|| s.strip_prefix(\"/\")).unwrap_or(s);\n-\n- let tail = rest\n- .split('/')\n- .filter_map(|seg| {\n- let t = seg.trim();\n- if t.is_empty() {\n- None\n- } else {\n- Some(t.to_lowercase())\n- }\n- })\n- .collect::>()\n- .join(\"/\");\n-\n- if is_tilde {\n- format!(\"https://slug.social/~/{}\", tail)\n- } else if tail.is_empty() {\n- \"https://slug.social\".to_string()\n- } else {\n- format!(\"https://slug.social/{}\", tail)\n- }\n-}\n-\n-pub fn item_path_segments(input: &str) -> Vec {\n- let canonical = canonicalize_item(input);\n- if canonical.is_empty() {\n- return vec![];\n- }\n-\n- if let Some(rest) = canonical.strip_prefix(\"https://\") {\n- let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n- let mut out = vec![format!(\"https://{}\", host)];\n- out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()));\n- return out;\n- }\n- if let Some(rest) = canonical.strip_prefix(\"http://\") {\n- let (host, tail) = rest.split_once('/').map_or((rest, \"\"), |(h, t)| (h, t));\n- let mut out = vec![format!(\"http://{}\", host)];\n- out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()));\n- return out;\n- }\n-\n- // Should be unreachable since all canonical items are now URLs\n- canonical\n- .split('/')\n- .filter(|s| !s.is_empty())\n- .map(|s| s.to_string())\n- .collect()\n-}\n-\n-pub fn item_parent_path(input: &str) -> Option {\n- let segs = item_path_segments(input);\n- if segs.len() <= 1 {\n- return None;\n- }\n- Some(segs[..segs.len() - 1].join(\"/\"))\n-}\n-\n-/// Canonical username stored without leading '@'.\n-pub fn canonicalize_username(input: &str) -> String {\n- input.trim().trim_start_matches('@').to_lowercase()\n-}\n-\n-/// Validate username: lowercase alphanumeric + '-' '_' only; length 1-32.\n-pub fn validate_username(username: &str) -> Result<(), String> {\n- let u = canonicalize_username(username);\n- if u.is_empty() || u.len() > 32 {\n- return Err(\"username must be 1-32 characters\".to_string());\n- }\n- if !u\n- .chars()\n- .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')\n- {\n- return Err(\"username must be lowercase alphanumeric with '-' or '_' only\".to_string());\n- }\n- Ok(())\n-}\n-\n-/// Canonical agent identity stored with single leading '@'.\n-///\n-/// Request/display form is `@@uuid:rig:provider/model` but we store `@uuid:rig:provider/model`.\n-pub fn canonicalize_agent(input: &str) -> String {\n- let s = input.trim();\n- let s = s.strip_prefix(\"@@\").or_else(|| s.strip_prefix('@')).unwrap_or(s);\n- format!(\"@{}\", s.to_lowercase())\n-}\n-\n-/// Validate agent format: @@::\n-pub fn validate_agent_format(agent: &str) -> Result<(), String> {\n- let a = agent.trim();\n- if !a.starts_with(\"@@\") && !a.starts_with('@') {\n- return Err(\"agent must start with @@\".to_string());\n- }\n- let a = a.strip_prefix(\"@@\").or_else(|| a.strip_prefix('@')).unwrap_or(a);\n- let parts: Vec<&str> = a.split(':').collect();\n- if parts.len() != 3 {\n- return Err(\"agent must be @@::\".to_string());\n- }\n- let (uuid_part, rig_part, model_part) = (parts[0], parts[1], parts[2]);\n- if uuid::Uuid::parse_str(uuid_part).is_err() {\n- return Err(\"agent uuid must be a valid UUID v4\".to_string());\n- }\n- if rig_part.trim().is_empty() {\n- return Err(\"agent rig must be non-empty\".to_string());\n- }\n- if !model_part.contains('/') {\n- return Err(\"agent model must be \".to_string());\n- }\n- Ok(())\n-}\n-\n #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]\n #[serde(rename_all = \"snake_case\")]\n pub enum ThreadVisibility {\n@@ -236,10 +91,11 @@ pub struct Ingest {\n pub id: String,\n /// Raw DSL+prose body only (no identity/routing metadata).\n pub raw: String,\n- /// Human principal username (no leading '@').\n+ /// Human principal username (wire and storage: no `@`).\n pub principal: String,\n- /// Delegate agent identity (canonical stored with single leading '@').\n- pub delegate: String,\n+ /// AI delegate id `uuid:rig:model` (wire and storage: no `@`). Omitted when absent.\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub delegate: Option,\n /// Thread identifier: public tag (e.g. \"languages\") or private id/slug (e.g. \"a7f2k9x/project-review\").\n pub thread_id: String,\n }\n@@ -247,5 +103,3 @@ pub struct Ingest {\n fn generate_id() -> String {\n uuid::Uuid::new_v4().to_string()\n }\n-\n-\ndiff --git a/server/src/html/editor.rs b/server/src/html/editor.rs\nindex 1d2c4be7eef4cc437cacddd0949307895f3ef018..4f206a0a895b47592df7669cd8d2fb4d83286fe7 100644\n--- a/server/src/html/editor.rs\n+++ b/server/src/html/editor.rs\n@@ -33,7 +33,7 @@ pub async fn editor_page() -> impl IntoResponse {\n p class=\"muted\" { \"write DSL, see what happens. nothing is saved.\" }\n div class=\"editor-container\" {\n textarea id=\"editor-input\" rows=\"12\" cols=\"80\"\n- placeholder=\"@your-uuid:rig:provider/model\\n#your-thread\\n\\n~/path/item-a { description }\\n~/path/item-b { description }\\n\\n~/path/item-a 3:1 ~/path/item-b { reasoning }\"\n+ placeholder=\"your-uuid:rig:provider/model\\n#your-thread\\n\\n~/path/item-a { description }\\n~/path/item-b { description }\\n\\n~/path/item-a 3:1 ~/path/item-b { reasoning }\"\n autocomplete=\"off\" autofocus {}\n div id=\"editor-status\" class=\"muted\" { \"type to check…\" }\n div id=\"editor-results\" {}\n@@ -110,7 +110,7 @@ pub async fn editor_check(\n id: uuid::Uuid::new_v4().to_string(),\n raw: form.text.clone(),\n principal: String::new(),\n- delegate: String::new(),\n+ delegate: None,\n thread_id: String::new(),\n });\n let mut simulated = { reduced_arc.read().await.clone() };\ndiff --git a/server/src/html/forum.rs b/server/src/html/forum.rs\nindex d40cc6398aaf3b2348d91498b5b3a80ed593e55c..a4e17ddf0064b08dfa221c964ccaa0eb99948b5b 100644\n--- a/server/src/html/forum.rs\n+++ b/server/src/html/forum.rs\n@@ -7,14 +7,14 @@ use serde::Deserialize;\n use maud::{html, Markup};\n \n use crate::{\n- events::canonicalize_tag,\n+ canonical_path::canonicalize_tag,\n reducer::ReducerState,\n state::AppState,\n timeago,\n };\n \n use super::{\n- actor_label, bc_threads, cli_panel, layout, now_ms,\n+ authorship_address, bc_threads, cli_panel, layout, now_ms,\n recency_class, render_linkified_with_embeds,\n };\n \n@@ -260,7 +260,7 @@ pub async fn thread_post_view(\n @let ago = timeago::timeago(now, ing.ts);\n div class=\"ingest-entry\" data-ingest-id=(ing.id) {\n div class=\"ingest-meta muted\" title=(hover) {\n- span class=\"address\" { \"@\" (actor_label(&ing.delegate)) }\n+ span class=\"address\" { (authorship_address(&ing.principal, &ing.delegate)) }\n \" · \"\n (ago)\n }\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex bddd90aa6eb288c0c97bb08f472e2934fd2fe67a..dcd7f0d4a2d7b602026b643c564d212cb084ff9f 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -5,7 +5,7 @@ use axum::{\n use maud::html;\n \n use crate::{\n- events::canonicalize_item,\n+ canonical_path::canonicalize_item,\n path_types::CanonicalItemUrl,\n ranking::{connected_components_from_voted_pairs, ranked_items_subset},\n scope_rank::{build_children_rankings, ChildrenRankings},\n@@ -14,7 +14,7 @@ use crate::{\n };\n \n use super::{\n- actor_label, bc_path, cli_panel, layout, now_ms, ratio_pct, render_linkified_with_embeds,\n+ authorship_address, bc_path, cli_panel, layout, now_ms, ratio_pct, render_linkified_with_embeds,\n breadcrumb_path::OntologyPath,\n };\n \n@@ -204,8 +204,8 @@ fn build_rank_history(\n .map(|doc| {\n doc.statements.into_iter().filter_map(|s| {\n if let crate::dsl::Stmt::Vote { item1, item2, ratio_left, ratio_right, explanation } = s {\n- let a_str = crate::events::canonicalize_item(&item1);\n- let b_str = crate::events::canonicalize_item(&item2);\n+ let a_str = crate::canonical_path::canonicalize_item(&item1);\n+ let b_str = crate::canonical_path::canonicalize_item(&item2);\n if a_str == item || b_str == item {\n Some(crate::reducer::VoteData {\n ts: e.ts,\n@@ -216,9 +216,7 @@ fn build_rank_history(\n principal: reduced.ingests_by_id.get(&e.post_id)\n .map(|ing| ing.principal.clone())\n .unwrap_or_default(),\n- delegate: reduced.ingests_by_id.get(&e.post_id)\n- .map(|ing| ing.delegate.clone())\n- .unwrap_or_default(),\n+ delegate: reduced.ingests_by_id.get(&e.post_id).and_then(|ing| ing.delegate.clone()),\n thread_id: e.thread.clone(),\n })\n } else { None }\n@@ -331,7 +329,7 @@ async fn render_scope_view(state: AppState, path: OntologyPath) -> axum::respons\n @let right_class = if v.b.as_str() == model.item { \"ratio-right current\" } else { \"ratio-right\" };\n div class=\"ont-vote-entry\" {\n div class=\"ont-vote-meta\" title=(hover) {\n- span class=\"address\" { \"@\" (actor_label(&v.delegate)) }\n+ span class=\"address\" { (authorship_address(&v.principal, &v.delegate)) }\n \" · \"\n (ago)\n }\n@@ -482,7 +480,7 @@ mod tests {\n id: format!(\"ing-{ts}\"),\n raw: raw.to_string(),\n principal: \"testuser\".to_string(),\n- delegate: \"@00000000-0000-0000-0000-000000000000:test:local/test\".to_string(),\n+ delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n thread_id: String::new(),\n }));\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 8b48a25cce79f0eefc7e29849e1a667cae4c7986..82c5993fabfeaf2ea8b9034bccaef37924100264 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -238,9 +238,9 @@ pub(super) fn bc_threads(thread_tag: Option<&str>) -> Markup {\n }\n }\n \n-/// Input is canonicalized without leading '@' (usually uuid:rig:provider/model).\n-pub(super) fn actor_label(actor: &str) -> String {\n- let a = actor.trim_start_matches('@').trim();\n+/// Short display label for a stored agent id (`uuid:rig:model`, no `@`).\n+pub(super) fn actor_label(agent_naked: &str) -> String {\n+ let a = agent_naked.trim();\n let parts: Vec<&str> = a.split(':').collect();\n if parts.len() >= 3 {\n let rig = parts[1].trim();\n@@ -258,6 +258,13 @@ pub(super) fn actor_label(actor: &str) -> String {\n a.to_string()\n }\n \n+/// HTML attribution only: human `@name`, or agent `@@uuid8:rig:model` when a delegate is present.\n+pub(super) fn authorship_address(principal: &str, delegate: &Option) -> String {\n+ match delegate {\n+ Some(d) => format!(\"@@{}\", actor_label(d)),\n+ None => format!(\"@{}\", principal),\n+ }\n+}\n \n /// Escape HTML special chars for safe injection.\n fn escape_html(s: &str) -> String {\ndiff --git a/server/src/html/search.rs b/server/src/html/search.rs\nindex f56f31546b4870d41e594d0e3ac2a4b50ba831b8..df5cb0a59ff72956f4bc94e53b86ab8337452817 100644\n--- a/server/src/html/search.rs\n+++ b/server/src/html/search.rs\n@@ -11,7 +11,7 @@ use crate::{\n timeago,\n };\n \n-use super::{actor_label, bc_segment, cli_panel, layout, now_ms};\n+use super::{authorship_address, bc_segment, cli_panel, layout, now_ms};\n \n /// Escape HTML special chars for safe injection.\n fn escape_html(s: &str) -> String {\n@@ -44,7 +44,8 @@ struct ThreadRow {\n \n struct PostRow {\n thread: String,\n- actor: String,\n+ /// Pre-formatted attribution string for display (includes `@` / `@@` from `authorship_address`).\n+ actor_display: String,\n text: String,\n ts: i64,\n }\n@@ -151,7 +152,7 @@ fn search(state: &ReducerState, q: &str, limit: usize) -> SearchResults {\n .unwrap_or_else(|| \"unknown\".to_string());\n scored_posts.push((score, PostRow {\n thread,\n- actor: ingest.principal.clone(),\n+ actor_display: authorship_address(&ingest.principal, &ingest.delegate),\n text: ingest.raw.clone(),\n ts: ingest.ts,\n }));\n@@ -321,7 +322,7 @@ fn render_search_results(results: &SearchResults, query: &str) -> Markup {\n li {\n div class=\"search-post-meta muted\" {\n a href=(format!(\"/t/{}\", r.thread)) { \"#\" (r.thread) }\n- \" · \" (actor_label(&r.actor))\n+ \" · \" (r.actor_display)\n \" · \" (timeago::timeago(now, r.ts))\n }\n div class=\"search-snippet\" {\ndiff --git a/server/src/html/tree.rs b/server/src/html/tree.rs\nindex 568a08edbb548291ab6f03b46c79cdcb4b432468..339f69af3b3279918a97b1dced62e4e3e0b528e3 100644\n--- a/server/src/html/tree.rs\n+++ b/server/src/html/tree.rs\n@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};\n use std::collections::BTreeSet;\n \n use crate::{\n- events::canonicalize_item,\n+ canonical_path::canonicalize_item,\n path_types::{CanonicalItemUrl, RelativePath},\n scope_rank::ChildrenRankings,\n state::AppState,\n@@ -702,7 +702,8 @@ pub async fn tree_select(\n mod tests {\n use super::*;\n \n- use crate::events::{canonicalize_item, Event, Ingest};\n+ use crate::canonical_path::canonicalize_item;\n+ use crate::events::{Event, Ingest};\n use crate::reducer::ReducerState;\n \n fn ingest(raw: &str) -> Event {\n@@ -711,7 +712,7 @@ mod tests {\n id: \"test-ingest\".to_string(),\n raw: raw.to_string(),\n principal: \"tester\".to_string(),\n- delegate: String::new(),\n+ delegate: None,\n thread_id: String::new(),\n })\n }\n@@ -789,9 +790,9 @@ mod tests {\n fn reducer_parent_key_for_tilde_items_is_without_trailing_slash() {\n // This is the reducer invariant that the tree view must match.\n let item = canonicalize_item(\"~/alphabet/a\");\n- assert_eq!(crate::events::item_parent_path(&item).unwrap(), \"https://slug.social/~/alphabet\");\n+ assert_eq!(crate::canonical_path::item_parent_path(&item).unwrap(), \"https://slug.social/~/alphabet\");\n let item2 = canonicalize_item(\"~/a\");\n- assert_eq!(crate::events::item_parent_path(&item2).unwrap(), \"https://slug.social/~\");\n+ assert_eq!(crate::canonical_path::item_parent_path(&item2).unwrap(), \"https://slug.social/~\");\n }\n \n #[test]\ndiff --git a/server/src/identity.rs b/server/src/identity.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..ad654ae813f8f6fe6323746e2544250687d3d47f\n--- /dev/null\n+++ b/server/src/identity.rs\n@@ -0,0 +1,66 @@\n+//! Usernames and agent delegate ids. Wire JSON and query params use **stored form only** (no `@` / `@@`).\n+//! The HTTP layer validates here; the reducer does not rewrite identity. For humans, `@name` / `@@agent`\n+//! appear only in HTML (see `html::authorship_address`).\n+\n+/// Parse username from query/body: trim, lowercase. `@` is not allowed (use `tommy`, not `@tommy`).\n+pub fn parse_username(input: &str) -> Result {\n+ let s = input.trim();\n+ if s.is_empty() {\n+ return Err(\"username must not be empty\".to_string());\n+ }\n+ if s.contains('@') {\n+ return Err(\n+ \"username must not contain '@' — use stored form (e.g. `tommy`)\".to_string(),\n+ );\n+ }\n+ let u = s.to_lowercase();\n+ validate_username_naked(&u)?;\n+ Ok(u)\n+}\n+\n+fn validate_username_naked(u: &str) -> Result<(), String> {\n+ if u.len() > 32 {\n+ return Err(\"username must be 1-32 characters\".to_string());\n+ }\n+ if !u\n+ .chars()\n+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')\n+ {\n+ return Err(\"username must be lowercase alphanumeric with '-' or '_' only\".to_string());\n+ }\n+ Ok(())\n+}\n+\n+/// Parse agent id: trim, lowercase. Must be `uuid:rig:provider/model` with no `@`.\n+pub fn parse_agent(input: &str) -> Result {\n+ let s = input.trim();\n+ if s.is_empty() {\n+ return Err(\"agent id must not be empty\".to_string());\n+ }\n+ if s.contains('@') {\n+ return Err(\n+ \"agent id must not contain '@' — use `uuid:rig:provider/model`\".to_string(),\n+ );\n+ }\n+ let a = s.to_lowercase();\n+ validate_agent_naked(&a)?;\n+ Ok(a)\n+}\n+\n+fn validate_agent_naked(a: &str) -> Result<(), String> {\n+ let parts: Vec<&str> = a.split(':').collect();\n+ if parts.len() != 3 {\n+ return Err(\"agent must be ::\".to_string());\n+ }\n+ let (uuid_part, rig_part, model_part) = (parts[0], parts[1], parts[2]);\n+ if uuid::Uuid::parse_str(uuid_part).is_err() {\n+ return Err(\"agent uuid must be a valid UUID v4\".to_string());\n+ }\n+ if rig_part.trim().is_empty() {\n+ return Err(\"agent rig must be non-empty\".to_string());\n+ }\n+ if !model_part.contains('/') {\n+ return Err(\"agent model must be \".to_string());\n+ }\n+ Ok(())\n+}\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 1820b0dd9cd2d0b6ce05789e3225e0d86a3d357a..173fbdd6b3f23c062a200b268d04491d5e030b3f 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -1,10 +1,12 @@\n #[macro_use]\n pub mod paths;\n pub mod api;\n+pub mod canonical_path;\n pub mod dsl;\n pub mod html;\n pub mod event_log;\n pub mod events;\n+pub mod identity;\n pub mod middleware;\n pub mod path_types;\n pub mod ranking;\ndiff --git a/server/src/path_types.rs b/server/src/path_types.rs\nindex 5a903de4f8309b6c3874e2cd70f2eb5650fd50a8..997597f6659c1ba2092bc5348e393bf7a8fa7ae0 100644\n--- a/server/src/path_types.rs\n+++ b/server/src/path_types.rs\n@@ -14,9 +14,9 @@ use std::fmt;\n \n use serde::{Deserialize, Serialize};\n \n-use crate::events::canonicalize_item;\n+use crate::canonical_path::canonicalize_item;\n \n-/// Canonical item identifier as produced by `events::canonicalize_item`.\n+/// Canonical item identifier as produced by `canonical_path::canonicalize_item`.\n ///\n /// In practice this is usually:\n /// - `https://slug.social/~/...` for ontology items, or\ndiff --git a/server/src/ranking.rs b/server/src/ranking.rs\nindex 70119611473a430113429eafb6d92b2bf96a3eb5..f70da0d076da2ba8f0abb5a4415babae1c2305f9 100644\n--- a/server/src/ranking.rs\n+++ b/server/src/ranking.rs\n@@ -265,7 +265,7 @@ mod tests {\n ratio_right: r,\n body: \"because\".to_string(),\n principal: \"test\".to_string(),\n- delegate: \"@00000000-0000-0000-0000-000000000000:test:local/test\".to_string(),\n+ delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n thread_id: \"untagged\".to_string(),\n }\n }\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 9ab063a340e228edaa8967ec35762809fccd5d3a..550bcda1a1068df13908d19484d86ad46d65ddf1 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -2,7 +2,8 @@ use std::collections::{HashMap, HashSet, VecDeque};\n \n use serde::{Deserialize, Serialize};\n \n-use crate::events::{canonicalize_agent, canonicalize_tag, canonicalize_username, Event, Ingest, ThreadCapability};\n+use crate::canonical_path::canonicalize_tag;\n+use crate::events::{Event, Ingest, ThreadCapability};\n use crate::path_types::CanonicalItemUrl;\n \n #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]\n@@ -21,10 +22,10 @@ pub struct VoteData {\n pub ratio_left: i32,\n pub ratio_right: i32,\n pub body: String,\n- /// Human principal username (no leading '@').\n+ /// Human principal username (no `@` in stored events).\n pub principal: String,\n- /// Agent delegate identity (stored with single leading '@').\n- pub delegate: String,\n+ /// AI delegate id, if any (no `@` in stored events).\n+ pub delegate: Option,\n /// Thread id where this vote was cast (public tag or private id/slug).\n pub thread_id: String,\n }\n@@ -99,8 +100,6 @@ impl GroupState {\n pub fn apply_vote(&mut self, mut vote: VoteData) {\n vote.a = CanonicalItemUrl::parse(vote.a.as_str()).unwrap_or(vote.a);\n vote.b = CanonicalItemUrl::parse(vote.b.as_str()).unwrap_or(vote.b);\n- vote.principal = canonicalize_username(&vote.principal);\n- vote.delegate = canonicalize_agent(&vote.delegate);\n vote.thread_id = canonicalize_tag(&vote.thread_id);\n if vote.ratio_left < 0 {\n vote.ratio_left = 0;\n@@ -189,7 +188,7 @@ pub struct ReducerState {\n pub users_by_provider: HashMap<(String, String), String>,\n /// token_id -> (username, salt, token_hash)\n pub tokens_by_id: HashMap,\n- /// agent delegate (canonical '@...') -> username\n+ /// agent id (naked `uuid:rig:model`) -> username\n pub agent_bindings: HashMap,\n \n pub ingests_by_id: HashMap,\n@@ -330,26 +329,27 @@ impl ReducerState {\n pub fn apply_event(&mut self, event: Event) {\n match event {\n Event::UserRegistered(ur) => {\n- let username = canonicalize_username(&ur.username);\n- self.users_by_provider\n- .insert((ur.provider.to_lowercase(), ur.provider_id.clone()), username);\n+ self.users_by_provider.insert(\n+ (ur.provider.to_lowercase(), ur.provider_id.clone()),\n+ ur.username,\n+ );\n }\n Event::TokenIssued(ti) => {\n- let username = canonicalize_username(&ti.username);\n- self.tokens_by_id\n- .insert(ti.token_id.clone(), (username, ti.salt.clone(), ti.token_hash.clone()));\n+ self.tokens_by_id.insert(\n+ ti.token_id.clone(),\n+ (ti.username, ti.salt.clone(), ti.token_hash.clone()),\n+ );\n }\n Event::AgentBound(ab) => {\n- let username = canonicalize_username(&ab.username);\n- let agent = canonicalize_agent(&ab.agent);\n- self.agent_bindings.insert(agent, username);\n+ if ab.agent.is_empty() {\n+ return;\n+ }\n+ self.agent_bindings.insert(ab.agent, ab.username);\n }\n Event::ThreadCreated(tc) => {\n self.threads.entry(tc.thread_id.clone()).or_default().visibility = tc.visibility;\n }\n Event::Ingest(mut ing) => {\n- ing.principal = canonicalize_username(&ing.principal);\n- ing.delegate = canonicalize_agent(&ing.delegate);\n ing.thread_id = canonicalize_tag(&ing.thread_id);\n \n self.ingests_by_id.insert(ing.id.clone(), ing.clone());\n@@ -528,7 +528,7 @@ impl ReducerState {\n let caps = self.grants\n .entry(ga.thread_id)\n .or_default()\n- .entry(canonicalize_username(&ga.username))\n+ .entry(ga.username)\n .or_default();\n for cap in ga.capabilities {\n caps.insert(cap);\n@@ -536,7 +536,7 @@ impl ReducerState {\n }\n Event::GrantRevoked(gr) => {\n if let Some(thread_grants) = self.grants.get_mut(&gr.thread_id) {\n- let username = canonicalize_username(&gr.username);\n+ let username = gr.username;\n if let Some(caps) = thread_grants.get_mut(&username) {\n for cap in &gr.capabilities {\n caps.remove(cap);\ndiff --git a/server/tests/basic.rs b/server/tests/basic.rs\nindex 8d7dd87d327c969ad953ecaa6b021a03e4d1842e..21cacd3049c9f5a307e75eefd5ebfc7bf0097b5d 100644\n--- a/server/tests/basic.rs\n+++ b/server/tests/basic.rs\n@@ -1,6 +1,7 @@\n use slugsocial_server::{\n event_log::EventLog,\n- events::{canonicalize_item, canonicalize_tag, Event, Ingest},\n+ canonical_path::{canonicalize_item, canonicalize_tag},\n+ events::{Event, Ingest},\n ranking::ranked_items,\n reducer::{GroupState, ReducerState},\n };\n@@ -15,7 +16,7 @@ fn ingest_event(ts: i64, raw: &str) -> Event {\n id: format!(\"test-{ts}\"),\n raw: raw.to_string(),\n principal: \"test\".to_string(),\n- delegate: \"@@00000000-0000-0000-0000-000000000000:test:local/test\".to_string(),\n+ delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n thread_id: \"t\".to_string(),\n })\n }\n@@ -491,7 +492,7 @@ fn reducer_negative_ratio_clamped_to_zero() {\n ratio_right: -3,\n body: \"negative\".to_string(),\n principal: \"test\".to_string(),\n- delegate: \"@@00000000-0000-0000-0000-000000000000:test:local/test\".to_string(),\n+ delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n thread_id: \"t\".to_string(),\n });\n assert_eq!(group.idx_to_item.len(), 2);\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex 24ef464631cd269889b8bb751ef0e90289443e97..eaa25f2c5cc21df3096d9b8f54c060c83207bfd6 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -87,7 +87,7 @@ async fn test_ingest_actor_with_colons_is_detected_and_validated() {\n // Old archive style: agent includes colons but UUID is only a prefix (invalid).\n // We should detect the agent line, then fail with \"invalid agent format\".\n let ingest_payload = serde_json::json!({\n- \"delegate\": \"@@aec1e31c:claudecode:anthropic/claude-sonnet-4.5\",\n+ \"delegate\": \"aec1e31c:claudecode:anthropic/claude-sonnet-4.5\",\n \"thread\": \"t\",\n \"text\": \"~/x {x}\\n\",\n });\n@@ -108,6 +108,26 @@ async fn test_ingest_actor_with_colons_is_detected_and_validated() {\n hint.to_lowercase().contains(\"uuid\"),\n \"hint should mention uuid, got: {hint}\"\n );\n+\n+ let at_payload = serde_json::json!({\n+ \"delegate\": \"@00000000-0000-0000-0000-000000000000:test:local/test\",\n+ \"thread\": \"t\",\n+ \"text\": \"~/x {x}\\n\",\n+ });\n+ let at_resp = client\n+ .post(&format!(\"http://{}/api/v0/ingest\", addr))\n+ .header(\"Authorization\", format!(\"Bearer {}\", test_bearer()))\n+ .json(&at_payload)\n+ .send()\n+ .await\n+ .unwrap();\n+ assert_eq!(at_resp.status(), reqwest::StatusCode::BAD_REQUEST);\n+ let at_body: serde_json::Value = at_resp.json().await.unwrap();\n+ let at_hint = at_body[\"hint\"].as_str().unwrap_or_default();\n+ assert!(\n+ at_hint.contains('@'),\n+ \"hint should reject '@' in delegate, got: {at_hint}\"\n+ );\n }\n \n #[tokio::test]\n@@ -117,7 +137,7 @@ async fn test_vote_endpoint() {\n \n // /api/v0/vote was removed; all votes are submitted via ingest.\n let ingest_payload = serde_json::json!({\n- \"delegate\": \"@@00000000-0000-0000-0000-000000000000:test:local/test\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\n \"thread\": \"cli\",\n \"text\": \"~/clap {cli parser}\\n~/argh {cli parser}\\n~/clap 3:1 ~/argh {because clap is more full-featured}\\n\",\n });\n@@ -143,7 +163,7 @@ async fn test_rank_endpoint() {\n \n // Ingest items + vote (vote endpoint removed).\n let ingest_payload = serde_json::json!({\n- \"delegate\": \"@@00000000-0000-0000-0000-000000000000:test:local/test\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\n \"thread\": \"langs\",\n \"text\": \"~/rust {systems}\\n~/go {concurrency}\\n~/rust 3:1 ~/go {because i prefer rust for systems work}\\n\",\n });\n@@ -180,7 +200,7 @@ async fn test_check_endpoint_does_not_commit() {\n let client = reqwest::Client::new();\n \n let check_payload = serde_json::json!({\n- \"delegate\": \"@@00000000-0000-0000-0000-000000000000:test:local/test\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\n \"thread\": \"t\",\n \"text\": \"~/a {x}\\n~/b {y}\\n~/a 2:1 ~/b {because}\\n\",\n });\n@@ -220,7 +240,7 @@ async fn test_garden_item_pair_matchup_include_threads() {\n \n // Ingest with thread_id metadata so item_threads and vote.thread_id are populated.\n let ingest_payload = serde_json::json!({\n- \"delegate\": \"@@00000000-0000-0000-0000-000000000000:test:local/test\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\n \"thread\": \"sorting-hat\",\n \"text\": \"~/sorts/insertion { O(n^2) }\\n~/sorts/mergesort { O(n log n) }\\n~/sorts/insertion 3:1 ~/sorts/mergesort { simpler for small n }\\n\",\n });\n@@ -324,7 +344,7 @@ async fn test_rank_history() {\n // First ingest: rust vs python — two votes on rust in one doc (the multi-vote case).\n ingest(\n serde_json::json!({\n- \"delegate\": \"@@00000000-0000-0000-0000-000000000001:rig:test/model\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000001:rig:test/model\",\n \"thread\": \"hist-test\",\n \"text\": \"~/hist/rust { systems }\\n~/hist/python { scripting }\\n~/hist/go { concurrency }\\n~/hist/rust 3:1 ~/hist/python { ownership over gc }\\n~/hist/rust 2:1 ~/hist/go { performance over simplicity }\\n\",\n }),\n@@ -354,7 +374,7 @@ async fn test_rank_history() {\n // Second ingest: python beats go — rust not directly touched, so python gets a new entry.\n ingest(\n serde_json::json!({\n- \"delegate\": \"@@00000000-0000-0000-0000-000000000002:rig:test/model\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000002:rig:test/model\",\n \"thread\": \"hist-test\",\n \"text\": \"~/hist/python 3:1 ~/hist/go { dynamic typing is worth it }\\n\",\n }),\n@@ -404,7 +424,7 @@ async fn pair_returns_connectivity_stats() {\n \n // Ingest 4 items with 1 vote (a vs b), leaving c and d as isolates.\n let doc = serde_json::json!({\n- \"delegate\": \"@@00000000-0000-0000-0000-000000000001:testrig:test/model\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000001:testrig:test/model\",\n \"thread\": \"connectivity-test\",\n \"text\": \"~/conn/a { item a }\\n~/conn/b { item b }\\n~/conn/c { item c }\\n~/conn/d { item d }\\n~/conn/a 3:1 ~/conn/b { a is better }\\n\",\n });\n@@ -438,7 +458,7 @@ async fn pair_returns_connectivity_stats() {\n \n // Add a vote connecting c to a — should reduce components.\n let doc2 = serde_json::json!({\n- \"delegate\": \"@@00000000-0000-0000-0000-000000000001:testrig:test/model\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000001:testrig:test/model\",\n \"thread\": \"connectivity-test\",\n \"text\": \"~/conn/c 2:1 ~/conn/a { c beats a }\\n\",\n });\ndiff --git a/test/auth.bb b/test/auth.bb\nindex b54ea509aafd2d5ee85877665982d7fc00cd8b20..611e04f1806ef81d678a91f499597fe55f691dd7 100644\n--- a/test/auth.bb\n+++ b/test/auth.bb\n@@ -155,7 +155,7 @@\n \n (println \"\\nstarting pending session…\")\n (let [start-resp (http-post-json (str base-url \"/api/v0/pending-session\")\n- {:agent \"@@00000000-0000-0000-0000-000000000000:bb:local/dev\"})\n+ {:agent \"00000000-0000-0000-0000-000000000000:bb:local/dev\"})\n _ (assert! (= 200 (:status start-resp)) \"pending-session start returns 200\")\n start-json (json/parse-string (:body start-resp) true)]\n (assert! (clojure.string/starts-with? (:session start-json) \"p_\") \"session id has p_ prefix\")\ndiff --git a/test/grants.bb b/test/grants.bb\nindex 962ff01cdc25d6d6977cc9a810c404918c20856d..f72799ae6fe099f48bdf316deee07f15882e9d45 100644\n--- a/test/grants.bb\n+++ b/test/grants.bb\n@@ -187,11 +187,11 @@\n ;; Register two users. The mock google cycles through google-user-alice then google-user-bob.\n (println \"\\nregistering alice…\")\n (let [alice-token (register-user base-url\n- \"@@00000000-0000-0000-0000-000000000001:test:local/dev\"\n+ \"00000000-0000-0000-0000-000000000001:test:local/dev\"\n \"alice\")\n _ (println \"registering bob…\")\n bob-token (register-user base-url\n- \"@@00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n \"bob\")\n \n ;; Alice creates a private thread.\n@@ -206,14 +206,14 @@\n ;; Alice (owner) can post prose to her own private thread.\n (println \"\\nalice posts prose to her private thread…\")\n (assert! (= 200 (:status (ingest! base-url alice-token thread-id\n- \"@@00000000-0000-0000-0000-000000000001:test:local/dev\"\n+ \"00000000-0000-0000-0000-000000000001:test:local/dev\"\n \"Hello from alice.\")))\n \"alice prose post succeeds\")\n \n ;; Bob has no grants at all — should get 403.\n (println \"\\nbob (no grants) tries to post prose…\")\n (assert! (= 403 (:status (ingest! base-url bob-token thread-id\n- \"@@00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n \"Hello from bob, unauthorized.\")))\n \"bob without grants gets 403\")\n \n@@ -226,7 +226,7 @@\n \n (println \"\\nbob (View only) tries to post prose…\")\n (assert! (= 403 (:status (ingest! base-url bob-token thread-id\n- \"@@00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n \"Hello from bob, view only.\")))\n \"bob with View but no Post gets 403\")\n \n@@ -239,21 +239,21 @@\n \n (println \"\\nbob (View + Post) posts prose…\")\n (assert! (= 200 (:status (ingest! base-url bob-token thread-id\n- \"@@00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n \"Hello from bob, now authorised.\")))\n \"bob with View + Post succeeds for prose\")\n \n ;; Alice defines two items and votes on them in the private thread.\n (println \"\\nalice posts items + vote to private thread…\")\n (assert! (= 200 (:status (ingest! base-url alice-token thread-id\n- \"@@00000000-0000-0000-0000-000000000001:test:local/dev\"\n+ \"00000000-0000-0000-0000-000000000001:test:local/dev\"\n \"~/fruits/apple { A crisp red apple. }\\n~/fruits/banana { A yellow banana. }\\n~/fruits/apple > ~/fruits/banana { apples are better }\")))\n \"alice vote in private thread succeeds\")\n \n ;; Bob (View + Post, no Vote) tries to vote — should be 403.\n (println \"\\nbob (no Vote) tries to vote…\")\n (assert! (= 403 (:status (ingest! base-url bob-token thread-id\n- \"@@00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n \"~/fruits/apple > ~/fruits/banana { bob's take }\")))\n \"bob without Vote gets 403\")\n \n@@ -266,7 +266,7 @@\n \n (println \"\\nbob (View + Post + Vote) votes…\")\n (assert! (= 200 (:status (ingest! base-url bob-token thread-id\n- \"@@00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n \"~/fruits/apple > ~/fruits/banana { bob's take }\")))\n \"bob with Vote succeeds\"))\n \ndiff --git a/test/integration.bb b/test/integration.bb\nindex 93db8f3102b1b3a05f3c4bd2cc9f348072fe54cc..4ff5d625840be3bf5e4162cef107a3cbd45aec4d 100644\n--- a/test/integration.bb\n+++ b/test/integration.bb\n@@ -166,7 +166,7 @@\n \n ;; 3. ingest via CLI (bearer required)\n (println \"\\ningesting .sorter document via CLI…\")\n- (bind ingest1-result (common/run-cli cli-bin base-url [\"ingest\" \"--json\" \"--thread\" \"integration-test\"] :input sorter-doc :extra-env token-env))\n+ (bind ingest1-result (common/run-cli cli-bin base-url [\"ingest\" \"--json\" \"--thread\" \"integration-test\" \"--delegate\" \"00000000-0000-0000-0000-000000000000:cli:local/dev\"] :input sorter-doc :extra-env token-env))\n (assert! (zero? (:exit ingest1-result)) \"cli ingest exits 0\")\n (bind ingest1-resp (json/parse-string (:out ingest1-result) true))\n (assert! (:ok ingest1-resp) \"ingest response ok=true\")\n@@ -237,7 +237,7 @@\n \"#integration-test\"\n \"~/languages/rust 4:1 ~/languages/python { type safety }\"\n \"~/languages/rust 3:1 ~/languages/go { zero-cost abstractions }\"]))\n- (bind hist-ingest (common/run-cli cli-bin base-url [\"ingest\" \"--json\" \"--thread\" \"integration-test\"] :input two-vote-doc :extra-env token-env))\n+ (bind hist-ingest (common/run-cli cli-bin base-url [\"ingest\" \"--json\" \"--thread\" \"integration-test\" \"--delegate\" \"00000000-0000-0000-0000-000000000000:cli:local/dev\"] :input two-vote-doc :extra-env token-env))\n (assert! (zero? (:exit hist-ingest))\n (str \"two-vote ingest exits 0 (err: \" (:err hist-ingest) \")\"))\n \ndiff --git a/test/oauth.bb b/test/oauth.bb\nindex f94583ee0f6fedfc023564ed7ee3a2986adcf660..84679e1a4adbe100dfe7e7d64a2c6270b023d275 100644\n--- a/test/oauth.bb\n+++ b/test/oauth.bb\n@@ -85,11 +85,11 @@\n stop-fn (http/run-server handler {:port port})]\n {:stop-fn stop-fn :port port}))\n \n-(def ^:private default-agent \"@@00000000-0000-0000-0000-000000000000:cli:local/dev\")\n+(def ^:private default-agent \"00000000-0000-0000-0000-000000000000:cli:local/dev\")\n \n (defn fetch-bearer-token!\n \"Simulate browser OAuth + username choice; returns `slug_…` bearer token.\n- Agent must match CLI default `SLUG_DELEGATE` for ingest binding.\"\n+ Ingest `--delegate` must match this agent string for `AgentBound` on first write.\"\n [base-url & {:keys [username agent] :or {username \"intuser\" agent default-agent}}]\n (let [start-resp (http-post-json (str base-url \"/api/v0/pending-session\")\n {:agent agent})]\ndiff --git a/types/src/lib.rs b/types/src/lib.rs\nindex a62822a36463c68fde6c5fc1aa9e4273c82ca0c8..92e6d122d573ea225f8fb86c548a81b3454425ad 100644\n--- a/types/src/lib.rs\n+++ b/types/src/lib.rs\n@@ -138,7 +138,7 @@ pub struct PostRow {\n /// Chronological index within the thread (0 = oldest).\n pub index: usize,\n pub ts: i64,\n- /// Self-declared actor (`@uuid:rig:model`).\n+ /// Principal username (stored form, no `@`).\n pub actor: String,\n pub body: String,\n pub truncated: bool,\n@@ -186,6 +186,7 @@ pub struct VoteRow {\n pub a: String,\n pub b: String,\n pub ratio: String,\n+ /// Principal username when present (stored form, no `@`).\n pub actor: Option,\n pub body: String,\n /// Thread where this vote was cast (e.g. \"#sorting-hat\").\n@@ -196,6 +197,7 @@ pub struct VoteRow {\n /// Response for the feed endpoint — all ingests since a cutoff, newest first.\n #[derive(Debug, Serialize, Deserialize)]\n pub struct FeedResponse {\n+ /// Principal username this feed is scoped to (stored form, no `@`).\n pub actor: String,\n /// The lower-bound timestamp used (actor's last ingest, ms). None if actor has never posted.\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n@@ -222,8 +224,9 @@ pub struct FeedPost {\n pub struct IngestRequest {\n /// Thread identifier: public tag (e.g. \"languages\") or private id/slug (e.g. \"a7f2k9x/project-review\").\n pub thread: String,\n- /// Agent delegate identity in request form (e.g. \"@@uuid:rig:provider/model\").\n- pub delegate: String,\n+ /// Delegate id: `uuid:rig:provider/model` (no `@`). Omit for human-only ingests.\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub delegate: Option,\n /// DSL+prose body only.\n pub text: String,\n }\n@@ -231,7 +234,7 @@ pub struct IngestRequest {\n /// Start a browser-based OAuth login flow for a CLI agent.\n #[derive(Debug, Serialize, Deserialize)]\n pub struct PendingSessionStartRequest {\n- /// Agent delegate identity in request form (e.g. \"@@uuid:rig:provider/model\").\n+ /// Delegate id: `uuid:rig:provider/model` (no `@`).\n pub agent: String,\n }\n \n@@ -255,6 +258,7 @@ pub struct PendingSessionPollResponse {\n \n #[derive(Debug, Serialize, Deserialize)]\n pub struct WhoamiResponse {\n+ /// Username (stored form, no `@`).\n pub user: String,\n pub agents_bound: usize,\n }\n","role":"user"}],"model":"~x-ai/grok-latest"}