{"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[96b6da05] rpc + reducer changes first pass\n\nSide B — unified diff (full patch):\ndiff --git a/cli/src/main.rs b/cli/src/main.rs\nindex 630c5dea1f78c0ec9bc53e6b96234a0dc75bb705..8d0442959f4332bafe499a2a8cdf364731a06871 100644\n--- a/cli/src/main.rs\n+++ b/cli/src/main.rs\n@@ -21,8 +21,9 @@ struct Cli {\n cmd: Option,\n }\n \n+/// Commands scoped to a room (`public` or `shortid/slug`).\n #[derive(Subcommand, Debug)]\n-enum Command {\n+enum ScopedCmd {\n /// Browse the garden (ontology) — light mode, ranked by votes\n Garden {\n #[command(subcommand)]\n@@ -174,6 +175,23 @@ enum Command {\n #[arg(long)]\n json: bool,\n },\n+}\n+\n+#[derive(Subcommand, Debug)]\n+enum Command {\n+ /// Public site (same as room `public`)\n+ Public {\n+ #[command(subcommand)]\n+ sub: ScopedCmd,\n+ },\n+ /// Private room id (`shortid/slug` from `room create`)\n+ Private {\n+ /// Room id, e.g. `a1b2c3d/my-project`\n+ #[arg(value_name = \"ROOM_ID\")]\n+ room: String,\n+ #[command(subcommand)]\n+ sub: ScopedCmd,\n+ },\n \n /// Show all activity since you last posted (global feed)\n ///\n@@ -628,6 +646,37 @@ fn http_client() -> Result {\n .build()?)\n }\n \n+async fn send_rpc(\n+ client: &reqwest::Client,\n+ base: &str,\n+ bearer: Option<&str>,\n+ commands: Vec,\n+) -> Result {\n+ let url = format!(\"{}/api/v0/rpc\", base.trim_end_matches('/'));\n+ let mut req = client.post(url).json(&RpcBatch(commands));\n+ if let Some(b) = bearer {\n+ req = req.header(\"Authorization\", format!(\"Bearer {}\", b));\n+ }\n+ let resp = req.send().await?;\n+ let status = resp.status();\n+ let text = resp.text().await.unwrap_or_default();\n+ if !status.is_success() {\n+ return Err(anyhow!(\"rpc HTTP {}: {}\", status, text.trim()));\n+ }\n+ serde_json::from_str(&text).map_err(|e| anyhow!(\"rpc response: {e}\"))\n+}\n+\n+fn rpc_line_ok(line: &RpcLine) -> Result<&RpcResult> {\n+ if !line.ok {\n+ let mut m = line.error.clone().unwrap_or_else(|| \"rpc error\".into());\n+ if let Some(h) = &line.hint {\n+ m.push_str(&format!(\"\\nhint: {h}\"));\n+ }\n+ return Err(anyhow!(m));\n+ }\n+ line.result.as_ref().ok_or_else(|| anyhow!(\"rpc missing result\"))\n+}\n+\n /// Normalize ontology path for API. Accepts path with or without ~/ (shell expands ~ to $HOME).\n /// Returns a bare slug path (e.g. `languages/python`) with no leading `/` or `~/`.\n /// Call `ontology_path_for_api_query` before sending `item=` / `parent=` params so the server\n@@ -729,231 +778,262 @@ fn write_secret_file(name: &str, contents: &str) -> Result<()> {\n Ok(())\n }\n \n-#[tokio::main]\n-async fn main() -> Result<()> {\n- let Cli { cmd, server } = Cli::parse();\n-\n- // If no command provided, print the guide\n- let Some(cmd) = cmd else {\n- print!(\"{}\", include_str!(\"../GUIDE.sorter\"));\n- return Ok(());\n- };\n-\n- let base = server.trim_end_matches('/');\n-\n- match cmd {\n- Command::Healthz { json } => {\n- let client = http_client()?;\n- let url = format!(\"{base}/healthz\");\n- let body = client.get(url).send().await?.text().await?;\n- if json {\n- // Wrap plain text response in a JSON object\n- println!(\"{}\", serde_json::json!({ \"ok\": true, \"body\": body.trim() }));\n- } else {\n- println!(\"{body}\");\n- }\n- }\n-\n- Command::Search { query, json } => {\n- let client = http_client()?;\n- let url = format!(\"{base}/api/v0/search?q={}\", urlencoding::encode(&query));\n- let resp: slug_types::SearchResponse = expect_json(client.get(url).send().await?).await?;\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- if !resp.items.is_empty() {\n- println!(\"items ({})\", resp.items.len());\n- for item in &resp.items {\n- print!(\" {}\", item.path);\n- if let Some(body) = &item.body {\n- let first_line = body.lines().next().unwrap_or(\"\").trim();\n- if !first_line.is_empty() {\n- print!(\" {}\", first_line);\n+async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {\n+ let room = room.trim();\n+ let client = http_client()?;\n+ match sub {\n+ ScopedCmd::Garden { sub } => match sub {\n+ GardenCmd::Tree { json } => {\n+ let batch = send_rpc(&client, base, None, vec![RpcCommand::GetLeaves { room: room.to_string() }]).await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::Leaves(resp) => {\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ for p in &resp.paths {\n+ println!(\"~/{}\", p);\n }\n }\n- println!();\n- }\n- }\n- if !resp.threads.is_empty() {\n- if !resp.items.is_empty() { println!(); }\n- println!(\"threads ({})\", resp.threads.len());\n- let now_ms = std::time::SystemTime::now()\n- .duration_since(std::time::UNIX_EPOCH)\n- .unwrap_or_default()\n- .as_millis() as i64;\n- for t in &resp.threads {\n- println!(\" {} {}n {}\", t.tag, t.post_count, slug_types::timeago::timeago(now_ms, t.last_activity));\n- }\n- }\n- if !resp.posts.is_empty() {\n- if !resp.items.is_empty() || !resp.threads.is_empty() { println!(); }\n- println!(\"posts ({})\", resp.posts.len());\n- let now_ms = std::time::SystemTime::now()\n- .duration_since(std::time::UNIX_EPOCH)\n- .unwrap_or_default()\n- .as_millis() as i64;\n- for p in &resp.posts {\n- let first_line = p.snippet.lines().next().unwrap_or(\"\").trim();\n- println!(\" {} · {} {}\", p.thread, slug_types::timeago::timeago(now_ms, p.ts), first_line);\n- }\n- }\n- if resp.items.is_empty() && resp.threads.is_empty() && resp.posts.is_empty() {\n- println!(\"no results\");\n- }\n- }\n- }\n-\n- Command::Garden { sub } => match sub {\n- GardenCmd::Tree { json } => {\n- let client = http_client()?;\n- let url = format!(\"{base}/api/v0/leaves\");\n- let builder = client.get(url);\n- let resp: LeavesResponse = expect_json(builder.send().await?).await?;\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- for p in &resp.paths {\n- println!(\"~/{}\", p);\n }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n-\n GardenCmd::Body { path, json, full } => {\n let path = normalize_ontology_path_input(&path).map_err(anyhow::Error::msg)?;\n let item_q = ontology_path_for_api_query(&path);\n- let client = http_client()?;\n- let mut url = format!(\"{base}/api/v0/item?item={}\", urlencoding::encode(&item_q));\n- if full {\n- url.push_str(\"&full=true\");\n- }\n- let builder = client.get(url);\n- let resp: ItemResponse = expect_json(builder.send().await?).await?;\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- print_item_response(&resp);\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::GetGardenItem {\n+ room: room.to_string(),\n+ item_path: item_q,\n+ full: Some(full),\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::GardenItem(resp) => {\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ print_item_response(&resp);\n+ }\n+ }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n-\n GardenCmd::Children { paths, depth, json } => {\n let paths: Vec = paths\n .iter()\n .map(|p| normalize_ontology_path_input(p).map_err(anyhow::Error::msg))\n .collect::>>()?;\n- let client = http_client()?;\n let parent_param = paths\n .iter()\n .map(|p| ontology_path_for_api_query(p))\n .collect::>()\n .join(\",\");\n- let mut url = format!(\"{base}/api/v0/rank?parent={}\", urlencoding::encode(&parent_param));\n- if let Some(d) = depth {\n- url.push_str(&format!(\"&depth={d}\"));\n- }\n- let builder = client.get(url);\n- let resp: RankResponse = expect_json(builder.send().await?).await?;\n-\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- print_rank_response(&resp);\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::GetGardenRank {\n+ room: room.to_string(),\n+ parent_path: parent_param,\n+ depth,\n+ offset: None,\n+ limit: None,\n+ percent: None,\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::GardenRank(resp) => {\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ print_rank_response(&resp);\n+ }\n+ }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n-\n GardenCmd::Pair { path, json } => {\n let path = normalize_ontology_path_input(&path).map_err(anyhow::Error::msg)?;\n let parent_q = ontology_path_for_api_query(&path);\n- let client = http_client()?;\n- let url = format!(\"{base}/api/v0/pair?parent={}\", urlencoding::encode(&parent_q));\n- let builder = client.get(url);\n- let resp: PairResponse = expect_json(builder.send().await?).await?;\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- print_pair_response(&resp);\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::GetPair {\n+ room: room.to_string(),\n+ parent_path: parent_q,\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::Pair(resp) => {\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ print_pair_response(&resp);\n+ }\n+ }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n-\n GardenCmd::Matchup { path, json } => {\n let path = normalize_ontology_path_input(&path).map_err(anyhow::Error::msg)?;\n let item_q = ontology_path_for_api_query(&path);\n- let client = http_client()?;\n- let url = format!(\"{base}/api/v0/matchup?item={}\", urlencoding::encode(&item_q));\n- let builder = client.get(url);\n- let resp: MatchupResponse = expect_json(builder.send().await?).await?;\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- print_matchup_response(&resp);\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::GetMatchup {\n+ room: room.to_string(),\n+ item_path: item_q,\n+ limit: None,\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::Matchup(resp) => {\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ print_matchup_response(&resp);\n+ }\n+ }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n-\n GardenCmd::History { path, json } => {\n let path = normalize_ontology_path_input(&path).map_err(anyhow::Error::msg)?;\n let item_q = ontology_path_for_api_query(&path);\n- let client = http_client()?;\n- let url = format!(\"{base}/api/v0/rank-history?item={}\", urlencoding::encode(&item_q));\n- let builder = client.get(url);\n- let resp: slug_types::RankHistoryResponse = expect_json(builder.send().await?).await?;\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- print_rank_history_response(&resp);\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::GetRankHistory {\n+ room: room.to_string(),\n+ item_path: item_q,\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::RankHistory(resp) => {\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ print_rank_history_response(&resp);\n+ }\n+ }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n-\n GardenCmd::Rank { limit, offset, percent, json } => {\n- let client = http_client()?;\n- let url = format!(\n- \"{base}/api/v0/global-rank?limit={limit}&offset={offset}&percent={percent}\"\n- );\n- let builder = client.get(url);\n- let resp: GlobalRankResponse = expect_json(builder.send().await?).await?;\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- print_global_rank_response(&resp);\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::GetGlobalRank {\n+ room: room.to_string(),\n+ limit: Some(limit),\n+ offset: Some(offset),\n+ percent: Some(percent),\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::GlobalRank(resp) => {\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ print_global_rank_response(&resp);\n+ }\n+ }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n },\n-\n- Command::Forum { title, json, offset, limit, since, before, actor, post } => {\n- let client = http_client()?;\n+ ScopedCmd::Forum {\n+ title,\n+ json,\n+ offset,\n+ limit,\n+ since,\n+ before,\n+ actor,\n+ post,\n+ } => {\n match title {\n None => {\n- let url = format!(\"{base}/api/v0/threads\");\n- let mut resp: ThreadsResponse =\n- expect_json(client.get(url).send().await?).await?;\n- resp.threads.truncate(10);\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- print_threads(&resp);\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::ListForumThreads {\n+ room: room.to_string(),\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::ForumThreads(resp) => {\n+ let limited = ThreadsResponse {\n+ threads: resp.threads.iter().take(10).cloned().collect(),\n+ };\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&limited)?);\n+ } else {\n+ print_threads(&limited);\n+ }\n+ }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n Some(name) => {\n let tag = normalize_thread_input(&name);\n- let mut url = format!(\"{base}/api/v0/thread?tag={}\", urlencoding::encode(&tag));\n- if let Some(o) = offset { url.push_str(&format!(\"&offset={o}\")); }\n- if let Some(l) = limit { url.push_str(&format!(\"&limit={l}\")); }\n- if let Some(s) = since { url.push_str(&format!(\"&since={}\", parse_ts(&s)?)); }\n- if let Some(b) = before { url.push_str(&format!(\"&before={}\", parse_ts(&b)?)); }\n- if let Some(a) = actor { url.push_str(&format!(\"&actor={}\", urlencoding::encode(&a))); }\n- if let Some(p) = post { url.push_str(&format!(\"&post_id={}\", urlencoding::encode(&p))); }\n- let resp: ThreadDetailResponse =\n- expect_json(client.get(url).send().await?).await?;\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- print_thread(&resp);\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::GetForumThread {\n+ room: room.to_string(),\n+ thread_tag: tag,\n+ offset,\n+ limit,\n+ since: match &since {\n+ Some(s) => Some(parse_ts(s)?),\n+ None => None,\n+ },\n+ before: match &before {\n+ Some(s) => Some(parse_ts(s)?),\n+ None => None,\n+ },\n+ actor,\n+ post_id: post,\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::ForumThread(resp) => {\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ print_thread(&resp);\n+ }\n+ }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n }\n }\n-\n- Command::Ingest { file, thread, delegate, json } => {\n- let client = http_client()?;\n-\n+ ScopedCmd::Ingest {\n+ file,\n+ thread,\n+ delegate,\n+ json,\n+ } => {\n let mut text = String::new();\n match file {\n Some(path) => {\n@@ -966,51 +1046,74 @@ async fn main() -> Result<()> {\n .context(\"failed to read stdin\")?;\n }\n }\n-\n if text.trim().is_empty() {\n return Err(anyhow!(\"no input provided (empty)\"));\n }\n-\n- let req = IngestRequest { thread, delegate, text };\n- let url = format!(\"{base}/api/v0/ingest\");\n let bearer = effective_bearer().ok_or_else(|| {\n anyhow!(\n \"no bearer token: run `slugsocial identity start --rig --model ` \\\n then `slugsocial identity poll `, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token\"\n )\n })?;\n- let builder = client\n- .post(url)\n- .json(&req)\n- .header(\"Authorization\", format!(\"Bearer {bearer}\"));\n- let resp: IngestResponse = expect_json(builder.send().await?).await?;\n- if resp.ok {\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- println!(\"✓ ingested\");\n- println!(\"events: {}\", resp.events_appended);\n- if !resp.threads.is_empty() {\n- println!(\"threads:\");\n- for t in &resp.threads {\n- println!(\" {t}\");\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ Some(&bearer),\n+ vec![RpcCommand::Post {\n+ room: room.to_string(),\n+ thread_tag: thread,\n+ delegate,\n+ text,\n+ return_rank_diff: true,\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::PostOk {\n+ events_appended,\n+ ranking_changes,\n+ threads,\n+ next,\n+ } => {\n+ if json {\n+ println!(\n+ \"{}\",\n+ serde_json::to_string_pretty(&serde_json::json!({\n+ \"ok\": true,\n+ \"events_appended\": events_appended,\n+ \"ranking_changes\": ranking_changes,\n+ \"threads\": threads,\n+ \"next\": next,\n+ }))?\n+ );\n+ } else {\n+ println!(\"✓ ingested\");\n+ println!(\"events: {}\", events_appended);\n+ if !threads.is_empty() {\n+ println!(\"threads:\");\n+ for t in threads {\n+ println!(\" {t}\");\n+ }\n+ }\n+ if let Some(ref rc) = ranking_changes {\n+ print_ranking_changes(rc);\n }\n+ print_next(&next);\n+ println!();\n+ println!(\"---\");\n+ println!(\"For your next comparison: remember to ask your human first. Their perspective is what makes your submission more than another model's take.\");\n+ println!(\"---\");\n }\n- print_ranking_changes(&resp.ranking_changes);\n- print_next(&resp.next);\n- println!();\n- println!(\"---\");\n- println!(\"For your next comparison: remember to ask your human first. Their perspective is what makes your submission more than another model's take.\");\n- println!(\"---\");\n }\n- } else {\n- return Err(anyhow!(\"ingest failed\"));\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n }\n }\n-\n- Command::Check { file, thread, delegate, json } => {\n- let client = http_client()?;\n-\n+ ScopedCmd::Check {\n+ file,\n+ thread: _thread,\n+ delegate: _delegate,\n+ json,\n+ } => {\n let mut text = String::new();\n match file {\n Some(path) => {\n@@ -1023,57 +1126,181 @@ async fn main() -> Result<()> {\n .context(\"failed to read stdin\")?;\n }\n }\n-\n if text.trim().is_empty() {\n return Err(anyhow!(\"no input provided (empty)\"));\n }\n-\n- let req = IngestRequest { thread, delegate, text };\n- let url = format!(\"{base}/api/v0/check\");\n- let builder = client.post(url).json(&req);\n- let resp: CheckResponse = expect_json(builder.send().await?).await?;\n- if resp.ok {\n- if json {\n- println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n- } else {\n- println!(\"✓ check ok (dry-run)\");\n- if !resp.threads.is_empty() {\n- println!(\"threads:\");\n- for t in &resp.threads {\n- println!(\" {t}\");\n- }\n- }\n- if resp.rankings.is_empty() {\n- println!();\n- println!(\"(no ranking touched by this doc yet)\");\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::Check {\n+ room: room.to_string(),\n+ text,\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::CheckOk {\n+ rankings,\n+ threads,\n+ next,\n+ } => {\n+ if json {\n+ println!(\n+ \"{}\",\n+ serde_json::to_string_pretty(&serde_json::json!({\n+ \"ok\": true,\n+ \"rankings\": rankings,\n+ \"threads\": threads,\n+ \"next\": next,\n+ }))?\n+ );\n } else {\n+ println!(\"✓ check ok (dry-run)\");\n+ if !threads.is_empty() {\n+ println!(\"threads:\");\n+ for t in threads {\n+ println!(\" {t}\");\n+ }\n+ }\n+ if rankings.is_empty() {\n+ println!();\n+ println!(\"(no ranking touched by this doc yet)\");\n+ } else {\n+ println!();\n+ print_check_rankings(&rankings);\n+ }\n println!();\n- print_check_rankings(&resp.rankings);\n- }\n- println!();\n- println!(\"---\");\n- println!(\"Does this submission reflect input from your human? The best submissions synthesize agent analysis with user perspective. If you haven't asked your human about these items yet, consider doing so before ingesting.\");\n- println!(\"---\");\n- if !resp.next.is_empty() {\n- println!();\n- println!(\"next:\");\n- for n in &resp.next {\n- println!(\" {n}\");\n+ println!(\"---\");\n+ println!(\"Does this submission reflect input from your human? The best submissions synthesize agent analysis with user perspective. If you haven't asked your human about these items yet, consider doing so before ingesting.\");\n+ println!(\"---\");\n+ if !next.is_empty() {\n+ println!();\n+ println!(\"next:\");\n+ for n in next {\n+ println!(\" {n}\");\n+ }\n }\n }\n }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n+ }\n+ }\n+ }\n+ Ok(())\n+}\n+\n+#[tokio::main]\n+async fn main() -> Result<()> {\n+ let Cli { cmd, server } = Cli::parse();\n+\n+ // If no command provided, print the guide\n+ let Some(cmd) = cmd else {\n+ print!(\"{}\", include_str!(\"../GUIDE.sorter\"));\n+ return Ok(());\n+ };\n+\n+ let base = server.trim_end_matches('/');\n+\n+ match cmd {\n+ Command::Public { sub } => run_scoped(base, \"public\", sub).await?,\n+ Command::Private { room, sub } => run_scoped(base, &room, sub).await?,\n+\n+ Command::Healthz { json } => {\n+ let client = http_client()?;\n+ let url = format!(\"{base}/healthz\");\n+ let body = client.get(url).send().await?.text().await?;\n+ if json {\n+ // Wrap plain text response in a JSON object\n+ println!(\"{}\", serde_json::json!({ \"ok\": true, \"body\": body.trim() }));\n } else {\n- return Err(anyhow!(\"check failed\"));\n+ println!(\"{body}\");\n }\n }\n \n- Command::Feed { actor, since, limit, json } => {\n+ Command::Search { query, json } => {\n let client = http_client()?;\n- let mut url = format!(\"{base}/api/v0/feed?actor={}&limit={}\", urlencoding::encode(&actor), limit);\n- if let Some(s) = since {\n- url.push_str(&format!(\"&since={}\", parse_ts(&s)?));\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::Search { query }],\n+ )\n+ .await?;\n+ let resp = match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::Search(s) => s,\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n+ };\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ if !resp.items.is_empty() {\n+ println!(\"items ({})\", resp.items.len());\n+ for item in &resp.items {\n+ print!(\" {}\", item.path);\n+ if let Some(body) = &item.body {\n+ let first_line = body.lines().next().unwrap_or(\"\").trim();\n+ if !first_line.is_empty() {\n+ print!(\" {}\", first_line);\n+ }\n+ }\n+ println!();\n+ }\n+ }\n+ if !resp.threads.is_empty() {\n+ if !resp.items.is_empty() { println!(); }\n+ println!(\"threads ({})\", resp.threads.len());\n+ let now_ms = std::time::SystemTime::now()\n+ .duration_since(std::time::UNIX_EPOCH)\n+ .unwrap_or_default()\n+ .as_millis() as i64;\n+ for t in &resp.threads {\n+ println!(\n+ \" {} · {} posts · {}\",\n+ t.tag,\n+ t.post_count,\n+ slug_types::timeago::timeago(now_ms, t.last_activity)\n+ );\n+ }\n+ }\n+ if !resp.posts.is_empty() {\n+ if !resp.items.is_empty() || !resp.threads.is_empty() { println!(); }\n+ println!(\"posts ({})\", resp.posts.len());\n+ let now_ms = std::time::SystemTime::now()\n+ .duration_since(std::time::UNIX_EPOCH)\n+ .unwrap_or_default()\n+ .as_millis() as i64;\n+ for p in &resp.posts {\n+ let first_line = p.snippet.lines().next().unwrap_or(\"\").trim();\n+ println!(\" {} · {} {}\", p.thread, slug_types::timeago::timeago(now_ms, p.ts), first_line);\n+ }\n+ }\n+ if resp.items.is_empty() && resp.threads.is_empty() && resp.posts.is_empty() {\n+ println!(\"no results\");\n+ }\n }\n- let resp: slug_types::FeedResponse = expect_json(client.get(url).send().await?).await?;\n+ }\n+\n+ Command::Feed { actor, since, limit, json } => {\n+ let client = http_client()?;\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ None,\n+ vec![RpcCommand::GetFeed {\n+ actor,\n+ since: match since {\n+ Some(s) => Some(parse_ts(&s)?),\n+ None => None,\n+ },\n+ limit: Some(limit),\n+ }],\n+ )\n+ .await?;\n+ let resp = match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::Feed(f) => f,\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n+ };\n if json {\n println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n } else {\ndiff --git a/server/src/api/debug.rs b/server/src/api/debug.rs\ndeleted file mode 100644\nindex ccb1730947b5ff40d2f77a5abfa05c2cde6056d9..0000000000000000000000000000000000000000\n--- a/server/src/api/debug.rs\n+++ /dev/null\n@@ -1,25 +0,0 @@\n-use axum::{response::IntoResponse, Json};\n-use axum_extra::extract::Query;\n-use serde::{Deserialize, Serialize};\n-\n-/// Debug endpoint to prove querystring decoding + repeated params behavior.\n-///\n-/// Example:\n-/// `/debug/query?open=~%2Fmodels%2Fllms&open=~%2Fmusic%2Fjazz&q=hello+world`\n-#[derive(Debug, Clone, Default, Deserialize, Serialize)]\n-pub struct DebugQuery {\n- /// Supports repeated `open=` params: `?open=a&open=b`.\n- #[serde(default)]\n- pub open: Vec,\n- /// Optional single selection.\n- #[serde(default)]\n- pub selected: Option,\n- /// A freeform string to show percent-decoding / + decoding.\n- #[serde(default)]\n- pub q: Option,\n-}\n-\n-pub async fn get_debug_query(Query(q): Query) -> impl IntoResponse {\n- Json(q)\n-}\n-\ndiff --git a/server/src/api/feed.rs b/server/src/api/feed.rs\ndeleted file mode 100644\nindex 983b7397a846bd540b6baf3773dd54c55ade4c45..0000000000000000000000000000000000000000\n--- a/server/src/api/feed.rs\n+++ /dev/null\n@@ -1,79 +0,0 @@\n-use axum::{\n- extract::{Query, State},\n- http::StatusCode,\n- response::IntoResponse,\n- Json,\n-};\n-use serde::Deserialize;\n-\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-// ============================================================================\n-\n-#[derive(Debug, Deserialize)]\n-pub struct FeedQuery {\n- pub actor: String,\n- /// Override the lower bound (ms). Defaults to actor's last ingest timestamp.\n- #[serde(default)]\n- pub since: Option,\n- #[serde(default)]\n- pub limit: Option,\n- #[serde(default)]\n- pub offset: Option,\n-}\n-\n-pub async fn get_feed(\n- State(state): State,\n- Query(q): Query,\n-) -> impl IntoResponse {\n- const DEFAULT_LIMIT: usize = 50;\n- const MAX_LIMIT: usize = 200;\n-\n- let reduced_arc = state.reduced.clone();\n- let reduced = reduced_arc.read().await;\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- let offset = q.offset.unwrap_or(0);\n-\n- // Scan ingests_ordered backwards (newest first), stop once ts <= cutoff.\n- let matching: Vec<&str> = reduced.ingests_ordered.iter().rev()\n- .map(|id| id.as_str())\n- .take_while(|id| reduced.ingests_by_id.get(*id).map_or(false, |ing| ing.ts > cutoff))\n- .collect();\n-\n- let total = matching.len();\n- let posts: Vec = matching.into_iter()\n- .skip(offset)\n- .take(limit)\n- .filter_map(|id| reduced.ingests_by_id.get(id))\n- .map(|ing| {\n- let thread = Some(ing.thread_id.clone());\n- let thread_post_index = reduced.ingests_by_thread.get(&ing.thread_id).and_then(|q| {\n- q.iter().rev().position(|id| id == &ing.id).map(|i| i + 1)\n- });\n- slug_types::FeedPost {\n- ts: ing.ts,\n- id: ing.id.clone(),\n- thread,\n- thread_post_index,\n- body: ing.raw.clone(),\n- }\n- })\n- .collect();\n-\n- Json(slug_types::FeedResponse {\n- actor,\n- since,\n- posts,\n- total,\n- }).into_response()\n-}\ndiff --git a/server/src/api/forum.rs b/server/src/api/forum.rs\ndeleted file mode 100644\nindex ab6f3ec4936979fed9b3a9c47bef609610c751dd..0000000000000000000000000000000000000000\n--- a/server/src/api/forum.rs\n+++ /dev/null\n@@ -1,142 +0,0 @@\n-use axum::{\n- extract::{Query, State},\n- http::StatusCode,\n- response::IntoResponse,\n- Json,\n-};\n-use serde::Deserialize;\n-use slug_types::*;\n-\n-use crate::{\n- canonical_path::canonicalize_tag,\n- identity::parse_username,\n- state::AppState,\n-};\n-\n-use super::helpers::api_error;\n-\n-pub async fn get_threads(State(state): State) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let reduced = reduced_arc.read().await;\n-\n- let mut out: Vec = reduced\n- .threads\n- .iter()\n- .map(|(thread, ts)| {\n- ThreadSummary {\n- thread: format!(\"#{thread}\"),\n- last_activity_ts: ts.last_activity_ts,\n- web: format!(\"https://slug.social/t/{}\", thread),\n- }\n- })\n- .collect();\n-\n- out.sort_by(|a, b| b.last_activity_ts.cmp(&a.last_activity_ts));\n- Json(ThreadsResponse { threads: out }).into_response()\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct ThreadDetailQuery {\n- pub tag: String,\n- #[serde(default)]\n- /// Chronological offset (0 = oldest). Default: 0.\n- pub offset: Option,\n- /// Number of posts to return. Default: 10, max: 500.\n- pub limit: Option,\n- /// Only posts at or after this Unix ms timestamp.\n- pub since: Option,\n- /// Only posts strictly before this Unix ms timestamp.\n- pub before: Option,\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-}\n-\n-/// Thread (forum) detail by tag -- paginated, filterable posts, full body.\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- if let Some(ref post_id) = q.post_id {\n- let thread_ids = reduced.ingests_by_thread.get(&tag);\n- let index = thread_ids.and_then(|ids| {\n- ids.iter().rev().enumerate().find(|(_, id)| *id == post_id).map(|(i, _)| i)\n- });\n- return match index.and_then(|idx| reduced.ingests_by_id.get(post_id).map(|ing| (idx, ing))) {\n- None => api_error(StatusCode::NOT_FOUND, \"post not found\", None),\n- Some((idx, ing)) => Json(ThreadDetailResponse {\n- thread: format!(\"#{}\", tag),\n- posts: vec![PostRow {\n- id: ing.id.clone(),\n- index: idx,\n- ts: ing.ts,\n- actor: ing.principal.clone(),\n- body: ing.raw.clone(),\n- truncated: false,\n- }],\n- total: 1,\n- offset: idx,\n- }).into_response(),\n- };\n- }\n-\n- let offset = q.offset.unwrap_or(0);\n- let limit = q.limit.unwrap_or(10).clamp(1, 500);\n-\n- // ingests_by_thread is newest-first; reverse to chronological order.\n- let all_ids: Vec = reduced\n- .ingests_by_thread\n- .get(&tag)\n- .map(|q| q.iter().rev().cloned().collect())\n- .unwrap_or_default();\n-\n- let filtered: Vec<(usize, _)> = all_ids\n- .into_iter()\n- .enumerate()\n- .filter_map(|(idx, id)| reduced.ingests_by_id.get(&id).map(|ing| (idx, ing.clone())))\n- .filter(|(_, ing)| q.since.map_or(true, |s| ing.ts >= s))\n- .filter(|(_, ing)| q.before.map_or(true, |b| ing.ts < b))\n- .filter(|(_, ing)| actor_prefix.is_empty() || ing.principal.to_lowercase().starts_with(&actor_prefix))\n- .collect();\n-\n- let total = filtered.len();\n-\n- const MAX_BODY: usize = 2000;\n- let posts: Vec = filtered\n- .into_iter()\n- .skip(offset)\n- .take(limit)\n- .map(|(idx, ing)| {\n- let (body, truncated) = if ing.raw.len() > MAX_BODY {\n- (ing.raw[..MAX_BODY].to_string(), true)\n- } else {\n- (ing.raw.clone(), false)\n- };\n- PostRow {\n- id: ing.id.clone(),\n- index: idx,\n- ts: ing.ts,\n- actor: ing.principal.clone(),\n- body,\n- truncated,\n- }\n- })\n- .collect();\n-\n- Json(ThreadDetailResponse {\n- thread: format!(\"#{}\", tag),\n- posts,\n- total,\n- offset,\n- }).into_response()\n-}\ndiff --git a/server/src/api/garden.rs b/server/src/api/garden.rs\ndeleted file mode 100644\nindex ca0e9e735b8e02e31075ffb77d99941129a045f0..0000000000000000000000000000000000000000\n--- a/server/src/api/garden.rs\n+++ /dev/null\n@@ -1,223 +0,0 @@\n-use axum::{\n- extract::{Query, State},\n- http::StatusCode,\n- response::IntoResponse,\n- Json,\n-};\n-use serde::Deserialize;\n-use slug_types::*;\n-use std::collections::HashSet;\n-\n-use crate::{\n- canonical_path::canonicalize_item,\n- path_types::CanonicalItemUrl,\n- state::AppState,\n-};\n-\n-use super::helpers::{api_error, item_path_for_api, vote_touches_path};\n-\n-// ============================================================================\n-// Exploration APIs (read-only)\n-// ============================================================================\n-\n-#[derive(Debug, Deserialize)]\n-pub struct PathsQuery {}\n-\n-/// List root paths (items with parent \"\").\n-pub async fn get_paths(State(state): State, Query(_q): Query) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n-\n- let out: Vec = reduced\n- .public()\n- .item_children\n- .get(&CanonicalItemUrl::ontology_root())\n- .map(|roots| {\n- let mut v: Vec = roots.iter()\n- .map(|path| {\n- let children = content.item_children.get(path.as_str()).map(|s| s.len()).unwrap_or(0);\n- PathSummary {\n- path: format!(\"~/{}\", path),\n- children,\n- web: format!(\"https://slug.social/~/{}\", path),\n- }\n- }).collect();\n- v.sort_by(|a, b| a.path.cmp(&b.path));\n- v\n- })\n- .unwrap_or_default();\n-\n- Json(PathsResponse { paths: out }).into_response()\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct LeavesQuery {}\n-\n-/// List every leaf item (full path). Items that have no children. Does not scale; works for now.\n-pub async fn get_leaves(State(state): State, Query(_q): Query) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n- let parents: HashSet<&str> = content.item_children.keys().map(|s| s.as_str()).collect();\n- let mut paths: Vec = content\n- .items\n- .iter()\n- .filter(|p| !parents.contains(p.as_str()))\n- .map(|p| p.as_str().to_string())\n- .collect();\n- paths.sort();\n- Json(LeavesResponse { paths }).into_response()\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct ItemQuery {\n- pub item: String,\n- /// Return the full body without truncation (default: false, bodies >10k chars are truncated).\n- #[serde(default)]\n- pub full: Option,\n-}\n-\n-pub async fn get_item(State(state): State, Query(q): Query) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let item_str = canonicalize_item(&q.item);\n- let item = CanonicalItemUrl(item_str.clone());\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n-\n- if !content.items.contains(&item) {\n- return api_error(\n- StatusCode::NOT_FOUND,\n- \"item not found\",\n- Some(format!(\"{} does not exist\", item_path_for_api(&item_str))),\n- );\n- }\n-\n- const MAX_ITEM_BODY: usize = 10_000;\n- let want_full = q.full.unwrap_or(false);\n-\n- let (body, truncated, body_len) = match content.item_bodies.get(&item) {\n- None => (None, false, 0),\n- Some(raw) => {\n- let char_len = raw.chars().count();\n- if !want_full && char_len > MAX_ITEM_BODY {\n- let byte_end = raw.char_indices().nth(MAX_ITEM_BODY).map(|(i, _)| i).unwrap_or(raw.len());\n- (Some(raw[..byte_end].to_string()), true, char_len)\n- } else {\n- (Some(raw.clone()), false, 0)\n- }\n- }\n- };\n-\n- let threads: Vec = reduced\n- .public()\n- .item_threads\n- .get(&item)\n- .map(|s| s.iter().cloned().collect())\n- .unwrap_or_default();\n-\n- Json(ItemResponse {\n- item: item_str.clone(),\n- body,\n- truncated,\n- body_len,\n- threads,\n- })\n- .into_response()\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct RecentVotesQuery {\n- #[serde(default)]\n- pub parent: Option,\n- #[serde(default)]\n- pub limit: Option,\n-}\n-\n-pub async fn get_recent_votes(\n- State(state): State,\n- Query(q): Query,\n-) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let limit = q.limit.unwrap_or(25).clamp(1, 200);\n-\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n- let group = &content.ranking_group;\n-\n- let iter = group.recent_votes.iter();\n- let iter: Box> = if let Some(parent) = &q.parent {\n- let parent_can = canonicalize_item(parent);\n- Box::new(iter.filter(move |v| vote_touches_path(v.a.as_str(), v.b.as_str(), &parent_can)))\n- } else {\n- Box::new(iter)\n- };\n-\n- let out: Vec = iter\n- .take(limit)\n- .map(|v| VoteRow {\n- ts: v.ts,\n- a: v.a.as_str().to_string(),\n- b: v.b.as_str().to_string(),\n- ratio: format!(\"{}:{}\", v.ratio_left, v.ratio_right),\n- actor: Some(v.principal.clone()),\n- body: v.body.clone(),\n- thread: Some(v.thread_id.clone()),\n- }).collect();\n-\n- Json(RecentVotesResponse { votes: out }).into_response()\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct MatchupQuery {\n- pub item: String,\n- #[serde(default)]\n- pub limit: Option,\n-}\n-\n-/// Vote history for one item (matchup: wins/losses with thread per vote).\n-pub async fn get_matchup(\n- State(state): State,\n- Query(q): Query,\n-) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let item_str = canonicalize_item(&q.item);\n- let item = CanonicalItemUrl(item_str.clone());\n- let limit = q.limit.unwrap_or(50).clamp(1, 200);\n-\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n-\n- if !content.items.contains(&item) {\n- return api_error(\n- StatusCode::NOT_FOUND,\n- \"item not found\",\n- Some(format!(\"{} does not exist\", item_path_for_api(&item_str))),\n- );\n- }\n-\n- let votes: Vec = content\n- .item_votes\n- .get(&item)\n- .map(|q| {\n- q.iter()\n- .take(limit)\n- .map(|v| VoteRow {\n- ts: v.ts,\n- a: v.a.as_str().to_string(),\n- b: v.b.as_str().to_string(),\n- ratio: format!(\"{}:{}\", v.ratio_left, v.ratio_right),\n- actor: Some(v.principal.clone()),\n- body: v.body.clone(),\n- thread: Some(v.thread_id.clone()),\n- })\n- .collect()\n- })\n- .unwrap_or_default();\n-\n- Json(MatchupResponse {\n- item: item_path_for_api(&item_str),\n- votes,\n- })\n- .into_response()\n-}\ndiff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs\nindex a16c3df6f9d8eaa2e587f5575b78660880062f81..81e2a55fa3abb8609b4099f91a989480336e11eb 100644\n--- a/server/src/api/helpers.rs\n+++ b/server/src/api/helpers.rs\n@@ -11,7 +11,6 @@ use crate::{\n canonical_path::canonicalize_item,\n path_types::CanonicalItemUrl,\n ranking::connected_components_from_voted_pairs,\n- reducer::ReducerState,\n };\n \n pub fn api_error(status: StatusCode, error: impl Into, hint: Option) -> axum::response::Response {\n@@ -136,8 +135,7 @@ pub fn is_pair_voted(group: &crate::reducer::GroupState, a: &str, b: &str) -> bo\n }\n \n /// Compute graph connectivity stats for a set of items within the ranking group.\n-pub fn compute_connectivity_stats(reduced: &ReducerState, pool: &[String]) -> ConnectivityStats {\n- let group = &reduced.public().ranking_group;\n+pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[String]) -> ConnectivityStats {\n let n = pool.len();\n \n // Map pool items to global indices (items not yet in the group get no index)\ndiff --git a/server/src/api/ingest.rs b/server/src/api/ingest.rs\ndeleted file mode 100644\nindex 5a58754bae8ed5f7ae9ce89e659a61de81c1bc01..0000000000000000000000000000000000000000\n--- a/server/src/api/ingest.rs\n+++ /dev/null\n@@ -1,519 +0,0 @@\n-use axum::{\n- extract::State,\n- http::{HeaderMap, StatusCode},\n- response::IntoResponse,\n- Json,\n-};\n-use serde::Deserialize;\n-use slug_types::*;\n-use std::collections::{HashMap, HashSet};\n-\n-use crate::{\n- canonical_path::canonicalize_tag,\n- dsl,\n- events::{AgentBound, Event, Ingest, ThreadCapability, ThreadVisibility},\n- identity::parse_agent,\n- path_types::CanonicalItemUrl,\n- reducer::{ReducerState, ScopeId},\n- state::AppState,\n-};\n-use super::auth::verify_bearer_principal;\n-use super::helpers::{api_error, item_path_for_api, now_ms, resolve_item};\n-\n-/// Result of validating an ingest document. Shared by post_ingest and post_check.\n-#[derive(Debug)]\n-pub struct ValidatedIngest {\n- pub doc: dsl::Document,\n- pub ts: i64,\n- pub raw_text: String,\n-}\n-\n-/// Parse and validate an ingest document against current reduced state.\n-/// Returns a validated struct for commit (ingest) or dry-run (check).\n-/// Error is (StatusCode, error message, optional hint).\n-///\n-/// Item existence is checked in `scope` first, then falls back to public.\n-pub fn validate_ingest_document(\n- reduced: &ReducerState,\n- text: &str,\n- scope: &ScopeId,\n-) -> Result)> {\n- let public_content = reduced.public();\n- // For a private scope, also check items defined there.\n- let scoped_content = match scope {\n- ScopeId::Public => None,\n- _ => reduced.content_for_scope(scope),\n- };\n- let item_exists = |key: &CanonicalItemUrl| {\n- scoped_content.map(|c| c.items.contains(key)).unwrap_or(false)\n- || public_content.items.contains(key)\n- };\n- let body_exists = |key: &CanonicalItemUrl| {\n- scoped_content.map(|c| c.item_bodies.contains_key(key)).unwrap_or(false)\n- || public_content.item_bodies.contains_key(key)\n- };\n- let doc = match dsl::parse_full(text) {\n- Ok(d) => d,\n- Err(e) => {\n- return Err((\n- StatusCode::BAD_REQUEST,\n- \"parse error\".to_string(),\n- Some(format!(\"{}\", e)),\n- ));\n- }\n- };\n-\n- let ts = now_ms();\n- let mut defined_in_doc: HashSet = HashSet::new();\n-\n- for s in &doc.statements {\n- match s {\n- dsl::Stmt::Item { title, body } => {\n- let item = match resolve_item(title) {\n- Ok(v) => v,\n- Err(msg) => {\n- return Err((StatusCode::BAD_REQUEST, \"invalid item path\".to_string(), Some(msg)));\n- }\n- };\n- let Some(body_text) = body else {\n- return Err((\n- StatusCode::BAD_REQUEST,\n- format!(\"item missing body: {}\", item_path_for_api(&item)),\n- Some(\"items must be declared with bodies, e.g. `~/path/item { ... }`\".to_string()),\n- ));\n- };\n- if body_text.trim().is_empty() {\n- return Err((\n- StatusCode::BAD_REQUEST,\n- format!(\"item body is empty: {}\", item_path_for_api(&item)),\n- Some(\"write at least one sentence inside `{ ... }`\".to_string()),\n- ));\n- }\n- defined_in_doc.insert(item);\n- }\n- dsl::Stmt::Vote { item1, item2, .. } => {\n- let a = match resolve_item(item1) {\n- Ok(v) => v,\n- Err(msg) => {\n- return Err((\n- StatusCode::BAD_REQUEST,\n- \"invalid vote item path\".to_string(),\n- Some(msg),\n- ));\n- }\n- };\n- let b = match resolve_item(item2) {\n- Ok(v) => v,\n- Err(msg) => {\n- return Err((\n- StatusCode::BAD_REQUEST,\n- \"invalid vote item path\".to_string(),\n- Some(msg),\n- ));\n- }\n- };\n- let missing: Vec = [&a, &b]\n- .into_iter()\n- .filter(|it| {\n- let key = CanonicalItemUrl((*it).clone());\n- !defined_in_doc.contains(*it) && !item_exists(&key)\n- })\n- .map(|it| item_path_for_api(it))\n- .collect();\n- if !missing.is_empty() {\n- return Err((\n- StatusCode::BAD_REQUEST,\n- \"vote references undefined item(s)\".to_string(),\n- Some(format!(\n- \"define items with bodies before voting. missing: {}\",\n- missing.join(\", \")\n- )),\n- ));\n- }\n- let missing_body: Vec = [&a, &b]\n- .into_iter()\n- .filter(|it| {\n- let key = CanonicalItemUrl((*it).clone());\n- !defined_in_doc.contains(*it) && !body_exists(&key)\n- })\n- .map(|it| item_path_for_api(it))\n- .collect();\n- if !missing_body.is_empty() {\n- return Err((\n- StatusCode::BAD_REQUEST,\n- \"vote references item(s) without bodies\".to_string(),\n- Some(format!(\n- \"missing bodies: {}. Declare each item as `/item {{ ... }}`\",\n- missing_body.join(\", \")\n- )),\n- ));\n- }\n- }\n- _ => {}\n- }\n- }\n-\n- Ok(ValidatedIngest {\n- doc,\n- ts,\n- raw_text: text.to_string(),\n- })\n-}\n-\n-/// Compute ranking changes between two snapshots of a parent scope's rankings.\n-/// Returns None if nothing changed.\n-fn compute_scope_rank_changes(\n- parent: &str,\n- before: &crate::scope_rank::ChildrenRankings,\n- after: &crate::scope_rank::ChildrenRankings,\n-) -> Option {\n- fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> {\n- let mut map = HashMap::new();\n- for comp in &rankings.component_rankings {\n- let total = comp.ranked.len();\n- for (i, item) in comp.ranked.iter().enumerate() {\n- map.insert(item.item.as_str().to_string(), Some(RankPosition { rank: i + 1, of: total }));\n- }\n- }\n- for item in &rankings.unranked_items {\n- map.insert(item.as_str().to_string(), None);\n- }\n- map\n- }\n-\n- let before_pos = build_positions(before);\n- let after_pos = build_positions(after);\n-\n- let all_items: std::collections::BTreeSet = before_pos.keys().cloned()\n- .chain(after_pos.keys().cloned())\n- .collect();\n-\n- let mut changes: Vec = Vec::new();\n- for item in all_items {\n- let b = before_pos.get(&item).cloned().flatten();\n- let a = after_pos.get(&item).cloned().flatten();\n- let changed = match (&b, &a) {\n- (Some(bp), Some(ap)) => bp.rank != ap.rank || bp.of != ap.of,\n- (None, Some(_)) => true,\n- (Some(_), None) => true,\n- (None, None) => false,\n- };\n- if changed {\n- changes.push(RankChange {\n- item: item_path_for_api(&item),\n- before: b,\n- after: a,\n- });\n- }\n- }\n-\n- if changes.is_empty() {\n- return None;\n- }\n-\n- changes.sort_by(|a, b| match (&a.after, &b.after) {\n- (Some(ap), Some(bp)) => ap.rank.cmp(&bp.rank),\n- (Some(_), None) => std::cmp::Ordering::Less,\n- (None, Some(_)) => std::cmp::Ordering::Greater,\n- (None, None) => a.item.cmp(&b.item),\n- });\n-\n- Some(ScopeRankChanges {\n- parent: if parent.is_empty() {\n- \"/\".to_string()\n- } else {\n- item_path_for_api(parent)\n- },\n- changes,\n- })\n-}\n-\n-pub async fn post_ingest(\n- State(state): State,\n- headers: HeaderMap,\n- Json(req): Json,\n-) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let event_log = state.event_log.clone();\n- let reduced = reduced_arc.read().await;\n-\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- 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- Ok(u) => u,\n- Err((st, msg)) => {\n- drop(reduced);\n- return api_error(st, msg, None).into_response();\n- }\n- };\n-\n- // Determine thread visibility and scope for item lookups and cap enforcement.\n- let visibility = reduced.threads.get(&thread_id)\n- .map(|t| t.visibility)\n- .unwrap_or(ThreadVisibility::Public);\n- let scope = match visibility {\n- ThreadVisibility::Public => ScopeId::Public,\n- ThreadVisibility::Private => ScopeId::Thread(thread_id.clone()),\n- };\n-\n- let v = match validate_ingest_document(&reduced, &req.text, &scope) {\n- Ok(x) => x,\n- Err((status, msg, hint)) => {\n- drop(reduced);\n- return api_error(status, msg, hint).into_response();\n- }\n- };\n-\n- // Enforce capabilities for private threads.\n- if matches!(visibility, ThreadVisibility::Private) {\n- let mut required: HashSet = HashSet::new();\n- required.insert(ThreadCapability::View);\n- for stmt in &v.doc.statements {\n- match stmt {\n- dsl::Stmt::Vote { .. } => { required.insert(ThreadCapability::Vote); }\n- dsl::Stmt::Item { .. } => { required.insert(ThreadCapability::AddItem); }\n- dsl::Stmt::Prose { .. } => { required.insert(ThreadCapability::Post); }\n- }\n- }\n- let missing: Vec<_> = required.iter()\n- .filter(|cap| !reduced.user_has_cap(&thread_id, &principal, **cap))\n- .collect();\n- if !missing.is_empty() {\n- drop(reduced);\n- return api_error(StatusCode::FORBIDDEN, \"insufficient capabilities for this thread\", None).into_response();\n- }\n- }\n-\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- 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- 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- // Collect parent scopes for all voted items so we can compute ranking deltas.\n- let voted_parent_scopes: Vec = {\n- let mut parents: HashSet = HashSet::new();\n- for s in &v.doc.statements {\n- if let dsl::Stmt::Vote { item1, item2, .. } = s {\n- if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n- }\n- }\n- }\n- let mut out: Vec = parents.into_iter().collect();\n- out.sort();\n- out\n- };\n-\n- // Snapshot rankings before the ingest event is applied.\n- let pre_rankings: HashMap =\n- if !voted_parent_scopes.is_empty() {\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n- voted_parent_scopes\n- .iter()\n- .map(|p| (p.clone(), crate::scope_rank::build_children_rankings(content, p)))\n- .collect()\n- } else {\n- HashMap::new()\n- };\n-\n- let ingest_event = Event::Ingest(Ingest {\n- ts: v.ts,\n- id: uuid::Uuid::new_v4().to_string(),\n- raw: v.raw_text.clone(),\n- principal: principal.clone(),\n- delegate: delegate.clone(),\n- thread_id: thread_id.clone(),\n- });\n-\n- if let Err(err) = event_log.append(&ingest_event).await {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, format!(\"{err}\"), None);\n- }\n- events_appended += 1;\n-\n- {\n- let mut reduced = reduced_arc.write().await;\n- reduced.apply_event(ingest_event);\n- }\n-\n- // Snapshot rankings after the event and compute per-scope deltas.\n- let ranking_changes: Vec = if !voted_parent_scopes.is_empty() {\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n- voted_parent_scopes\n- .iter()\n- .filter_map(|p| {\n- let before = pre_rankings.get(p)?;\n- let after = crate::scope_rank::build_children_rankings(content, p);\n- compute_scope_rank_changes(p.as_str(), before, &after)\n- })\n- .collect()\n- } else {\n- vec![]\n- };\n-\n- Json(IngestResponse {\n- ok: true,\n- threads: vec![format!(\"#{}\", thread_id)],\n- events_appended,\n- next: NextMoves {\n- pair: \"npx slugsocial pair\".to_string(),\n- rank: \"npx slugsocial rank\".to_string(),\n- web: format!(\"https://slug.social/t/{}\", thread_id),\n- },\n- ranking_changes,\n- }).into_response()\n-}\n-\n-pub async fn post_check(\n- State(state): State,\n- Json(req): Json,\n-) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let reduced = reduced_arc.read().await;\n- // check is auth-free; always validates against public scope.\n- let v = match validate_ingest_document(&reduced, &req.text, &ScopeId::Public) {\n- Ok(x) => x,\n- Err((status, msg, hint)) => return api_error(status, msg, hint).into_response(),\n- };\n- drop(reduced);\n-\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 = \"placeholder\".to_string();\n-\n- let event = Event::Ingest(Ingest {\n- ts: v.ts,\n- id: uuid::Uuid::new_v4().to_string(),\n- raw: v.raw_text.clone(),\n- principal,\n- delegate,\n- thread_id: thread_id.clone(),\n- });\n-\n- let mut simulated = { reduced_arc.read().await.clone() };\n- simulated.apply_event(event);\n-\n- // Collect parent scopes touched by votes in this document.\n- let voted_parents: Vec = {\n- let mut parents: std::collections::HashSet = std::collections::HashSet::new();\n- for s in &v.doc.statements {\n- if let dsl::Stmt::Vote { item1, item2, .. } = s {\n- if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n- }\n- }\n- }\n- let mut out: Vec = parents.into_iter().collect();\n- out.sort();\n- out\n- };\n-\n- // Show the scoped rankings for affected parent paths; fall back to empty if no votes.\n- let rankings: Vec = voted_parents\n- .iter()\n- .map(|parent| {\n- let scoped = crate::scope_rank::build_children_rankings(simulated.public(), parent);\n- let components: Vec = scoped\n- .component_rankings\n- .into_iter()\n- .map(|comp| RankComponent {\n- pairs: comp.pairs,\n- ranking: comp\n- .ranked\n- .into_iter()\n- .map(|r| RankRow {\n- item: item_path_for_api(r.item.as_str()),\n- score: r.score,\n- percent: None,\n- })\n- .collect(),\n- })\n- .collect();\n- slug_types::CheckScopeRanking {\n- parent: item_path_for_api(parent.as_str()),\n- components,\n- unranked_items: scoped\n- .unranked_items\n- .into_iter()\n- .map(|it| item_path_for_api(it.as_str()))\n- .collect(),\n- }\n- })\n- .collect();\n-\n- Json(CheckResponse {\n- ok: true,\n- threads: vec![format!(\"#{}\", thread_id)],\n- rankings,\n- next: vec![\n- \"npx slugsocial ingest \".to_string(),\n- \"npx slugsocial threads\".to_string(),\n- format!(\"https://slug.social/t/{}\", thread_id),\n- ],\n- }).into_response()\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct IngestQuery {\n- #[serde(default)]\n- pub text: Option,\n-}\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex e3299ee353f8c62935fa82708762490a6d5c54e3..a1ea432932cdca0e42dc7377f0a25075fd5644c0 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -1,18 +1,7 @@\n-mod debug;\n mod auth;\n-mod feed;\n-mod forum;\n-mod garden;\n mod helpers;\n-mod ingest;\n-mod rank;\n-mod search;\n-mod thread;\n-// mod stream; // disabled while HTML/SSE views are offline\n-\n-// Re-export all public items so `api::get_rank`, `api::post_ingest`, etc. still work.\n-\n-pub use debug::{get_debug_query, DebugQuery};\n+mod rpc;\n+mod validate;\n \n pub use auth::{\n get_pending_session,\n@@ -25,32 +14,15 @@ pub use auth::{\n get_choose_username,\n };\n \n-pub use feed::{get_feed, FeedQuery};\n-\n-pub use forum::{get_thread, get_threads, ThreadDetailQuery};\n-\n-pub use garden::{\n- get_item, get_leaves, get_matchup, get_paths, get_recent_votes,\n- ItemQuery, LeavesQuery, MatchupQuery, PathsQuery, RecentVotesQuery,\n-};\n-\n pub use helpers::{\n api_error, compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings,\n parse_parent_specs, pick_random_distinct, sha256_hex, resolve_item, vote_touches_path,\n+ item_path_for_api,\n };\n \n-pub use ingest::{post_check, post_ingest, validate_ingest_document, IngestQuery, ValidatedIngest};\n-\n-pub use rank::{\n- get_global_rank, get_pair, get_rank, get_rank_history,\n- GlobalRankQuery, PairQuery, RankHistoryQuery, RankQuery,\n-};\n-\n-pub use search::{get_search, SearchApiQuery};\n-\n-pub use thread::{post_create_thread, post_thread_grants};\n+pub use rpc::handle_rpc_batch;\n \n-// pub use stream::{get_html_stream, get_stream};\n+pub use validate::{normalize_room_and_thread, validate_ingest_document, ValidatedIngest};\n \n #[cfg(test)]\n mod tests {\n@@ -66,7 +38,8 @@ mod tests {\n raw: raw.to_string(),\n principal: \"test\".to_string(),\n delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n- thread_id: \"t\".to_string(),\n+ room_id: \"public\".to_string(),\n+ thread_tag: \"t\".to_string(),\n }));\n }\n \n@@ -94,7 +67,7 @@ mod tests {\n #[test]\n fn validate_ingest_document_rejects_vote_on_undefined_item() {\n let reduced = ReducerState::default();\n- let text = \"~/t/a {a}\\n~/t/b 1:1 ~/t/missing {why}\\n\";\n+ let text = \"~/t/a {x}\\n~/t/b 1:1 ~/t/missing {why}\\n\";\n let err = validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err();\n assert_eq!(err.0, StatusCode::BAD_REQUEST);\n assert!(err.1.contains(\"undefined item\"));\n@@ -103,7 +76,6 @@ mod tests {\n #[test]\n fn validate_ingest_document_accepts_quoted_thread_title() {\n let reduced = ReducerState::default();\n- // quoted thread title is now prose; should still parse.\n let text = \"\\\"This is a title\\\" { This is the body of the post }\\n~/t/a {a}\\n\";\n validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap();\n }\n@@ -111,7 +83,6 @@ mod tests {\n #[test]\n fn validate_ingest_document_rejects_multiple_threads() {\n let reduced = ReducerState::default();\n- // multiple thread declarations are now prose; should still parse.\n let text = \"#one\\n#two\\n~/t/a {a}\\n\";\n validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap();\n }\ndiff --git a/server/src/api/rank.rs b/server/src/api/rank.rs\ndeleted file mode 100644\nindex f2d348280ec4d70a32044601964cb5dc9382d9d7..0000000000000000000000000000000000000000\n--- a/server/src/api/rank.rs\n+++ /dev/null\n@@ -1,453 +0,0 @@\n-use axum::{\n- extract::{Query, State},\n- http::StatusCode,\n- response::IntoResponse,\n- Json,\n-};\n-use rand::seq::SliceRandom;\n-use serde::Deserialize;\n-use slug_types::*;\n-use std::collections::HashSet;\n-\n-use crate::{\n- path_types::CanonicalItemUrl,\n- ranking::{connected_components_from_voted_pairs, ranked_items_subset},\n- state::AppState,\n-};\n-\n-use super::helpers::{\n- api_error, compute_connectivity_stats, is_pair_voted, item_path_for_api, paginate_rankings,\n- parse_parent_specs, pick_random_distinct,\n-};\n-\n-// ============================================================================\n-// Rank / Pair -- one-ranking (parent-path-filtered)\n-// ============================================================================\n-\n-#[derive(Debug, Deserialize)]\n-pub struct RankQuery {\n- /// Parent path(s). Comma-separated to merge scopes: ~/a,~/b (e.g. rank ~/models ~/ai-models).\n- /// Use `~` alone for global ranking across all items.\n- #[serde(default)]\n- pub parent: Option,\n- #[serde(default)]\n- pub limit: Option,\n- /// Skip the first N items (for pagination).\n- #[serde(default)]\n- pub offset: Option,\n- /// When true, each RankRow includes a `percent` field: score as % of max score in its component.\n- #[serde(default)]\n- pub percent: Option,\n- /// How many levels deep to resolve children (default 1 = direct children only).\n- #[serde(default)]\n- pub depth: Option,\n-}\n-\n-pub async fn get_rank(State(state): State, Query(q): Query) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n-\n- let specs = parse_parent_specs(q.parent.as_ref());\n- let is_global = q.parent.as_deref().map(|p| p.trim() == \"~\").unwrap_or(false);\n- let depth = q.depth.unwrap_or(1).max(1);\n-\n- // 404 when a specific named path is requested but doesn't exist as a known item or parent.\n- if !is_global && !specs.is_empty() {\n- let none_exist = specs.iter().all(|spec| {\n- let Some(canon) = CanonicalItemUrl::parse(spec) else { return true };\n- !content.items.contains(&canon) && !content.item_children.contains_key(&canon)\n- });\n- if none_exist {\n- return api_error(\n- StatusCode::NOT_FOUND,\n- \"path not found\",\n- Some(format!(\"{} does not exist\", specs.join(\", \"))),\n- );\n- }\n- }\n-\n- let rankings = if is_global {\n- let all_items: Vec = content.items.iter().cloned().collect();\n- crate::scope_rank::build_rankings_for_item_set(content, &all_items)\n- } else if specs.is_empty() {\n- crate::scope_rank::build_children_rankings(content, &CanonicalItemUrl::ontology_root())\n- } else if depth > 1 {\n- let items = crate::scope_rank::resolve_scope_recursive(content, &specs, depth);\n- crate::scope_rank::build_rankings_for_item_set(content, &items)\n- } else {\n- let items = crate::scope_rank::resolve_scope(content, &specs);\n- crate::scope_rank::build_rankings_for_item_set(content, &items)\n- };\n-\n- let want_percent = q.percent.unwrap_or(false);\n-\n- let components: Vec = rankings\n- .component_rankings\n- .into_iter()\n- .map(|c| {\n- let max_score = c.ranked.first().map(|r| r.score).unwrap_or(1.0).max(1e-12);\n- RankComponent {\n- pairs: c.pairs,\n- ranking: c\n- .ranked\n- .into_iter()\n- .map(|r| RankRow {\n- item: item_path_for_api(r.item.as_str()),\n- percent: if want_percent {\n- Some((r.score / max_score) * 100.0)\n- } else {\n- None\n- },\n- score: r.score,\n- })\n- .collect(),\n- }\n- })\n- .collect();\n-\n- let prefixed_unranked: Vec = rankings\n- .unranked_items\n- .into_iter()\n- .map(|s| item_path_for_api(s.as_str()))\n- .collect();\n-\n- let offset = q.offset.unwrap_or(0);\n- let (components, unranked_items) = if offset > 0 || q.limit.is_some() {\n- paginate_rankings(components, prefixed_unranked, offset, q.limit)\n- } else {\n- (components, prefixed_unranked)\n- };\n-\n- Json(RankResponse {\n- components,\n- unranked_items,\n- })\n- .into_response()\n-}\n-\n-// ============================================================================\n-// Global rank -- flat, paginated ranking across all items\n-// ============================================================================\n-\n-#[derive(Debug, Deserialize)]\n-pub struct GlobalRankQuery {\n- #[serde(default)]\n- pub limit: Option,\n- #[serde(default)]\n- pub offset: Option,\n- /// When true, add a `percent` field to each row (top item = 100, bottom = 0).\n- #[serde(default)]\n- pub percent: Option,\n-}\n-\n-pub async fn get_global_rank(\n- State(state): State,\n- Query(q): Query,\n-) -> impl IntoResponse {\n- const DEFAULT_LIMIT: usize = 50;\n- const MAX_LIMIT: usize = 500;\n-\n- let limit = q.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);\n- let offset = q.offset.unwrap_or(0);\n- let want_percent = q.percent.unwrap_or(false);\n-\n- let reduced_arc = state.reduced.clone();\n- // Write lock needed to update cached_scores when dirty.\n- let mut reduced = reduced_arc.write().await;\n- let content = reduced.content.get_mut(&crate::reducer::ScopeId::Public).expect(\"public scope missing\");\n-\n- // Find connected components in global graph; rank within each; largest component first.\n- let n = content.ranking_group.idx_to_item.len();\n- let (mut comps, _) = connected_components_from_voted_pairs(\n- n, content.ranking_group.voted_pairs.iter().copied(),\n- );\n- comps.sort_by(|a, b| b.len().cmp(&a.len()));\n-\n- let mut ranked: Vec = Vec::new();\n- for comp in &comps {\n- let items = ranked_items_subset(&content.ranking_group, comp, 10000, 1e-8);\n- let top = items.first().map(|r| r.score).unwrap_or(1.0);\n- let bot = items.last().map(|r| r.score).unwrap_or(0.0);\n- let range = (top - bot).max(1e-12);\n- for r in items {\n- let pct = want_percent.then(|| ((r.score - bot) / range * 100.0).clamp(0.0, 100.0));\n- ranked.push(RankRow { item: item_path_for_api(r.item.as_str()), score: r.score, percent: pct });\n- }\n- }\n-\n- let ranked_total = ranked.len();\n-\n- // Items that exist but have never been voted on.\n- let mut unranked: Vec = content\n- .items\n- .iter()\n- .filter(|it| !content.ranking_group.item_to_idx.contains_key(*it))\n- .map(|it| it.as_str().to_string())\n- .collect();\n- unranked.sort();\n- let unranked_total = unranked.len();\n-\n- let page: Vec = ranked\n- .into_iter()\n- .chain(unranked.into_iter().map(|it| RankRow {\n- item: item_path_for_api(&it),\n- score: 0.0,\n- percent: want_percent.then_some(0.0),\n- }))\n- .skip(offset)\n- .take(limit)\n- .collect();\n-\n- Json(GlobalRankResponse {\n- ranked_total,\n- unranked_total,\n- offset,\n- limit,\n- items: page,\n- })\n- .into_response()\n-}\n-\n-// ============================================================================\n-// Rank history -- per-item rank ledger\n-// ============================================================================\n-\n-#[derive(Debug, Deserialize)]\n-pub struct RankHistoryQuery {\n- pub item: String,\n-}\n-\n-pub async fn get_rank_history(\n- State(state): State,\n- Query(q): Query,\n-) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n-\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-\n- // Resolve caused_by at query time: look up the ingest and filter relevant votes.\n- let history: Vec = entries.iter().map(|e| {\n- let caused_by: Vec = reduced.ingests_by_id.get(&e.post_id)\n- .and_then(|ing| crate::dsl::parse_full(&ing.raw).ok())\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::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| ing.principal.clone()),\n- body: explanation,\n- thread: Some(format!(\"#{}\", e.thread)),\n- })\n- } else {\n- None\n- }\n- } else {\n- None\n- }\n- }).collect()\n- })\n- .unwrap_or_default();\n-\n- // Compute 1-indexed chronological position within the thread.\n- // ingests_by_thread is most-recent-first, so index from back + 1.\n- let thread_post_index = reduced.ingests_by_thread\n- .get(&e.thread)\n- .and_then(|q| q.iter().rev().position(|id| id == &e.post_id))\n- .map(|i| i + 1)\n- .unwrap_or(0);\n-\n- slug_types::RankHistoryRow {\n- ts: e.ts,\n- scope_rank: e.scope_rank,\n- scope_rank_delta: e.scope_rank_delta,\n- scope_total: e.scope_total,\n- global_rank: e.global_rank,\n- global_rank_delta: e.global_rank_delta,\n- global_total: e.global_total,\n- score: e.score,\n- thread: format!(\"#{}\", e.thread),\n- thread_post_index,\n- caused_by,\n- }\n- }).collect();\n-\n- Json(slug_types::RankHistoryResponse {\n- item: item_path_for_api(&item_str),\n- history,\n- }).into_response()\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct PairQuery {\n- /// Parent path(s). Comma-separated to merge scopes: ~/a,~/b (e.g. rank ~/models ~/ai-models).\n- #[serde(default)]\n- pub parent: Option,\n- #[serde(default)]\n- pub random: Option,\n-}\n-\n-pub async fn get_pair(State(state): State, Query(q): Query) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let force_random = q.random.unwrap_or(false);\n-\n- let pool: Vec = {\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n- let specs = parse_parent_specs(q.parent.as_ref());\n- let raw_pool: Vec = if specs.is_empty() {\n- content.ranking_group.idx_to_item.clone()\n- } else {\n- crate::scope_rank::resolve_scope(content, &specs)\n- };\n- raw_pool.into_iter().map(|it| it.0).collect()\n- };\n-\n- if pool.len() < 2 {\n- let scope = q.parent.as_deref().unwrap_or(\"(all)\");\n- return api_error(\n- axum::http::StatusCode::BAD_REQUEST,\n- format!(\"need at least 2 items under parent /{}\", scope),\n- Some(\"add items via ingest\".to_string()),\n- );\n- }\n-\n- if force_random {\n- let Some((left, right)) = pick_random_distinct(&pool) else {\n- return api_error(axum::http::StatusCode::BAD_REQUEST, \"need at least 2 items\", None);\n- };\n- let (left_body, right_body, threads, connectivity) = {\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n- let left_key = CanonicalItemUrl(left.clone());\n- let right_key = CanonicalItemUrl(right.clone());\n- let lb = content.item_bodies.get(&left_key).cloned();\n- let rb = content.item_bodies.get(&right_key).cloned();\n- let th: Vec = content\n- .item_threads\n- .get(&left_key)\n- .into_iter()\n- .chain(content.item_threads.get(&right_key))\n- .flat_map(|s| s.iter().cloned())\n- .collect::>()\n- .into_iter()\n- .collect();\n- let cs = compute_connectivity_stats(&reduced, &pool);\n- (lb, rb, th, cs)\n- };\n- return Json(PairResponse {\n- left: item_path_for_api(&left),\n- right: item_path_for_api(&right),\n- left_body,\n- right_body,\n- threads,\n- connectivity: Some(connectivity),\n- })\n- .into_response();\n- }\n-\n- let selected: Option<(String, String)> = {\n- let mut reduced = reduced_arc.write().await;\n- let group = &mut reduced\n- .content\n- .get_mut(&crate::reducer::ScopeId::Public)\n- .expect(\"public scope missing\")\n- .ranking_group;\n- if group.idx_to_item.is_empty() {\n- pick_random_distinct(&pool)\n- } else {\n- let mut rng = rand::thread_rng();\n- let idxs: Vec = pool.iter()\n- .filter_map(|it| {\n- let key = CanonicalItemUrl(it.clone());\n- group.item_to_idx.get(&key).copied()\n- })\n- .collect();\n- let ranked = ranked_items_subset(group, &idxs, 10000, 1e-8);\n- let ranked_set: std::collections::HashSet =\n- ranked.iter().map(|r| r.item.as_str().to_string()).collect();\n- let unsorted: Vec = pool.iter()\n- .filter(|it| !ranked_set.contains(*it))\n- .cloned()\n- .collect();\n-\n- let mut pick: Option<(String, String)> = None;\n- if !unsorted.is_empty() {\n- if let Some(left) = unsorted.choose(&mut rng).cloned() {\n- let mut candidates: Vec = if !ranked.is_empty() {\n- ranked.iter().map(|r| r.item.as_str().to_string()).collect()\n- } else {\n- pool.clone()\n- };\n- candidates.retain(|c| c != &left);\n- if let Some(right) = candidates.choose(&mut rng).cloned() {\n- pick = Some((left, right));\n- }\n- }\n- } else if ranked.len() >= 2 {\n- for i in 0..(ranked.len().saturating_sub(1)) {\n- let a = ranked[i].item.as_str();\n- let b = ranked[i + 1].item.as_str();\n- if a != b && !is_pair_voted(group, a, b) {\n- pick = Some((a.to_string(), b.to_string()));\n- break;\n- }\n- }\n- if pick.is_none() {\n- for _ in 0..64 {\n- let (Some(a), Some(b)) = (pool.choose(&mut rng).cloned(), pool.choose(&mut rng).cloned()) else { break; };\n- if a != b && !is_pair_voted(group, &a, &b) {\n- pick = Some((a, b));\n- break;\n- }\n- }\n- }\n- }\n- pick.or_else(|| pick_random_distinct(&pool))\n- }\n- };\n-\n- let Some((left, right)) = selected else {\n- return api_error(axum::http::StatusCode::BAD_REQUEST, \"need at least 2 items\", None);\n- };\n-\n- let (left_body, right_body, threads, connectivity) = {\n- let reduced = reduced_arc.read().await;\n- let content = reduced.public();\n- let left_key = CanonicalItemUrl(left.clone());\n- let right_key = CanonicalItemUrl(right.clone());\n- let lb = content.item_bodies.get(&left_key).cloned();\n- let rb = content.item_bodies.get(&right_key).cloned();\n- let th: Vec = content\n- .item_threads\n- .get(&left_key)\n- .into_iter()\n- .chain(content.item_threads.get(&right_key))\n- .flat_map(|s| s.iter().cloned())\n- .collect::>()\n- .into_iter()\n- .collect();\n- let cs = compute_connectivity_stats(&reduced, &pool);\n- (lb, rb, th, cs)\n- };\n- Json(PairResponse {\n- left: item_path_for_api(&left),\n- right: item_path_for_api(&right),\n- left_body,\n- right_body,\n- threads,\n- connectivity: Some(connectivity),\n- })\n- .into_response()\n-}\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..5675dcd2e28062acbe3c037b94b77c33adf32474\n--- /dev/null\n+++ b/server/src/api/rpc.rs\n@@ -0,0 +1,1330 @@\n+use std::collections::{HashMap, HashSet};\n+use std::sync::OnceLock;\n+\n+use axum::{\n+ extract::State,\n+ http::HeaderMap,\n+ response::IntoResponse,\n+ Json,\n+};\n+use rand::seq::SliceRandom;\n+use slug_types::*;\n+\n+use crate::{\n+ canonical_path::{canonicalize_item, canonicalize_tag},\n+ dsl,\n+ events::{AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility},\n+ identity::{parse_agent, parse_username},\n+ path_types::CanonicalItemUrl,\n+ ranking::{connected_components_from_voted_pairs, ranked_items_subset},\n+ reducer::{scope_from_room_wire, ReducerState, ScopeId},\n+ state::AppState,\n+};\n+\n+use super::auth::verify_bearer_principal;\n+use super::helpers::{\n+ compute_connectivity_stats, is_pair_voted, item_path_for_api, now_ms, paginate_rankings,\n+ parse_parent_specs, pick_random_distinct, resolve_item, vote_touches_path,\n+};\n+use super::validate::{normalize_room_and_thread, validate_ingest_document};\n+\n+fn empty_content() -> &'static crate::reducer::ContentState {\n+ static E: OnceLock = OnceLock::new();\n+ E.get_or_init(Default::default)\n+}\n+\n+fn content_for_room<'a>(reduced: &'a ReducerState, room: &str) -> &'a crate::reducer::ContentState {\n+ let scope = scope_from_room_wire(room);\n+ reduced.content.get(&scope).unwrap_or(empty_content())\n+}\n+\n+type RpcErr = (String, Option);\n+\n+fn line_ok(r: RpcResult) -> RpcLine {\n+ RpcLine {\n+ ok: true,\n+ result: Some(r),\n+ error: None,\n+ hint: None,\n+ }\n+}\n+\n+fn line_err(msg: impl Into, hint: Option) -> RpcLine {\n+ RpcLine {\n+ ok: false,\n+ result: None,\n+ error: Some(msg.into()),\n+ hint,\n+ }\n+}\n+\n+fn compute_scope_rank_changes(\n+ parent: &str,\n+ before: &crate::scope_rank::ChildrenRankings,\n+ after: &crate::scope_rank::ChildrenRankings,\n+) -> Option {\n+ fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> {\n+ let mut map = HashMap::new();\n+ for comp in &rankings.component_rankings {\n+ let total = comp.ranked.len();\n+ for (i, item) in comp.ranked.iter().enumerate() {\n+ map.insert(item.item.as_str().to_string(), Some(RankPosition { rank: i + 1, of: total }));\n+ }\n+ }\n+ for item in &rankings.unranked_items {\n+ map.insert(item.as_str().to_string(), None);\n+ }\n+ map\n+ }\n+\n+ let before_pos = build_positions(before);\n+ let after_pos = build_positions(after);\n+\n+ let all_items: std::collections::BTreeSet = before_pos.keys().cloned()\n+ .chain(after_pos.keys().cloned())\n+ .collect();\n+\n+ let mut changes: Vec = Vec::new();\n+ for item in all_items {\n+ let b = before_pos.get(&item).cloned().flatten();\n+ let a = after_pos.get(&item).cloned().flatten();\n+ let changed = match (&b, &a) {\n+ (Some(bp), Some(ap)) => bp.rank != ap.rank || bp.of != ap.of,\n+ (None, Some(_)) => true,\n+ (Some(_), None) => true,\n+ (None, None) => false,\n+ };\n+ if changed {\n+ changes.push(RankChange {\n+ item: item_path_for_api(&item),\n+ before: b,\n+ after: a,\n+ });\n+ }\n+ }\n+\n+ if changes.is_empty() {\n+ return None;\n+ }\n+\n+ changes.sort_by(|a, b| match (&a.after, &b.after) {\n+ (Some(ap), Some(bp)) => ap.rank.cmp(&bp.rank),\n+ (Some(_), None) => std::cmp::Ordering::Less,\n+ (None, Some(_)) => std::cmp::Ordering::Greater,\n+ (None, None) => a.item.cmp(&b.item),\n+ });\n+\n+ Some(ScopeRankChanges {\n+ parent: if parent.is_empty() {\n+ \"/\".to_string()\n+ } else {\n+ item_path_for_api(parent)\n+ },\n+ changes,\n+ })\n+}\n+\n+fn parse_capability(s: &str) -> Result {\n+ match s {\n+ \"view\" => Ok(ThreadCapability::View),\n+ \"post\" => Ok(ThreadCapability::Post),\n+ \"vote\" => Ok(ThreadCapability::Vote),\n+ \"add_item\" => Ok(ThreadCapability::AddItem),\n+ \"manage\" => Ok(ThreadCapability::Manage),\n+ other => Err(format!(\"unknown capability: {other}\")),\n+ }\n+}\n+\n+fn gen_short_id() -> String {\n+ use rand::Rng;\n+ const ALPHABET: &[u8] = b\"0123456789abcdefghijklmnopqrstuvwxyz\";\n+ let mut rng = rand::thread_rng();\n+ (0..7).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()\n+}\n+\n+fn build_rank_response_for_content(\n+ content: &crate::reducer::ContentState,\n+ parent: Option<&str>,\n+ depth: usize,\n+ offset: usize,\n+ limit: Option,\n+ want_percent: bool,\n+) -> Result {\n+ let parent_owned = parent.map(|s| s.to_string());\n+ let specs = parse_parent_specs(parent_owned.as_ref());\n+ let is_global = parent.map(|p| p.trim() == \"~\").unwrap_or(false);\n+\n+ if !is_global && !specs.is_empty() {\n+ let none_exist = specs.iter().all(|spec| {\n+ let Some(canon) = CanonicalItemUrl::parse(spec) else { return true };\n+ !content.items.contains(&canon) && !content.item_children.contains_key(&canon)\n+ });\n+ if none_exist {\n+ return Err((\n+ \"path not found\".into(),\n+ Some(format!(\"{} does not exist\", specs.join(\", \"))),\n+ ));\n+ }\n+ }\n+\n+ let depth = depth.max(1);\n+ let rankings = if is_global {\n+ let all_items: Vec = content.items.iter().cloned().collect();\n+ crate::scope_rank::build_rankings_for_item_set(content, &all_items)\n+ } else if specs.is_empty() {\n+ crate::scope_rank::build_children_rankings(content, &CanonicalItemUrl::ontology_root())\n+ } else if depth > 1 {\n+ let items = crate::scope_rank::resolve_scope_recursive(content, &specs, depth);\n+ crate::scope_rank::build_rankings_for_item_set(content, &items)\n+ } else {\n+ let items = crate::scope_rank::resolve_scope(content, &specs);\n+ crate::scope_rank::build_rankings_for_item_set(content, &items)\n+ };\n+\n+ let components: Vec = rankings\n+ .component_rankings\n+ .into_iter()\n+ .map(|c| {\n+ let max_score = c.ranked.first().map(|r| r.score).unwrap_or(1.0).max(1e-12);\n+ RankComponent {\n+ pairs: c.pairs,\n+ ranking: c\n+ .ranked\n+ .into_iter()\n+ .map(|r| RankRow {\n+ item: item_path_for_api(r.item.as_str()),\n+ percent: if want_percent {\n+ Some((r.score / max_score) * 100.0)\n+ } else {\n+ None\n+ },\n+ score: r.score,\n+ })\n+ .collect(),\n+ }\n+ })\n+ .collect();\n+\n+ let prefixed_unranked: Vec = rankings\n+ .unranked_items\n+ .into_iter()\n+ .map(|s| item_path_for_api(s.as_str()))\n+ .collect();\n+\n+ let (components, unranked_items) = if offset > 0 || limit.is_some() {\n+ paginate_rankings(components, prefixed_unranked, offset, limit)\n+ } else {\n+ (components, prefixed_unranked)\n+ };\n+\n+ Ok(RankResponse {\n+ components,\n+ unranked_items,\n+ })\n+}\n+\n+async fn rpc_post(\n+ state: &AppState,\n+ headers: &HeaderMap,\n+ room: String,\n+ thread_tag: String,\n+ delegate_opt: Option,\n+ text: String,\n+ return_rank_diff: bool,\n+) -> Result {\n+ let reduced_arc = state.reduced.clone();\n+ let reduced = reduced_arc.read().await;\n+\n+ let principal = verify_bearer_principal(headers, &reduced).map_err(|(_, m)| (m, None))?;\n+\n+ let delegate: Option = match delegate_opt {\n+ None => None,\n+ Some(ref s) if s.trim().is_empty() => None,\n+ Some(s) => Some(parse_agent(&s).map_err(|msg| (format!(\"invalid delegate format\"), Some(msg)))?),\n+ };\n+\n+ let (room_key, thread_id) = normalize_room_and_thread(&room, &thread_tag);\n+ let scope = scope_from_room_wire(&room_key);\n+\n+ let is_private = !matches!(scope, ScopeId::Public);\n+ if is_private && !reduced.rooms.contains_key(&room_key) {\n+ drop(reduced);\n+ return Err((\"unknown room\".into(), Some(format!(\"room `{}` does not exist\", room_key))));\n+ }\n+\n+ let v = validate_ingest_document(&reduced, &text, &scope).map_err(|(st, m, h)| {\n+ let _ = st;\n+ (m, h)\n+ })?;\n+\n+ if is_private {\n+ let mut required: HashSet = HashSet::new();\n+ required.insert(ThreadCapability::View);\n+ for stmt in &v.doc.statements {\n+ match stmt {\n+ dsl::Stmt::Vote { .. } => { required.insert(ThreadCapability::Vote); }\n+ dsl::Stmt::Item { .. } => { required.insert(ThreadCapability::AddItem); }\n+ dsl::Stmt::Prose { .. } => { required.insert(ThreadCapability::Post); }\n+ }\n+ }\n+ let missing: Vec<_> = required.iter()\n+ .filter(|cap| !reduced.user_has_cap(&room_key, &principal, **cap))\n+ .collect();\n+ if !missing.is_empty() {\n+ drop(reduced);\n+ return Err((\"insufficient capabilities for this room\".into(), None));\n+ }\n+ }\n+\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 Err((\"delegate already bound to another user\".into(), None));\n+ }\n+ _ => {}\n+ }\n+ }\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+ 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) = state.event_log.append(&ab).await {\n+ return Err((format!(\"{err}\"), None));\n+ }\n+ events_appended += 1;\n+ let mut reduced = reduced_arc.write().await;\n+ reduced.apply_event(ab);\n+ }\n+ }\n+\n+ let voted_parent_scopes: Vec = {\n+ let mut parents: HashSet = HashSet::new();\n+ for s in &v.doc.statements {\n+ if let dsl::Stmt::Vote { item1, item2, .. } = s {\n+ if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n+ if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ }\n+ }\n+ }\n+ let mut out: Vec = parents.into_iter().collect();\n+ out.sort();\n+ out\n+ };\n+\n+ let pre_rankings: HashMap =\n+ if !voted_parent_scopes.is_empty() {\n+ let reduced = reduced_arc.read().await;\n+ let content = content_for_room(&reduced, &room_key);\n+ voted_parent_scopes\n+ .iter()\n+ .map(|p| (p.clone(), crate::scope_rank::build_children_rankings(content, p)))\n+ .collect()\n+ } else {\n+ HashMap::new()\n+ };\n+\n+ let ingest_event = Event::Ingest(Ingest {\n+ ts: v.ts,\n+ id: uuid::Uuid::new_v4().to_string(),\n+ raw: v.raw_text.clone(),\n+ principal: principal.clone(),\n+ delegate: delegate.clone(),\n+ room_id: room_key.clone(),\n+ thread_tag: thread_id.clone(),\n+ });\n+\n+ if let Err(err) = state.event_log.append(&ingest_event).await {\n+ return Err((format!(\"{err}\"), None));\n+ }\n+ events_appended += 1;\n+\n+ {\n+ let mut reduced = reduced_arc.write().await;\n+ reduced.apply_event(ingest_event);\n+ }\n+\n+ let ranking_changes: Option> = if return_rank_diff && !voted_parent_scopes.is_empty() {\n+ let reduced = reduced_arc.read().await;\n+ let content = content_for_room(&reduced, &room_key);\n+ let v: Vec = voted_parent_scopes\n+ .iter()\n+ .filter_map(|p| {\n+ let before = pre_rankings.get(p)?;\n+ let after = crate::scope_rank::build_children_rankings(content, p);\n+ compute_scope_rank_changes(p.as_str(), before, &after)\n+ })\n+ .collect();\n+ if v.is_empty() { None } else { Some(v) }\n+ } else {\n+ None\n+ };\n+\n+ Ok(RpcResult::PostOk {\n+ events_appended,\n+ ranking_changes,\n+ threads: vec![format!(\"#{}\", thread_id)],\n+ next: NextMoves {\n+ pair: \"npx slugsocial pair\".to_string(),\n+ rank: \"npx slugsocial rank\".to_string(),\n+ web: format!(\"https://slug.social/t/{}\", thread_id),\n+ },\n+ })\n+}\n+\n+async fn rpc_check(\n+ state: &AppState,\n+ _room: String,\n+ text: String,\n+) -> Result {\n+ let reduced_arc = state.reduced.clone();\n+ let reduced = reduced_arc.read().await;\n+ let thread_id = \"check\".to_string();\n+ let v = validate_ingest_document(&reduced, &text, &ScopeId::Public).map_err(|(_, m, h)| (m, h))?;\n+ drop(reduced);\n+\n+ let delegate: Option = None;\n+ let principal = \"placeholder\".to_string();\n+ let event = Event::Ingest(Ingest {\n+ ts: v.ts,\n+ id: uuid::Uuid::new_v4().to_string(),\n+ raw: v.raw_text.clone(),\n+ principal,\n+ delegate,\n+ room_id: \"public\".into(),\n+ thread_tag: thread_id.clone(),\n+ });\n+\n+ let mut simulated = { reduced_arc.read().await.clone() };\n+ simulated.apply_event(event);\n+\n+ let voted_parents: Vec = {\n+ let mut parents: HashSet = HashSet::new();\n+ for s in &v.doc.statements {\n+ if let dsl::Stmt::Vote { item1, item2, .. } = s {\n+ if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n+ if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ }\n+ }\n+ }\n+ let mut out: Vec = parents.into_iter().collect();\n+ out.sort();\n+ out\n+ };\n+\n+ let rankings: Vec = voted_parents\n+ .iter()\n+ .map(|parent| {\n+ let scoped = crate::scope_rank::build_children_rankings(simulated.public(), parent);\n+ let components: Vec = scoped\n+ .component_rankings\n+ .into_iter()\n+ .map(|comp| RankComponent {\n+ pairs: comp.pairs,\n+ ranking: comp\n+ .ranked\n+ .into_iter()\n+ .map(|r| RankRow {\n+ item: item_path_for_api(r.item.as_str()),\n+ score: r.score,\n+ percent: None,\n+ })\n+ .collect(),\n+ })\n+ .collect();\n+ CheckScopeRanking {\n+ parent: item_path_for_api(parent.as_str()),\n+ components,\n+ unranked_items: scoped\n+ .unranked_items\n+ .into_iter()\n+ .map(|it| item_path_for_api(it.as_str()))\n+ .collect(),\n+ }\n+ })\n+ .collect();\n+\n+ Ok(RpcResult::CheckOk {\n+ rankings,\n+ threads: vec![format!(\"#{}\", thread_id)],\n+ next: vec![\n+ \"npx slugsocial ingest\".to_string(),\n+ \"npx slugsocial forum\".to_string(),\n+ format!(\"https://slug.social/t/{}\", thread_id),\n+ ],\n+ })\n+}\n+\n+fn forum_scope_from_room(room: &str) -> ScopeId {\n+ scope_from_room_wire(room)\n+}\n+\n+fn rpc_list_forum_threads(reduced: &ReducerState, room: &str) -> ThreadsResponse {\n+ let scope = forum_scope_from_room(room);\n+ let mut out: Vec = reduced\n+ .forum_threads\n+ .iter()\n+ .filter(|((s, _), _)| s == &scope)\n+ .map(|((_, tag), ts)| ThreadSummary {\n+ thread: format!(\"#{tag}\"),\n+ last_activity_ts: ts.last_activity_ts,\n+ web: format!(\"https://slug.social/t/{}\", tag),\n+ })\n+ .collect();\n+ out.sort_by(|a, b| b.last_activity_ts.cmp(&a.last_activity_ts));\n+ ThreadsResponse { threads: out }\n+}\n+\n+fn rpc_forum_thread_detail(\n+ reduced: &ReducerState,\n+ room: &str,\n+ thread_tag: &str,\n+ offset: usize,\n+ limit: usize,\n+ since: Option,\n+ before: Option,\n+ actor_prefix: &str,\n+ post_id: Option<&str>,\n+) -> Result {\n+ let scope = forum_scope_from_room(room);\n+ let tag = canonicalize_tag(thread_tag);\n+ let key = (scope.clone(), tag.clone());\n+\n+ if let Some(pid) = post_id {\n+ let thread_ids = reduced.ingests_by_scope_thread.get(&key);\n+ let index = thread_ids.and_then(|ids| {\n+ ids.iter().rev().enumerate().find(|(_, id)| *id == pid).map(|(i, _)| i)\n+ });\n+ return match index.and_then(|idx| reduced.ingests_by_id.get(pid).map(|ing| (idx, ing))) {\n+ None => Err((\"post not found\".into(), None)),\n+ Some((idx, ing)) => Ok(ThreadDetailResponse {\n+ thread: format!(\"#{}\", tag),\n+ posts: vec![PostRow {\n+ id: ing.id.clone(),\n+ index: idx,\n+ ts: ing.ts,\n+ actor: ing.principal.clone(),\n+ body: ing.raw.clone(),\n+ truncated: false,\n+ }],\n+ total: 1,\n+ offset: idx,\n+ }),\n+ };\n+ }\n+\n+ let all_ids: Vec = reduced\n+ .ingests_by_scope_thread\n+ .get(&key)\n+ .map(|q| q.iter().rev().cloned().collect())\n+ .unwrap_or_default();\n+\n+ let filtered: Vec<(usize, _)> = all_ids\n+ .into_iter()\n+ .enumerate()\n+ .filter_map(|(idx, id)| reduced.ingests_by_id.get(&id).map(|ing| (idx, ing.clone())))\n+ .filter(|(_, ing)| since.map_or(true, |s| ing.ts >= s))\n+ .filter(|(_, ing)| before.map_or(true, |b| ing.ts < b))\n+ .filter(|(_, ing)| actor_prefix.is_empty() || ing.principal.to_lowercase().starts_with(actor_prefix))\n+ .collect();\n+\n+ let total = filtered.len();\n+ const MAX_BODY: usize = 2000;\n+ let posts: Vec = filtered\n+ .into_iter()\n+ .skip(offset)\n+ .take(limit)\n+ .map(|(idx, ing)| {\n+ let (body, truncated) = if ing.raw.len() > MAX_BODY {\n+ (ing.raw[..MAX_BODY].to_string(), true)\n+ } else {\n+ (ing.raw.clone(), false)\n+ };\n+ PostRow {\n+ id: ing.id.clone(),\n+ index: idx,\n+ ts: ing.ts,\n+ actor: ing.principal.clone(),\n+ body,\n+ truncated,\n+ }\n+ })\n+ .collect();\n+\n+ Ok(ThreadDetailResponse {\n+ thread: format!(\"#{}\", tag),\n+ posts,\n+ total,\n+ offset,\n+ })\n+}\n+\n+fn tokenize_query(q: &str) -> Vec {\n+ q.split_whitespace()\n+ .map(|w| w.to_lowercase())\n+ .filter(|w| !w.is_empty())\n+ .collect()\n+}\n+\n+fn text_contains_all(text: &str, words: &[String]) -> bool {\n+ let lower = text.to_lowercase();\n+ words.iter().all(|w| lower.contains(w.as_str()))\n+}\n+\n+fn text_contains_any(text: &str, words: &[String]) -> usize {\n+ let lower = text.to_lowercase();\n+ words.iter().filter(|w| lower.contains(w.as_str())).count()\n+}\n+\n+fn snippet_around(text: &str, words: &[String], max_len: usize) -> String {\n+ let lower = text.to_lowercase();\n+ let first_pos = words.iter().filter_map(|w| lower.find(w.as_str())).min().unwrap_or(0);\n+ let start = first_pos.saturating_sub(max_len / 3);\n+ let start = if start > 0 {\n+ let mut i = start;\n+ while i < text.len() && !text.is_char_boundary(i) { i += 1; }\n+ text[i..].find(' ').map(|j| i + j + 1).unwrap_or(i)\n+ } else {\n+ 0\n+ };\n+ let mut end = (start + max_len).min(text.len());\n+ while end < text.len() && !text.is_char_boundary(end) { end += 1; }\n+ text[start..end].to_string()\n+}\n+\n+fn rpc_search(reduced: &ReducerState, q: &str, limit: usize) -> SearchResponse {\n+ let words = tokenize_query(q);\n+ if words.is_empty() {\n+ return SearchResponse {\n+ items: vec![],\n+ threads: vec![],\n+ posts: vec![],\n+ };\n+ }\n+ let content = reduced.public();\n+ let mut scored_items: Vec<(u32, SearchItemHit)> = Vec::new();\n+ for item in &content.items {\n+ let mut score: u32 = 0;\n+ if text_contains_all(item.as_str(), &words) { score += 10; }\n+ else if text_contains_any(item.as_str(), &words) > 0 { score += 5; }\n+ if let Some(body) = content.item_bodies.get(item) {\n+ if text_contains_all(body, &words) { score += 6; }\n+ else {\n+ let any = text_contains_any(body, &words);\n+ if any > 0 { score += any as u32; }\n+ }\n+ }\n+ if score > 0 {\n+ scored_items.push((score, SearchItemHit {\n+ path: item_path_for_api(item.as_str()),\n+ body: content.item_bodies.get(item).map(|b| snippet_around(b, &words, 120)),\n+ }));\n+ }\n+ }\n+ let mut scored_threads: Vec<(u32, i64, SearchThreadHit)> = Vec::new();\n+ for ((scope, tag), ts) in &reduced.forum_threads {\n+ if scope != &ScopeId::Public { continue; }\n+ let mut score: u32 = 0;\n+ if text_contains_all(tag, &words) { score += 8; }\n+ else if text_contains_any(tag, &words) > 0 { score += 4; }\n+ if score > 0 {\n+ let post_count = reduced\n+ .ingests_by_scope_thread\n+ .get(&(ScopeId::Public, tag.clone()))\n+ .map(|q| q.len())\n+ .unwrap_or(0);\n+ scored_threads.push((score, ts.last_activity_ts, SearchThreadHit {\n+ tag: format!(\"#{tag}\"),\n+ post_count,\n+ last_activity: ts.last_activity_ts,\n+ }));\n+ }\n+ }\n+ let mut scored_posts: Vec<(u32, i64, SearchPostHit)> = Vec::new();\n+ for (id, ingest) in &reduced.ingests_by_id {\n+ let mut score: u32 = 0;\n+ if text_contains_all(&ingest.raw, &words) { score += 4; }\n+ else {\n+ let any = text_contains_any(&ingest.raw, &words);\n+ if any > 0 { score += any as u32; }\n+ }\n+ if score > 0 {\n+ let thread = reduced\n+ .ingests_by_scope_thread\n+ .iter()\n+ .find(|(_, ids)| ids.contains(id))\n+ .map(|((scope, tag), _)| match scope {\n+ ScopeId::Public => format!(\"#{tag}\"),\n+ ScopeId::Room(rid) => format!(\"{rid}/#{tag}\"),\n+ })\n+ .unwrap_or_else(|| \"#unknown\".to_string());\n+ scored_posts.push((score, ingest.ts, SearchPostHit {\n+ thread,\n+ actor: ingest.principal.clone(),\n+ snippet: snippet_around(&ingest.raw, &words, 160),\n+ ts: ingest.ts,\n+ }));\n+ }\n+ }\n+ scored_items.sort_by(|a, b| b.0.cmp(&a.0));\n+ scored_threads.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));\n+ scored_posts.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));\n+ scored_items.truncate(limit);\n+ scored_threads.truncate(limit);\n+ scored_posts.truncate(limit);\n+ SearchResponse {\n+ items: scored_items.into_iter().map(|(_, h)| h).collect(),\n+ threads: scored_threads.into_iter().map(|(_, _, h)| h).collect(),\n+ posts: scored_posts.into_iter().map(|(_, _, h)| h).collect(),\n+ }\n+}\n+\n+async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Result {\n+ let scope = scope_from_room_wire(&room);\n+ let reduced_arc = state.reduced.clone();\n+ let pool: Vec = {\n+ let reduced = reduced_arc.read().await;\n+ let content = content_for_room(&reduced, &room);\n+ let tmp = if parent_path.trim().is_empty() {\n+ None\n+ } else {\n+ Some(parent_path.clone())\n+ };\n+ let specs = parse_parent_specs(tmp.as_ref());\n+ let raw_pool: Vec = if specs.is_empty() {\n+ content.ranking_group.idx_to_item.clone()\n+ } else {\n+ crate::scope_rank::resolve_scope(content, &specs)\n+ };\n+ raw_pool.into_iter().map(|it| it.0).collect()\n+ };\n+ if pool.len() < 2 {\n+ return Err((\n+ format!(\"need at least 2 items under parent /{}\", parent_path.trim()),\n+ Some(\"add items via ingest\".into()),\n+ ));\n+ }\n+ let selected: Option<(String, String)> = {\n+ let mut reduced = reduced_arc.write().await;\n+ let content = reduced.content.entry(scope.clone()).or_default();\n+ let group = &mut content.ranking_group;\n+ if group.idx_to_item.is_empty() {\n+ pick_random_distinct(&pool)\n+ } else {\n+ let mut rng = rand::thread_rng();\n+ let idxs: Vec = pool.iter()\n+ .filter_map(|it| {\n+ let key = CanonicalItemUrl(it.clone());\n+ group.item_to_idx.get(&key).copied()\n+ })\n+ .collect();\n+ let ranked = ranked_items_subset(group, &idxs, 10000, 1e-8);\n+ let ranked_set: HashSet = ranked.iter().map(|r| r.item.as_str().to_string()).collect();\n+ let unsorted: Vec = pool.iter()\n+ .filter(|it| !ranked_set.contains(*it))\n+ .cloned()\n+ .collect();\n+ let mut pick: Option<(String, String)> = None;\n+ if !unsorted.is_empty() {\n+ if let Some(left) = unsorted.choose(&mut rng).cloned() {\n+ let mut candidates: Vec = if !ranked.is_empty() {\n+ ranked.iter().map(|r| r.item.as_str().to_string()).collect()\n+ } else {\n+ pool.clone()\n+ };\n+ candidates.retain(|c| c != &left);\n+ if let Some(right) = candidates.choose(&mut rng).cloned() {\n+ pick = Some((left, right));\n+ }\n+ }\n+ } else if ranked.len() >= 2 {\n+ for i in 0..(ranked.len().saturating_sub(1)) {\n+ let a = ranked[i].item.as_str();\n+ let b = ranked[i + 1].item.as_str();\n+ if a != b && !is_pair_voted(group, a, b) {\n+ pick = Some((a.to_string(), b.to_string()));\n+ break;\n+ }\n+ }\n+ if pick.is_none() {\n+ for _ in 0..64 {\n+ let (Some(a), Some(b)) = (pool.choose(&mut rng).cloned(), pool.choose(&mut rng).cloned()) else { break; };\n+ if a != b && !is_pair_voted(group, &a, &b) {\n+ pick = Some((a, b));\n+ break;\n+ }\n+ }\n+ }\n+ }\n+ pick.or_else(|| pick_random_distinct(&pool))\n+ }\n+ };\n+ let Some((left, right)) = selected else {\n+ return Err((\"need at least 2 items\".into(), None));\n+ };\n+ let reduced = reduced_arc.read().await;\n+ let content = content_for_room(&reduced, &room);\n+ let left_key = CanonicalItemUrl(left.clone());\n+ let right_key = CanonicalItemUrl(right.clone());\n+ let lb = content.item_bodies.get(&left_key).cloned();\n+ let rb = content.item_bodies.get(&right_key).cloned();\n+ let th: Vec = content\n+ .item_threads\n+ .get(&left_key)\n+ .into_iter()\n+ .chain(content.item_threads.get(&right_key))\n+ .flat_map(|s| s.iter().cloned())\n+ .collect::>()\n+ .into_iter()\n+ .collect();\n+ let cs = compute_connectivity_stats(&content.ranking_group, &pool);\n+ Ok(RpcResult::Pair(PairResponse {\n+ left: item_path_for_api(&left),\n+ right: item_path_for_api(&right),\n+ left_body: lb,\n+ right_body: rb,\n+ threads: th,\n+ connectivity: Some(cs),\n+ }))\n+}\n+\n+pub async fn handle_rpc_batch(\n+ State(state): State,\n+ headers: HeaderMap,\n+ Json(RpcBatch(commands)): Json,\n+) -> impl IntoResponse {\n+ let mut results = Vec::with_capacity(commands.len());\n+\n+ for cmd in commands {\n+ let line = match cmd {\n+ RpcCommand::Post {\n+ room,\n+ thread_tag,\n+ delegate,\n+ text,\n+ return_rank_diff,\n+ } => match rpc_post(&state, &headers, room, thread_tag, delegate, text, return_rank_diff).await {\n+ Ok(r) => line_ok(r),\n+ Err((e, h)) => line_err(e, h),\n+ },\n+ RpcCommand::Check { room, text } => match rpc_check(&state, room, text).await {\n+ Ok(r) => line_ok(r),\n+ Err((e, h)) => line_err(e, h),\n+ },\n+ RpcCommand::GetGardenRank {\n+ room,\n+ parent_path,\n+ depth,\n+ offset,\n+ limit,\n+ percent,\n+ } => {\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_room(&reduced, &room);\n+ let popt = if parent_path.trim().is_empty() {\n+ None\n+ } else {\n+ Some(parent_path.as_str())\n+ };\n+ match build_rank_response_for_content(\n+ content,\n+ popt,\n+ depth.unwrap_or(1),\n+ offset.unwrap_or(0),\n+ limit,\n+ percent.unwrap_or(false),\n+ ) {\n+ Ok(r) => line_ok(RpcResult::GardenRank(r)),\n+ Err((e, h)) => line_err(e, h),\n+ }\n+ }\n+ RpcCommand::GetGardenItem {\n+ room,\n+ item_path,\n+ full,\n+ } => {\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_room(&reduced, &room);\n+ let item_str = canonicalize_item(&item_path);\n+ let item = CanonicalItemUrl(item_str.clone());\n+ if !content.items.contains(&item) {\n+ line_err(\n+ \"item not found\",\n+ Some(format!(\"{} does not exist\", item_path_for_api(&item_str))),\n+ )\n+ } else {\n+ const MAX_ITEM_BODY: usize = 10_000;\n+ let want_full = full.unwrap_or(false);\n+ let (body, truncated, body_len) = match content.item_bodies.get(&item) {\n+ None => (None, false, 0),\n+ Some(raw) => {\n+ let char_len = raw.chars().count();\n+ if !want_full && char_len > MAX_ITEM_BODY {\n+ let byte_end = raw.char_indices().nth(MAX_ITEM_BODY).map(|(i, _)| i).unwrap_or(raw.len());\n+ (Some(raw[..byte_end].to_string()), true, char_len)\n+ } else {\n+ (Some(raw.clone()), false, 0)\n+ }\n+ }\n+ };\n+ let threads: Vec = content\n+ .item_threads\n+ .get(&item)\n+ .map(|s| s.iter().cloned().collect())\n+ .unwrap_or_default();\n+ line_ok(RpcResult::GardenItem(ItemResponse {\n+ item: item_str,\n+ body,\n+ truncated,\n+ body_len,\n+ threads,\n+ }))\n+ }\n+ }\n+ RpcCommand::GetForumThread {\n+ room,\n+ thread_tag,\n+ offset,\n+ limit,\n+ since,\n+ before,\n+ actor,\n+ post_id,\n+ } => {\n+ let reduced = state.reduced.read().await;\n+ let actor_prefix = match actor.as_deref().map(str::trim) {\n+ None | Some(\"\") => Ok(String::new()),\n+ Some(s) => parse_username(s).map_err(|msg| (\"invalid actor filter\".to_string(), Some(msg))),\n+ };\n+ match actor_prefix {\n+ Err((e, h)) => line_err(e, h),\n+ Ok(actor_prefix) => {\n+ let offset = offset.unwrap_or(0);\n+ let limit = limit.unwrap_or(10).clamp(1, 500);\n+ match rpc_forum_thread_detail(\n+ &reduced,\n+ &room,\n+ &thread_tag,\n+ offset,\n+ limit,\n+ since,\n+ before,\n+ &actor_prefix,\n+ post_id.as_deref(),\n+ ) {\n+ Ok(r) => line_ok(RpcResult::ForumThread(r)),\n+ Err((e, h)) => line_err(e, h),\n+ }\n+ }\n+ }\n+ }\n+ RpcCommand::ListForumThreads { room } => {\n+ let reduced = state.reduced.read().await;\n+ line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room)))\n+ }\n+ RpcCommand::RoomCreate { slug, visibility } => {\n+ match verify_bearer_principal(&headers, &*state.reduced.read().await) {\n+ Ok(principal) => {\n+ let slug = slug.trim().to_lowercase();\n+ if slug.is_empty() || slug.len() > 64 {\n+ line_err(\"slug must be 1-64 characters\", None)\n+ } else if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {\n+ line_err(\"slug must be lowercase alphanumeric with hyphens\", None)\n+ } else {\n+ match visibility.as_deref().unwrap_or(\"private\") {\n+ \"private\" | \"public\" => {\n+ let vis = if visibility.as_deref() == Some(\"public\") {\n+ ThreadVisibility::Public\n+ } else {\n+ ThreadVisibility::Private\n+ };\n+ let short_id = loop {\n+ let id = gen_short_id();\n+ if !state.reduced.read().await.rooms.contains_key(&format!(\"{id}/{slug}\")) {\n+ break id;\n+ }\n+ };\n+ let room_id = format!(\"{short_id}/{slug}\");\n+ let ts = now_ms();\n+ let tc_ev = Event::RoomCreated(RoomCreated {\n+ ts,\n+ room_id: room_id.clone(),\n+ slug: slug.clone(),\n+ owner: principal.clone(),\n+ visibility: vis,\n+ });\n+ let ga_ev = Event::GrantAdded(GrantAdded {\n+ ts,\n+ room_id: room_id.clone(),\n+ username: principal.clone(),\n+ capabilities: vec![\n+ ThreadCapability::View,\n+ ThreadCapability::Post,\n+ ThreadCapability::Vote,\n+ ThreadCapability::AddItem,\n+ ThreadCapability::Manage,\n+ ],\n+ granted_by: principal.clone(),\n+ });\n+ if let Err(e) = state.event_log.append(&tc_ev).await {\n+ line_err(format!(\"{e}\"), None)\n+ } else if let Err(e) = state.event_log.append(&ga_ev).await {\n+ line_err(format!(\"{e}\"), None)\n+ } else {\n+ let mut r = state.reduced.write().await;\n+ r.apply_event(tc_ev);\n+ r.apply_event(ga_ev);\n+ line_ok(RpcResult::RoomCreated { room_id })\n+ }\n+ }\n+ other => line_err(format!(\"unknown visibility: {other}\"), None),\n+ }\n+ }\n+ }\n+ Err((_, m)) => line_err(m, None),\n+ }\n+ }\n+ RpcCommand::RoomGrant {\n+ room,\n+ username,\n+ capability,\n+ } => {\n+ match verify_bearer_principal(&headers, &*state.reduced.read().await) {\n+ Err((_, m)) => line_err(m, None),\n+ Ok(principal) => {\n+ let reduced = state.reduced.read().await;\n+ if !reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) {\n+ line_err(\"requires Manage capability\", None)\n+ } else {\n+ match parse_username(&username) {\n+ Err(msg) => line_err(\"invalid username\", Some(msg)),\n+ Ok(target) => {\n+ if !reduced.users_by_provider.values().any(|u| u == &target) {\n+ line_err(format!(\"user @{target} not found\"), None)\n+ } else {\n+ match parse_capability(&capability) {\n+ Err(msg) => line_err(msg, None),\n+ Ok(cap) => {\n+ let ga_ev = Event::GrantAdded(GrantAdded {\n+ ts: now_ms(),\n+ room_id: room,\n+ username: target,\n+ capabilities: vec![cap],\n+ granted_by: principal,\n+ });\n+ if let Err(e) = state.event_log.append(&ga_ev).await {\n+ line_err(format!(\"{e}\"), None)\n+ } else {\n+ let mut r = state.reduced.write().await;\n+ r.apply_event(ga_ev);\n+ line_ok(RpcResult::GrantOk {})\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ RpcCommand::GetGlobalRank {\n+ room,\n+ limit,\n+ offset,\n+ percent,\n+ } => {\n+ const DEFAULT_LIMIT: usize = 50;\n+ const MAX_LIMIT: usize = 500;\n+ let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);\n+ let offset = offset.unwrap_or(0);\n+ let want_percent = percent.unwrap_or(false);\n+ let mut reduced = state.reduced.write().await;\n+ let content = reduced.content.entry(scope_from_room_wire(&room)).or_default();\n+ let group = &mut content.ranking_group;\n+ let n = group.idx_to_item.len();\n+ let (mut comps, _) = connected_components_from_voted_pairs(\n+ n, group.voted_pairs.iter().copied(),\n+ );\n+ comps.sort_by(|a, b| b.len().cmp(&a.len()));\n+\n+ let mut ranked: Vec = Vec::new();\n+ for comp in &comps {\n+ let items = ranked_items_subset(group, comp, 10000, 1e-8);\n+ let top = items.first().map(|r| r.score).unwrap_or(1.0);\n+ let bot = items.last().map(|r| r.score).unwrap_or(0.0);\n+ let range = (top - bot).max(1e-12);\n+ for r in items {\n+ let pct = want_percent.then(|| ((r.score - bot) / range * 100.0).clamp(0.0, 100.0));\n+ ranked.push(RankRow { item: item_path_for_api(r.item.as_str()), score: r.score, percent: pct });\n+ }\n+ }\n+\n+ let ranked_total = ranked.len();\n+ let mut unranked: Vec = content\n+ .items\n+ .iter()\n+ .filter(|it| !group.item_to_idx.contains_key(*it))\n+ .map(|it| it.as_str().to_string())\n+ .collect();\n+ unranked.sort();\n+ let unranked_total = unranked.len();\n+\n+ let page: Vec = ranked\n+ .into_iter()\n+ .chain(unranked.into_iter().map(|it| RankRow {\n+ item: item_path_for_api(&it),\n+ score: 0.0,\n+ percent: want_percent.then_some(0.0),\n+ }))\n+ .skip(offset)\n+ .take(limit)\n+ .collect();\n+\n+ line_ok(RpcResult::GlobalRank(GlobalRankResponse {\n+ ranked_total,\n+ unranked_total,\n+ offset,\n+ limit,\n+ items: page,\n+ }))\n+ }\n+ RpcCommand::GetPair { room, parent_path } => match rpc_get_pair(&state, room, parent_path).await {\n+ Ok(r) => line_ok(r),\n+ Err((e, h)) => line_err(e, h),\n+ },\n+ RpcCommand::GetMatchup {\n+ room,\n+ item_path,\n+ limit,\n+ } => {\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_room(&reduced, &room);\n+ let item_str = canonicalize_item(&item_path);\n+ let item = CanonicalItemUrl(item_str.clone());\n+ let limit = limit.unwrap_or(50).clamp(1, 200);\n+ if !content.items.contains(&item) {\n+ line_err(\n+ \"item not found\",\n+ Some(format!(\"{} does not exist\", item_path_for_api(&item_str))),\n+ )\n+ } else {\n+ let votes: Vec = content\n+ .item_votes\n+ .get(&item)\n+ .map(|q| {\n+ q.iter()\n+ .take(limit)\n+ .map(|v| VoteRow {\n+ ts: v.ts,\n+ a: v.a.as_str().to_string(),\n+ b: v.b.as_str().to_string(),\n+ ratio: format!(\"{}:{}\", v.ratio_left, v.ratio_right),\n+ actor: Some(v.principal.clone()),\n+ body: v.body.clone(),\n+ thread: Some(v.thread_tag.clone()),\n+ })\n+ .collect()\n+ })\n+ .unwrap_or_default();\n+ line_ok(RpcResult::Matchup(MatchupResponse {\n+ item: item_path_for_api(&item_str),\n+ votes,\n+ }))\n+ }\n+ },\n+ RpcCommand::GetRankHistory { room, item_path } => {\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_room(&reduced, &room);\n+ let scope = scope_from_room_wire(&room);\n+ let item_str = canonicalize_item(&item_path);\n+ let item = CanonicalItemUrl(item_str.clone());\n+ let entries = content.rank_history.get(&item).cloned().unwrap_or_default();\n+ let history: Vec = entries.iter().map(|e| {\n+ let caused_by: Vec = reduced.ingests_by_id.get(&e.post_id)\n+ .and_then(|ing| crate::dsl::parse_full(&ing.raw).ok())\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 = canonicalize_item(&item1);\n+ let b = 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| ing.principal.clone()),\n+ body: explanation,\n+ thread: Some(format!(\"#{}\", e.thread)),\n+ })\n+ } else {\n+ None\n+ }\n+ } else {\n+ None\n+ }\n+ }).collect()\n+ })\n+ .unwrap_or_default();\n+ let thread_post_index = reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope.clone(), e.thread.clone()))\n+ .and_then(|q| q.iter().rev().position(|id| id == &e.post_id))\n+ .map(|i| i + 1)\n+ .unwrap_or(0);\n+ RankHistoryRow {\n+ ts: e.ts,\n+ scope_rank: e.scope_rank,\n+ scope_rank_delta: e.scope_rank_delta,\n+ scope_total: e.scope_total,\n+ global_rank: e.global_rank,\n+ global_rank_delta: e.global_rank_delta,\n+ global_total: e.global_total,\n+ score: e.score,\n+ thread: format!(\"#{}\", e.thread),\n+ thread_post_index,\n+ caused_by,\n+ }\n+ }).collect();\n+ line_ok(RpcResult::RankHistory(RankHistoryResponse {\n+ item: item_path_for_api(&item_str),\n+ history,\n+ }))\n+ },\n+ RpcCommand::GetLeaves { room } => {\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_room(&reduced, &room);\n+ let parents: HashSet<&str> = content.item_children.keys().map(|s| s.as_str()).collect();\n+ let mut paths: Vec = content\n+ .items\n+ .iter()\n+ .filter(|p| !parents.contains(p.as_str()))\n+ .map(|p| p.as_str().to_string())\n+ .collect();\n+ paths.sort();\n+ line_ok(RpcResult::Leaves(LeavesResponse { paths }))\n+ },\n+ RpcCommand::GetPaths { room } => {\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_room(&reduced, &room);\n+ let out: Vec = content\n+ .item_children\n+ .get(&CanonicalItemUrl::ontology_root())\n+ .map(|roots| {\n+ let mut v: Vec = roots.iter()\n+ .map(|path| {\n+ let children = content.item_children.get(path.as_str()).map(|s| s.len()).unwrap_or(0);\n+ PathSummary {\n+ path: format!(\"~/{}\", path),\n+ children,\n+ web: format!(\"https://slug.social/~/{}\", path),\n+ }\n+ }).collect();\n+ v.sort_by(|a, b| a.path.cmp(&b.path));\n+ v\n+ })\n+ .unwrap_or_default();\n+ line_ok(RpcResult::Paths(PathsResponse { paths: out }))\n+ },\n+ RpcCommand::GetRecentVotes {\n+ room,\n+ parent,\n+ limit,\n+ } => {\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_room(&reduced, &room);\n+ let group = &content.ranking_group;\n+ let limit = limit.unwrap_or(25).clamp(1, 200);\n+ let iter = group.recent_votes.iter();\n+ let iter: Box> = if let Some(p) = &parent {\n+ let parent_can = canonicalize_item(p);\n+ Box::new(iter.filter(move |v| vote_touches_path(v.a.as_str(), v.b.as_str(), &parent_can)))\n+ } else {\n+ Box::new(iter)\n+ };\n+ let out: Vec = iter\n+ .take(limit)\n+ .map(|v| VoteRow {\n+ ts: v.ts,\n+ a: v.a.as_str().to_string(),\n+ b: v.b.as_str().to_string(),\n+ ratio: format!(\"{}:{}\", v.ratio_left, v.ratio_right),\n+ actor: Some(v.principal.clone()),\n+ body: v.body.clone(),\n+ thread: Some(v.thread_tag.clone()),\n+ }).collect();\n+ line_ok(RpcResult::RecentVotes(RecentVotesResponse { votes: out }))\n+ },\n+ RpcCommand::Search { query } => {\n+ let reduced = state.reduced.read().await;\n+ let limit = 50usize.min(200);\n+ line_ok(RpcResult::Search(rpc_search(&reduced, &query, limit)))\n+ },\n+ RpcCommand::GetFeed {\n+ actor,\n+ since,\n+ limit,\n+ } => {\n+ const DEFAULT_LIMIT: usize = 50;\n+ const MAX_LIMIT: usize = 200;\n+ match parse_username(&actor) {\n+ Err(msg) => line_err(\"invalid actor\", Some(msg)),\n+ Ok(actor_stored) => {\n+ let reduced = state.reduced.read().await;\n+ let since = since.or_else(|| reduced.actor_last_post_ts.get(&actor_stored).copied());\n+ let cutoff = since.unwrap_or(0);\n+ let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);\n+ let matching: Vec<&str> = reduced.ingests_ordered.iter().rev()\n+ .map(|id| id.as_str())\n+ .take_while(|id| reduced.ingests_by_id.get(*id).map_or(false, |ing| ing.ts > cutoff))\n+ .collect();\n+ let total = matching.len();\n+ let posts: Vec = matching.into_iter()\n+ .take(limit)\n+ .filter_map(|id| reduced.ingests_by_id.get(id))\n+ .map(|ing| {\n+ let scope = scope_from_room_wire(&ing.room_id);\n+ let thread_post_index = reduced\n+ .ingests_by_scope_thread\n+ .get(&(scope, ing.thread_tag.clone()))\n+ .and_then(|q| {\n+ q.iter().rev().position(|pid| pid == &ing.id).map(|i| i + 1)\n+ });\n+ FeedPost {\n+ ts: ing.ts,\n+ id: ing.id.clone(),\n+ thread: Some(ing.thread_tag.clone()),\n+ thread_post_index,\n+ body: ing.raw.clone(),\n+ }\n+ })\n+ .collect();\n+ line_ok(RpcResult::Feed(FeedResponse {\n+ actor: actor_stored,\n+ since,\n+ posts,\n+ total,\n+ }))\n+ }\n+ }\n+ },\n+ };\n+ results.push(line);\n+ }\n+\n+ Json(RpcBatchResponse { results }).into_response()\n+}\ndiff --git a/server/src/api/search.rs b/server/src/api/search.rs\ndeleted file mode 100644\nindex 87621e6e4210a28c7802bb941200788f28518940..0000000000000000000000000000000000000000\n--- a/server/src/api/search.rs\n+++ /dev/null\n@@ -1,142 +0,0 @@\n-use axum::{\n- extract::{Query, State},\n- response::IntoResponse,\n- Json,\n-};\n-use serde::Deserialize;\n-\n-use crate::state::AppState;\n-\n-use super::helpers::item_path_for_api;\n-\n-// ============================================================================\n-// Search\n-// ============================================================================\n-\n-#[derive(Debug, Deserialize)]\n-pub struct SearchApiQuery {\n- pub q: Option,\n- pub limit: Option,\n-}\n-\n-fn tokenize_query(q: &str) -> Vec {\n- q.split_whitespace()\n- .map(|w| w.to_lowercase())\n- .filter(|w| !w.is_empty())\n- .collect()\n-}\n-\n-fn text_contains_all(text: &str, words: &[String]) -> bool {\n- let lower = text.to_lowercase();\n- words.iter().all(|w| lower.contains(w.as_str()))\n-}\n-\n-fn text_contains_any(text: &str, words: &[String]) -> usize {\n- let lower = text.to_lowercase();\n- words.iter().filter(|w| lower.contains(w.as_str())).count()\n-}\n-\n-fn snippet_around(text: &str, words: &[String], max_len: usize) -> String {\n- let lower = text.to_lowercase();\n- let first_pos = words.iter().filter_map(|w| lower.find(w.as_str())).min().unwrap_or(0);\n- let start = first_pos.saturating_sub(max_len / 3);\n- let start = if start > 0 {\n- let mut i = start;\n- while i < text.len() && !text.is_char_boundary(i) { i += 1; }\n- text[i..].find(' ').map(|j| i + j + 1).unwrap_or(i)\n- } else {\n- 0\n- };\n- let mut end = (start + max_len).min(text.len());\n- while end < text.len() && !text.is_char_boundary(end) { end += 1; }\n- text[start..end].to_string()\n-}\n-\n-pub async fn get_search(\n- State(state): State,\n- Query(params): Query,\n-) -> impl IntoResponse {\n- let q = params.q.unwrap_or_default();\n- let limit = params.limit.unwrap_or(50).min(200);\n-\n- let words = tokenize_query(&q);\n- if words.is_empty() {\n- return Json(slug_types::SearchResponse {\n- items: vec![], threads: vec![], posts: vec![],\n- }).into_response();\n- }\n-\n- let reduced = state.reduced.read().await;\n- let content = reduced.public();\n-\n- let mut scored_items: Vec<(u32, slug_types::SearchItemHit)> = Vec::new();\n- for item in &content.items {\n- let mut score: u32 = 0;\n- if text_contains_all(item.as_str(), &words) { score += 10; }\n- else if text_contains_any(item.as_str(), &words) > 0 { score += 5; }\n- if let Some(body) = content.item_bodies.get(item) {\n- if text_contains_all(body, &words) { score += 6; }\n- else {\n- let any = text_contains_any(body, &words);\n- if any > 0 { score += any as u32; }\n- }\n- }\n- if score > 0 {\n- scored_items.push((score, slug_types::SearchItemHit {\n- path: item_path_for_api(item.as_str()),\n- body: content.item_bodies.get(item).map(|b| snippet_around(b, &words, 120)),\n- }));\n- }\n- }\n-\n- let mut scored_threads: Vec<(u32, i64, slug_types::SearchThreadHit)> = Vec::new();\n- for (tag, ts) in &reduced.threads {\n- let mut score: u32 = 0;\n- if text_contains_all(tag, &words) { score += 8; }\n- else if text_contains_any(tag, &words) > 0 { score += 4; }\n- if score > 0 {\n- let post_count = reduced.ingests_by_thread.get(tag).map(|q| q.len()).unwrap_or(0);\n- scored_threads.push((score, ts.last_activity_ts, slug_types::SearchThreadHit {\n- tag: format!(\"#{tag}\"),\n- post_count,\n- last_activity: ts.last_activity_ts,\n- }));\n- }\n- }\n-\n- let mut scored_posts: Vec<(u32, i64, slug_types::SearchPostHit)> = Vec::new();\n- for (id, ingest) in &reduced.ingests_by_id {\n- let mut score: u32 = 0;\n- if text_contains_all(&ingest.raw, &words) { score += 4; }\n- else {\n- let any = text_contains_any(&ingest.raw, &words);\n- if any > 0 { score += any as u32; }\n- }\n- if score > 0 {\n- let thread = reduced.ingests_by_thread.iter()\n- .find(|(_, ids)| ids.contains(id))\n- .map(|(tag, _)| format!(\"#{tag}\"))\n- .unwrap_or_else(|| \"#unknown\".to_string());\n- scored_posts.push((score, ingest.ts, slug_types::SearchPostHit {\n- thread,\n- actor: ingest.principal.clone(),\n- snippet: snippet_around(&ingest.raw, &words, 160),\n- ts: ingest.ts,\n- }));\n- }\n- }\n-\n- scored_items.sort_by(|a, b| b.0.cmp(&a.0));\n- scored_threads.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));\n- scored_posts.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));\n-\n- scored_items.truncate(limit);\n- scored_threads.truncate(limit);\n- scored_posts.truncate(limit);\n-\n- Json(slug_types::SearchResponse {\n- items: scored_items.into_iter().map(|(_, h)| h).collect(),\n- threads: scored_threads.into_iter().map(|(_, _, h)| h).collect(),\n- posts: scored_posts.into_iter().map(|(_, _, h)| h).collect(),\n- }).into_response()\n-}\ndiff --git a/server/src/api/thread.rs b/server/src/api/thread.rs\ndeleted file mode 100644\nindex be6ef446e316fef352a7eef5b35b40583dca4442..0000000000000000000000000000000000000000\n--- a/server/src/api/thread.rs\n+++ /dev/null\n@@ -1,189 +0,0 @@\n-use axum::{\n- extract::{Path, State},\n- http::{HeaderMap, StatusCode},\n- response::IntoResponse,\n- Json,\n-};\n-use serde::{Deserialize, Serialize};\n-\n-use crate::{\n- api::helpers::{api_error, now_ms},\n- events::{Event, GrantAdded, ThreadCapability, ThreadCreated, ThreadVisibility},\n- identity::parse_username,\n- state::AppState,\n-};\n-use super::auth::verify_bearer_principal;\n-\n-fn gen_short_id() -> String {\n- use rand::Rng;\n- const ALPHABET: &[u8] = b\"0123456789abcdefghijklmnopqrstuvwxyz\";\n- let mut rng = rand::thread_rng();\n- (0..7).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct CreateThreadRequest {\n- pub slug: String,\n- pub visibility: Option,\n-}\n-\n-#[derive(Debug, Serialize)]\n-pub struct CreateThreadResponse {\n- pub ok: bool,\n- pub thread_id: String,\n-}\n-\n-pub async fn post_create_thread(\n- State(state): State,\n- headers: HeaderMap,\n- Json(req): Json,\n-) -> impl IntoResponse {\n- let reduced_arc = state.reduced.clone();\n- let reduced = reduced_arc.read().await;\n-\n- let principal = match verify_bearer_principal(&headers, &reduced) {\n- Ok(u) => u,\n- Err((st, msg)) => return api_error(st, msg, None).into_response(),\n- };\n-\n- let slug = req.slug.trim().to_lowercase();\n- if slug.is_empty() || slug.len() > 64 {\n- return api_error(StatusCode::BAD_REQUEST, \"slug must be 1-64 characters\", None).into_response();\n- }\n- if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {\n- return api_error(StatusCode::BAD_REQUEST, \"slug must be lowercase alphanumeric with hyphens\", None).into_response();\n- }\n-\n- let visibility = match req.visibility.as_deref().unwrap_or(\"private\") {\n- \"private\" => ThreadVisibility::Private,\n- \"public\" => ThreadVisibility::Public,\n- other => return api_error(StatusCode::BAD_REQUEST, format!(\"unknown visibility: {other}\"), None).into_response(),\n- };\n-\n- // Generate a collision-free short id.\n- let short_id = loop {\n- let id = gen_short_id();\n- // Threads are keyed by full \"shortid/slug\" in the map.\n- if !reduced.threads.contains_key(&format!(\"{id}/{slug}\")) {\n- break id;\n- }\n- };\n- let thread_id = format!(\"{short_id}/{slug}\");\n-\n- drop(reduced);\n-\n- let ts = now_ms();\n-\n- let tc_ev = Event::ThreadCreated(ThreadCreated {\n- ts,\n- thread_id: thread_id.clone(),\n- slug: slug.clone(),\n- owner: principal.clone(),\n- visibility,\n- });\n-\n- // Owner gets all capabilities.\n- let ga_ev = Event::GrantAdded(GrantAdded {\n- ts,\n- thread_id: thread_id.clone(),\n- username: principal.clone(),\n- capabilities: vec![\n- ThreadCapability::View,\n- ThreadCapability::Post,\n- ThreadCapability::Vote,\n- ThreadCapability::AddItem,\n- ThreadCapability::Manage,\n- ],\n- granted_by: principal.clone(),\n- });\n-\n- if let Err(err) = state.event_log.append(&tc_ev).await {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, format!(\"{err}\"), None).into_response();\n- }\n- if let Err(err) = state.event_log.append(&ga_ev).await {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, format!(\"{err}\"), None).into_response();\n- }\n-\n- {\n- let mut reduced = reduced_arc.write().await;\n- reduced.apply_event(tc_ev);\n- reduced.apply_event(ga_ev);\n- }\n-\n- Json(CreateThreadResponse { ok: true, thread_id }).into_response()\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct AddGrantRequest {\n- pub username: String,\n- pub capabilities: Vec,\n-}\n-\n-fn parse_capability(s: &str) -> Result {\n- match s {\n- \"view\" => Ok(ThreadCapability::View),\n- \"post\" => Ok(ThreadCapability::Post),\n- \"vote\" => Ok(ThreadCapability::Vote),\n- \"add_item\" => Ok(ThreadCapability::AddItem),\n- \"manage\" => Ok(ThreadCapability::Manage),\n- other => Err(format!(\"unknown capability: {other}\")),\n- }\n-}\n-\n-pub async fn post_thread_grants(\n- Path((short_id, slug)): Path<(String, String)>,\n- State(state): State,\n- headers: HeaderMap,\n- Json(req): Json,\n-) -> impl IntoResponse {\n- let thread_id = format!(\"{short_id}/{slug}\");\n- let reduced_arc = state.reduced.clone();\n- let reduced = reduced_arc.read().await;\n-\n- let principal = match verify_bearer_principal(&headers, &reduced) {\n- Ok(u) => u,\n- Err((st, msg)) => return api_error(st, msg, None).into_response(),\n- };\n-\n- if !reduced.user_has_cap(&thread_id, &principal, ThreadCapability::Manage) {\n- return api_error(StatusCode::FORBIDDEN, \"requires Manage capability\", None).into_response();\n- }\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- }\n-\n- if req.capabilities.is_empty() {\n- return api_error(StatusCode::BAD_REQUEST, \"capabilities must not be empty\", None).into_response();\n- }\n- let capabilities: Result, _> = req.capabilities.iter().map(|s| parse_capability(s)).collect();\n- let capabilities = match capabilities {\n- Ok(c) => c,\n- Err(msg) => return api_error(StatusCode::BAD_REQUEST, msg, None).into_response(),\n- };\n-\n- drop(reduced);\n-\n- let ga_ev = Event::GrantAdded(GrantAdded {\n- ts: now_ms(),\n- thread_id,\n- username: target,\n- capabilities,\n- granted_by: principal,\n- });\n-\n- if let Err(err) = state.event_log.append(&ga_ev).await {\n- return api_error(StatusCode::INTERNAL_SERVER_ERROR, format!(\"{err}\"), None).into_response();\n- }\n-\n- {\n- let mut reduced = reduced_arc.write().await;\n- reduced.apply_event(ga_ev);\n- }\n-\n- Json(serde_json::json!({\"ok\": true})).into_response()\n-}\ndiff --git a/server/src/api/validate.rs b/server/src/api/validate.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..27150577982f52cbd6d11bb93654dc3d4cf75cc5\n--- /dev/null\n+++ b/server/src/api/validate.rs\n@@ -0,0 +1,151 @@\n+use axum::http::StatusCode;\n+use std::collections::HashSet;\n+\n+use crate::{\n+ canonical_path::canonicalize_tag,\n+ dsl,\n+ path_types::CanonicalItemUrl,\n+ reducer::{ReducerState, ScopeId},\n+};\n+\n+use super::helpers::{item_path_for_api, resolve_item};\n+\n+#[derive(Debug)]\n+pub struct ValidatedIngest {\n+ pub doc: dsl::Document,\n+ pub ts: i64,\n+ pub raw_text: String,\n+}\n+\n+pub fn validate_ingest_document(\n+ reduced: &ReducerState,\n+ text: &str,\n+ scope: &ScopeId,\n+) -> Result)> {\n+ let public_content = reduced.public();\n+ let scoped_content = match scope {\n+ ScopeId::Public => None,\n+ _ => reduced.content_for_scope(scope),\n+ };\n+ let item_exists = |key: &CanonicalItemUrl| {\n+ scoped_content.map(|c| c.items.contains(key)).unwrap_or(false)\n+ || public_content.items.contains(key)\n+ };\n+ let body_exists = |key: &CanonicalItemUrl| {\n+ scoped_content.map(|c| c.item_bodies.contains_key(key)).unwrap_or(false)\n+ || public_content.item_bodies.contains_key(key)\n+ };\n+ let doc = match dsl::parse_full(text) {\n+ Ok(d) => d,\n+ Err(e) => {\n+ return Err((\n+ StatusCode::BAD_REQUEST,\n+ \"parse error\".to_string(),\n+ Some(format!(\"{}\", e)),\n+ ));\n+ }\n+ };\n+\n+ let ts = super::helpers::now_ms();\n+ let mut defined_in_doc: HashSet = HashSet::new();\n+\n+ for s in &doc.statements {\n+ match s {\n+ dsl::Stmt::Item { title, body } => {\n+ let item = match resolve_item(title) {\n+ Ok(v) => v,\n+ Err(msg) => {\n+ return Err((StatusCode::BAD_REQUEST, \"invalid item path\".to_string(), Some(msg)));\n+ }\n+ };\n+ let Some(body_text) = body else {\n+ return Err((\n+ StatusCode::BAD_REQUEST,\n+ format!(\"item missing body: {}\", item_path_for_api(&item)),\n+ Some(\"items must be declared with bodies, e.g. `~/path/item { ... }`\".to_string()),\n+ ));\n+ };\n+ if body_text.trim().is_empty() {\n+ return Err((\n+ StatusCode::BAD_REQUEST,\n+ format!(\"item body is empty: {}\", item_path_for_api(&item)),\n+ Some(\"write at least one sentence inside `{ ... }`\".to_string()),\n+ ));\n+ }\n+ defined_in_doc.insert(item);\n+ }\n+ dsl::Stmt::Vote { item1, item2, .. } => {\n+ let a = match resolve_item(item1) {\n+ Ok(v) => v,\n+ Err(msg) => {\n+ return Err((\n+ StatusCode::BAD_REQUEST,\n+ \"invalid vote item path\".to_string(),\n+ Some(msg),\n+ ));\n+ }\n+ };\n+ let b = match resolve_item(item2) {\n+ Ok(v) => v,\n+ Err(msg) => {\n+ return Err((\n+ StatusCode::BAD_REQUEST,\n+ \"invalid vote item path\".to_string(),\n+ Some(msg),\n+ ));\n+ }\n+ };\n+ let missing: Vec = [&a, &b]\n+ .into_iter()\n+ .filter(|it| {\n+ let key = CanonicalItemUrl((*it).clone());\n+ !defined_in_doc.contains(*it) && !item_exists(&key)\n+ })\n+ .map(|it| item_path_for_api(it))\n+ .collect();\n+ if !missing.is_empty() {\n+ return Err((\n+ StatusCode::BAD_REQUEST,\n+ \"vote references undefined item(s)\".to_string(),\n+ Some(format!(\n+ \"define items with bodies before voting. missing: {}\",\n+ missing.join(\", \")\n+ )),\n+ ));\n+ }\n+ let missing_body: Vec = [&a, &b]\n+ .into_iter()\n+ .filter(|it| {\n+ let key = CanonicalItemUrl((*it).clone());\n+ !defined_in_doc.contains(*it) && !body_exists(&key)\n+ })\n+ .map(|it| item_path_for_api(it))\n+ .collect();\n+ if !missing_body.is_empty() {\n+ return Err((\n+ StatusCode::BAD_REQUEST,\n+ \"vote references item(s) without bodies\".to_string(),\n+ Some(format!(\n+ \"missing bodies: {}. Declare each item as `/item {{ ... }}`\",\n+ missing_body.join(\", \")\n+ )),\n+ ));\n+ }\n+ }\n+ _ => {}\n+ }\n+ }\n+\n+ Ok(ValidatedIngest {\n+ doc,\n+ ts,\n+ raw_text: text.to_string(),\n+ })\n+}\n+\n+pub fn normalize_room_and_thread(room: &str, thread_tag: &str) -> (String, String) {\n+ (\n+ room.trim().to_string(),\n+ canonicalize_tag(thread_tag),\n+ )\n+}\ndiff --git a/server/src/events.rs b/server/src/events.rs\nindex 26edac7505ce4e67a042a5511113efba5fea4950..125005d89ade7b22c9b6d997f0ed81781df5e7ec 100644\n--- a/server/src/events.rs\n+++ b/server/src/events.rs\n@@ -23,7 +23,7 @@ pub enum Event {\n UserRegistered(UserRegistered),\n TokenIssued(TokenIssued),\n AgentBound(AgentBound),\n- ThreadCreated(ThreadCreated),\n+ RoomCreated(RoomCreated),\n GrantAdded(GrantAdded),\n GrantRevoked(GrantRevoked),\n /// Ingest of a DSL+prose body. Identity and routing live in event metadata.\n@@ -56,9 +56,9 @@ pub struct AgentBound {\n }\n \n #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n-pub struct ThreadCreated {\n+pub struct RoomCreated {\n pub ts: i64,\n- pub thread_id: String,\n+ pub room_id: String,\n pub slug: String,\n pub owner: String,\n pub visibility: ThreadVisibility,\n@@ -67,7 +67,7 @@ pub struct ThreadCreated {\n #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n pub struct GrantAdded {\n pub ts: i64,\n- pub thread_id: String,\n+ pub room_id: String,\n pub username: String,\n pub capabilities: Vec,\n pub granted_by: String,\n@@ -76,7 +76,7 @@ pub struct GrantAdded {\n #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n pub struct GrantRevoked {\n pub ts: i64,\n- pub thread_id: String,\n+ pub room_id: String,\n pub username: String,\n pub capabilities: Vec,\n pub revoked_by: String,\n@@ -96,8 +96,10 @@ pub struct Ingest {\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+ /// Permission boundary: `\"public\"` or private room id (`shortid/slug`).\n+ pub room_id: String,\n+ /// Forum channel within the room (e.g. `languages`).\n+ pub thread_tag: String,\n }\n \n fn generate_id() -> String {\ndiff --git a/server/src/html/editor.rs b/server/src/html/editor.rs\nindex 4f206a0a895b47592df7669cd8d2fb4d83286fe7..fae8f31adbf4f4aaf5920f08e2764892b3719eea 100644\n--- a/server/src/html/editor.rs\n+++ b/server/src/html/editor.rs\n@@ -111,7 +111,8 @@ pub async fn editor_check(\n raw: form.text.clone(),\n principal: String::new(),\n delegate: None,\n- thread_id: String::new(),\n+ room_id: \"public\".to_string(),\n+ thread_tag: String::new(),\n });\n let mut simulated = { reduced_arc.read().await.clone() };\n simulated.apply_event(event);\ndiff --git a/server/src/html/forum.rs b/server/src/html/forum.rs\nindex a4e17ddf0064b08dfa221c964ccaa0eb99948b5b..228a120065dbacd8a308ba6e8bfc6ab111877c4b 100644\n--- a/server/src/html/forum.rs\n+++ b/server/src/html/forum.rs\n@@ -8,7 +8,7 @@ use maud::{html, Markup};\n \n use crate::{\n canonical_path::canonicalize_tag,\n- reducer::ReducerState,\n+ reducer::{ReducerState, ScopeId},\n state::AppState,\n timeago,\n };\n@@ -30,10 +30,15 @@ struct ThreadRow {\n fn collect_thread_rows(reduced: &ReducerState, now: i64) -> Vec {\n let _ = now;\n reduced\n- .threads\n+ .forum_threads\n .iter()\n- .map(|(tag, thread)| {\n- let ingests = reduced.ingests_by_thread.get(tag).map(|q| q.len()).unwrap_or(0);\n+ .filter(|((scope, _), _)| scope == &ScopeId::Public)\n+ .map(|((_, tag), thread)| {\n+ let ingests = reduced\n+ .ingests_by_scope_thread\n+ .get(&(ScopeId::Public, tag.clone()))\n+ .map(|q| q.len())\n+ .unwrap_or(0);\n ThreadRow {\n tag: tag.clone(),\n subtitle: None,\n@@ -163,12 +168,12 @@ pub async fn thread_view(\n ) -> impl IntoResponse {\n let tag = canonicalize_tag(&tag);\n \n- // ingests_by_thread is newest-first; collect all IDs in chronological order.\n+ // Newest-first queue → chronological for the page.\n let all_ids: Vec = {\n let reduced = state.reduced.read().await;\n reduced\n- .ingests_by_thread\n- .get(&tag)\n+ .ingests_by_scope_thread\n+ .get(&(ScopeId::Public, tag.clone()))\n .map(|q| q.iter().rev().cloned().collect())\n .unwrap_or_default()\n };\n@@ -243,7 +248,7 @@ pub async fn thread_post_view(\n let index: usize = index_str.parse().unwrap_or(0);\n let (ing, subtitle) = {\n let reduced = state.reduced.read().await;\n- let ing = reduced.ingests_by_thread.get(&tag)\n+ let ing = reduced.ingests_by_scope_thread.get(&(ScopeId::Public, tag.clone()))\n .and_then(|q| q.iter().rev().nth(index))\n .and_then(|id| reduced.ingests_by_id.get(id).cloned());\n let subtitle: Option = None;\n@@ -288,8 +293,8 @@ pub async fn thread_post_expand(\n let ing = {\n let reduced = state.reduced.read().await;\n reduced\n- .ingests_by_thread\n- .get(&tag)\n+ .ingests_by_scope_thread\n+ .get(&(ScopeId::Public, tag.clone()))\n .and_then(|q| q.iter().rev().nth(index))\n .and_then(|id| reduced.ingests_by_id.get(id).cloned())\n };\ndiff --git a/server/src/html/garden.rs b/server/src/html/garden.rs\nindex dcd7f0d4a2d7b602026b643c564d212cb084ff9f..af9cc5be89a3a259c719247c3c83aa33804cdf65 100644\n--- a/server/src/html/garden.rs\n+++ b/server/src/html/garden.rs\n@@ -217,7 +217,7 @@ fn build_rank_history(\n .map(|ing| ing.principal.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+ thread_tag: e.thread.clone(),\n })\n } else { None }\n } else { None }\n@@ -225,8 +225,8 @@ fn build_rank_history(\n })\n .unwrap_or_default();\n \n- let thread_post_index = reduced.ingests_by_thread\n- .get(&e.thread)\n+ let thread_post_index = reduced.ingests_by_scope_thread\n+ .get(&(crate::reducer::ScopeId::Public, e.thread.clone()))\n .and_then(|q| q.iter().rev().position(|id| id == &e.post_id))\n .map(|i| i + 1)\n .unwrap_or(0);\n@@ -481,7 +481,8 @@ mod tests {\n raw: raw.to_string(),\n principal: \"testuser\".to_string(),\n delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n- thread_id: String::new(),\n+ room_id: \"public\".to_string(),\n+ thread_tag: String::new(),\n }));\n }\n \ndiff --git a/server/src/html/search.rs b/server/src/html/search.rs\nindex df5cb0a59ff72956f4bc94e53b86ab8337452817..9353cf229fbbd2cfbab5b89ba63ebe192042c91f 100644\n--- a/server/src/html/search.rs\n+++ b/server/src/html/search.rs\n@@ -6,7 +6,7 @@ use maud::{html, Markup, PreEscaped};\n use serde::Deserialize;\n \n use crate::{\n- reducer::ReducerState,\n+ reducer::{ReducerState, ScopeId},\n state::AppState,\n timeago,\n };\n@@ -113,8 +113,11 @@ fn search(state: &ReducerState, q: &str, limit: usize) -> SearchResults {\n }\n }\n \n- // Search threads\n- for (tag, thread_state) in &state.threads {\n+ // Search threads (public room only in HTML)\n+ for ((scope, tag), thread_state) in &state.forum_threads {\n+ if scope != &ScopeId::Public {\n+ continue;\n+ }\n let mut score: u32 = 0;\n if contains_all(tag, &words) {\n score += 8;\n@@ -122,7 +125,11 @@ fn search(state: &ReducerState, q: &str, limit: usize) -> SearchResults {\n score += 4;\n }\n if score > 0 {\n- let post_count = state.ingests_by_thread.get(tag).map(|q| q.len()).unwrap_or(0);\n+ let post_count = state\n+ .ingests_by_scope_thread\n+ .get(&(ScopeId::Public, tag.clone()))\n+ .map(|q| q.len())\n+ .unwrap_or(0);\n scored_threads.push((score, ThreadRow {\n tag: tag.clone(),\n subtitle: None,\n@@ -145,10 +152,13 @@ fn search(state: &ReducerState, q: &str, limit: usize) -> SearchResults {\n }\n if score > 0 {\n let thread = state\n- .ingests_by_thread\n+ .ingests_by_scope_thread\n .iter()\n .find(|(_, ids)| ids.contains(id))\n- .map(|(tag, _)| tag.clone())\n+ .map(|((scope, tag), _)| match scope {\n+ ScopeId::Public => format!(\"#{tag}\"),\n+ ScopeId::Room(rid) => format!(\"{rid}/#{tag}\"),\n+ })\n .unwrap_or_else(|| \"unknown\".to_string());\n scored_posts.push((score, PostRow {\n thread,\ndiff --git a/server/src/html/tree.rs b/server/src/html/tree.rs\nindex 339f69af3b3279918a97b1dced62e4e3e0b528e3..6aed7db08682f088df3d52d0358b8dc954e9e1df 100644\n--- a/server/src/html/tree.rs\n+++ b/server/src/html/tree.rs\n@@ -713,7 +713,8 @@ mod tests {\n raw: raw.to_string(),\n principal: \"tester\".to_string(),\n delegate: None,\n- thread_id: String::new(),\n+ room_id: \"public\".to_string(),\n+ thread_tag: String::new(),\n })\n }\n \ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 173fbdd6b3f23c062a200b268d04491d5e030b3f..d00b56d909e1fabd1d122d054dd02785e13e0bd8 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -16,6 +16,7 @@ pub mod state;\n pub mod timeago;\n \n use axum::Router;\n+use axum::routing::post;\n use tower_http::trace::TraceLayer;\n \n use crate::state::AppState;\n@@ -34,29 +35,7 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/api/v0/pending-session\", axum::routing::post(api::post_pending_session))\n .route(\"/api/v0/pending-session/:id\", axum::routing::get(api::get_pending_session))\n .route(\"/api/v0/whoami\", axum::routing::get(api::get_whoami))\n- .route(\"/debug/query\", axum::routing::get(api::get_debug_query))\n- .route(\"/api/v0/paths\", axum::routing::get(api::get_paths))\n- .route(\"/api/v0/leaves\", axum::routing::get(api::get_leaves))\n- .route(\"/api/v0/threads\", axum::routing::get(api::get_threads))\n- .route(\"/api/v0/thread\", axum::routing::get(api::get_thread))\n- .route(\"/api/v0/item\", axum::routing::get(api::get_item))\n- .route(\"/api/v0/matchup\", axum::routing::get(api::get_matchup))\n- .route(\"/api/v0/recent_votes\", axum::routing::get(api::get_recent_votes))\n- // REMOVED: /api/v0/vote - use /api/v0/ingest instead\n- .route(\"/api/v0/thread\", axum::routing::post(api::post_create_thread))\n- .route(\"/api/v0/thread/:short_id/:slug/grants\", axum::routing::post(api::post_thread_grants))\n- .route(\"/api/v0/ingest\", axum::routing::post(api::post_ingest))\n- .route(\"/api/v0/check\", axum::routing::post(api::post_check))\n- .route(\"/api/v0/pair\", axum::routing::get(api::get_pair))\n- .route(\"/api/v0/rank\", axum::routing::get(api::get_rank))\n- .route(\"/api/v0/global-rank\", axum::routing::get(api::get_global_rank))\n- .route(\"/api/v0/rank-history\", axum::routing::get(api::get_rank_history))\n- .route(\"/api/v0/feed\", axum::routing::get(api::get_feed))\n- .route(\"/api/v0/search\", axum::routing::get(api::get_search))\n- // .route(\"/api/v0/stream\", axum::routing::get(api::get_stream))\n+ .route(\"/api/v0/rpc\", post(api::handle_rpc_batch))\n .with_state(state)\n- // view_count_middleware currently disabled while HTML views are offline\n .layer(TraceLayer::new_for_http())\n }\n-\n-\ndiff --git a/server/src/paths.rs b/server/src/paths.rs\nindex 23fb67cab17658dab655c30f1e601207a8263c24..85dd839db2d5d3f1924fbff5ae9d83d61c0690bd 100644\n--- a/server/src/paths.rs\n+++ b/server/src/paths.rs\n@@ -266,26 +266,21 @@ mod tests {\n }\n \n // -----------------------------------------------------------------------\n- // Mutation 7: self.ingests_by_thread.entry(thread).or_default().push_front(ing.id.clone())\n- //\n- // Original (reducer.rs line 419-420):\n- // let q = self.ingests_by_thread.entry(thread).or_default();\n- // q.push_front(ing.id.clone());\n- //\n- // Path version:\n- // nav!(self.ingests_by_thread, keypath(thread), push_front(ing_id));\n+ // Mutation 7: ingests_by_scope_thread (scope, thread_tag) push_front\n // -----------------------------------------------------------------------\n #[test]\n- fn mutation_7_ingests_by_thread() {\n- let mut ingests_by_thread: HashMap> = HashMap::new();\n+ fn mutation_7_ingests_by_scope_thread() {\n+ use crate::reducer::ScopeId;\n+ let mut ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque> = HashMap::new();\n let thread = \"debate-1\".to_string();\n let id1 = \"ing-1\".to_string();\n let id2 = \"ing-2\".to_string();\n+ let k = (ScopeId::Public, thread.clone());\n \n- nav!(ingests_by_thread, keypath(thread.clone()), push_front(id1));\n- nav!(ingests_by_thread, keypath(thread), push_front(id2));\n+ nav!(ingests_by_scope_thread, keypath(k.clone()), push_front(id1));\n+ nav!(ingests_by_scope_thread, keypath(k), push_front(id2));\n \n- let q = ingests_by_thread.get(\"debate-1\").unwrap();\n+ let q = ingests_by_scope_thread.get(&(ScopeId::Public, \"debate-1\".to_string())).unwrap();\n assert_eq!(q[0], \"ing-2\"); // most recent first\n assert_eq!(q[1], \"ing-1\");\n }\ndiff --git a/server/src/ranking.rs b/server/src/ranking.rs\nindex f70da0d076da2ba8f0abb5a4415babae1c2305f9..b24e80ddd5f34b4e454bb81b541e8f54bd97a541 100644\n--- a/server/src/ranking.rs\n+++ b/server/src/ranking.rs\n@@ -266,7 +266,7 @@ mod tests {\n body: \"because\".to_string(),\n principal: \"test\".to_string(),\n delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n- thread_id: \"untagged\".to_string(),\n+ thread_tag: \"untagged\".to_string(),\n }\n }\n \ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 550bcda1a1068df13908d19484d86ad46d65ddf1..118cb19f4df4a083df2878ca7a1424f3d63de851 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -9,11 +9,20 @@ use crate::path_types::CanonicalItemUrl;\n #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]\n pub enum ScopeId {\n Public,\n- Thread(String),\n+ Room(String),\n+}\n+\n+/// Wire `room` field → content scope (`\"public\"` → [`ScopeId::Public`]).\n+pub fn scope_from_room_wire(room: &str) -> ScopeId {\n+ let r = room.trim();\n+ if r.is_empty() || r == \"public\" {\n+ ScopeId::Public\n+ } else {\n+ ScopeId::Room(r.to_string())\n+ }\n }\n \n /// Parsed vote data (internal representation).\n-// Note: NOT a wire-format struct — this is internal only. `a` and `b` are CanonicalItemUrl.\n #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n pub struct VoteData {\n pub ts: i64,\n@@ -22,15 +31,12 @@ pub struct VoteData {\n pub ratio_left: i32,\n pub ratio_right: i32,\n pub body: String,\n- /// Human principal username (no `@` in stored events).\n pub principal: 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+ /// Forum channel where this vote was cast (tag only, not room id).\n+ pub thread_tag: String,\n }\n \n-/// Single ranking group (one-ranking model). All votes contribute to this group.\n #[derive(Debug, Clone)]\n pub struct GroupState {\n pub item_to_idx: HashMap,\n@@ -41,10 +47,8 @@ pub struct GroupState {\n \n /// Unordered pairs that have at least one vote recorded between them (i,\n-\n pub dirty: bool,\n pub cached_scores: Vec,\n-\n pub recent_votes: VecDeque,\n }\n \n@@ -100,7 +104,7 @@ 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.thread_id = canonicalize_tag(&vote.thread_id);\n+ vote.thread_tag = canonicalize_tag(&vote.thread_tag);\n if vote.ratio_left < 0 {\n vote.ratio_left = 0;\n }\n@@ -147,18 +151,20 @@ pub struct RankHistoryEntry {\n pub post_id: String,\n }\n \n-/// First-class thread metadata.\n #[derive(Debug, Clone)]\n-pub struct ThreadState {\n- pub last_activity_ts: i64,\n+pub struct RoomState {\n pub visibility: crate::events::ThreadVisibility,\n }\n \n-impl Default for ThreadState {\n+#[derive(Debug, Clone)]\n+pub struct ForumThreadState {\n+ pub last_activity_ts: i64,\n+}\n+\n+impl Default for ForumThreadState {\n fn default() -> Self {\n Self {\n last_activity_ts: 0,\n- visibility: crate::events::ThreadVisibility::Public,\n }\n }\n }\n@@ -188,23 +194,21 @@ 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 id (naked `uuid:rig:model`) -> username\n pub agent_bindings: HashMap,\n \n- pub ingests_by_id: HashMap,\n- /// Thread -> recent ingest ids (most recent first).\n- pub ingests_by_thread: HashMap>,\n-\n- /// First-class thread state: bump time, subscriber count.\n- pub threads: HashMap,\n \n- /// Last ingest timestamp (ms) per actor. Used by feed to default `since`.\n+ pub ingests_by_id: HashMap,\n+ /// (scope, thread_tag) → ingest ids, newest first.\n+ pub ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque>,\n+ /// Private (or public) room registry: room_id → visibility from [`RoomCreated`].\n+ pub rooms: HashMap,\n+ /// (scope, thread_tag) → last activity.\n+ pub forum_threads: HashMap<(ScopeId, String), ForumThreadState>,\n pub actor_last_post_ts: HashMap,\n \n /// All ingest IDs in chronological order (oldest first). Used by the feed endpoint.\n pub ingests_ordered: Vec,\n-\n- /// thread_id -> username -> set of capabilities\n+ /// room_id → username → capabilities\n pub grants: HashMap>>,\n }\n \n@@ -213,10 +217,9 @@ impl ReducerState {\n self.content.get(scope)\n }\n \n- /// Check whether `username` has `cap` in `thread_id`.\n- pub fn user_has_cap(&self, thread_id: &str, username: &str, cap: ThreadCapability) -> bool {\n+ pub fn user_has_cap(&self, room_id: &str, username: &str, cap: ThreadCapability) -> bool {\n self.grants\n- .get(thread_id)\n+ .get(room_id)\n .and_then(|t| t.get(username))\n .map(|caps| caps.contains(&cap))\n .unwrap_or(false)\n@@ -346,24 +349,23 @@ impl ReducerState {\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+ Event::RoomCreated(rc) => {\n+ self.rooms.insert(\n+ rc.room_id.clone(),\n+ RoomState {\n+ visibility: rc.visibility,\n+ },\n+ );\n }\n Event::Ingest(mut ing) => {\n- ing.thread_id = canonicalize_tag(&ing.thread_id);\n+ ing.thread_tag = canonicalize_tag(&ing.thread_tag);\n+ let room_key = ing.room_id.trim().to_string();\n+ let scope = scope_from_room_wire(&room_key);\n+ let canonical_thread = ing.thread_tag.clone();\n+ let scope_thread_key = (scope.clone(), canonical_thread.clone());\n \n self.ingests_by_id.insert(ing.id.clone(), ing.clone());\n \n- let visibility = self\n- .threads\n- .entry(ing.thread_id.clone())\n- .or_default()\n- .visibility;\n- let scope = match visibility {\n- crate::events::ThreadVisibility::Public => ScopeId::Public,\n- crate::events::ThreadVisibility::Private => ScopeId::Thread(ing.thread_id.clone()),\n- };\n-\n let doc = match crate::dsl::parse_full(&ing.raw) {\n Ok(d) => d,\n Err(e) => {\n@@ -386,10 +388,9 @@ impl ReducerState {\n \n let principal = ing.principal.clone();\n let delegate = ing.delegate.clone();\n- let canonical_thread: String = ing.thread_id.clone();\n \n {\n- let content = self.content_for_scope_mut(scope);\n+ let content = self.content_for_scope_mut(scope.clone());\n \n // Snapshot ranks before votes are applied (force-compute if stale).\n let before: HashMap = if !voted_items.is_empty() {\n@@ -446,7 +447,7 @@ impl ReducerState {\n body: explanation,\n principal: principal.clone(),\n delegate: delegate.clone(),\n- thread_id: canonical_thread.clone(),\n+ thread_tag: canonical_thread.clone(),\n };\n \n ingest_items.insert(item_a.clone());\n@@ -513,12 +514,11 @@ impl ReducerState {\n }\n }\n \n- // Bump thread state for the canonical thread.\n- let ts = self.threads.entry(canonical_thread.clone()).or_default();\n- nav!(ts.last_activity_ts, selected(ing.ts > ts.last_activity_ts, setval(ing.ts)));\n+ let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default();\n+ let prev_ts = ft.last_activity_ts;\n+ nav!(ft.last_activity_ts, selected(ing.ts > prev_ts, setval(ing.ts)));\n \n- // Index ingest by canonical thread.\n- nav!(self.ingests_by_thread, keypath(canonical_thread.clone()), push_front(ing.id.clone()));\n+ nav!(self.ingests_by_scope_thread, keypath(scope_thread_key), push_front(ing.id.clone()));\n \n nav!(self.ingests_ordered, push_back(ing.id.clone()));\n \n@@ -526,7 +526,7 @@ impl ReducerState {\n }\n Event::GrantAdded(ga) => {\n let caps = self.grants\n- .entry(ga.thread_id)\n+ .entry(ga.room_id)\n .or_default()\n .entry(ga.username)\n .or_default();\n@@ -535,18 +535,18 @@ impl ReducerState {\n }\n }\n Event::GrantRevoked(gr) => {\n- if let Some(thread_grants) = self.grants.get_mut(&gr.thread_id) {\n+ if let Some(room_grants) = self.grants.get_mut(&gr.room_id) {\n let username = gr.username;\n- if let Some(caps) = thread_grants.get_mut(&username) {\n+ if let Some(caps) = room_grants.get_mut(&username) {\n for cap in &gr.capabilities {\n caps.remove(cap);\n }\n if caps.is_empty() {\n- thread_grants.remove(&username);\n+ room_grants.remove(&username);\n }\n }\n- if thread_grants.is_empty() {\n- self.grants.remove(&gr.thread_id);\n+ if room_grants.is_empty() {\n+ self.grants.remove(&gr.room_id);\n }\n }\n }\n@@ -564,8 +564,9 @@ impl Default for ReducerState {\n tokens_by_id: HashMap::new(),\n agent_bindings: HashMap::new(),\n ingests_by_id: HashMap::new(),\n- ingests_by_thread: HashMap::new(),\n- threads: HashMap::new(),\n+ ingests_by_scope_thread: HashMap::new(),\n+ rooms: HashMap::new(),\n+ forum_threads: HashMap::new(),\n actor_last_post_ts: HashMap::new(),\n ingests_ordered: Vec::new(),\n grants: HashMap::new(),\ndiff --git a/server/tests/basic.rs b/server/tests/basic.rs\nindex 21cacd3049c9f5a307e75eefd5ebfc7bf0097b5d..b39bef181451088ca948b044b5ee2f7468b707f9 100644\n--- a/server/tests/basic.rs\n+++ b/server/tests/basic.rs\n@@ -3,7 +3,7 @@ use slugsocial_server::{\n canonical_path::{canonicalize_item, canonicalize_tag},\n events::{Event, Ingest},\n ranking::ranked_items,\n- reducer::{GroupState, ReducerState},\n+ reducer::{GroupState, ReducerState, ScopeId},\n };\n \n \n@@ -17,7 +17,8 @@ fn ingest_event(ts: i64, raw: &str) -> Event {\n raw: raw.to_string(),\n principal: \"test\".to_string(),\n delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n- thread_id: \"t\".to_string(),\n+ room_id: \"public\".to_string(),\n+ thread_tag: \"t\".to_string(),\n })\n }\n \n@@ -94,12 +95,12 @@ fn reducer_handles_item_and_body_from_ingest() {\n #[test]\n fn reducer_indexes_item_threads_and_vote_thread() {\n let mut state = ReducerState::default();\n- // Thread routing is metadata now (ingest.thread_id), not parsed from raw.\n+ // Thread routing is metadata (ingest.thread_tag), not parsed from raw.\n let mut ev = match ingest_event(1, \"~/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 Event::Ingest(i) => i,\n _ => unreachable!(),\n };\n- ev.thread_id = \"sorting-hat\".to_string();\n+ ev.thread_tag = \"sorting-hat\".to_string();\n state.apply_event(Event::Ingest(ev));\n \n let content = state.public();\n@@ -109,7 +110,7 @@ fn reducer_indexes_item_threads_and_vote_thread() {\n assert!(threads_for_mergesort.contains(\"sorting-hat\"));\n \n let vote = content.item_votes.get(\"https://slug.social/~/sorts/insertion\").unwrap().front().unwrap();\n- assert_eq!(vote.thread_id, \"sorting-hat\");\n+ assert_eq!(vote.thread_tag, \"sorting-hat\");\n }\n \n #[test]\n@@ -493,7 +494,7 @@ fn reducer_negative_ratio_clamped_to_zero() {\n body: \"negative\".to_string(),\n principal: \"test\".to_string(),\n delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n- thread_id: \"t\".to_string(),\n+ thread_tag: \"t\".to_string(),\n });\n assert_eq!(group.idx_to_item.len(), 2);\n // Both edges should exist (negatives clamped to 0, then 0:0 -> 1:1)\n@@ -664,22 +665,23 @@ fn test_actor_last_post_ts() {\n #[test]\n fn test_thread_timestamp_bump() {\n let mut state = ReducerState::default();\n+ let key = (ScopeId::Public, \"my-thread\".to_string());\n let mut ev1 = match ingest_event(100, \"~/t/a {a}\\n\") { Event::Ingest(i) => i, _ => unreachable!() };\n- ev1.thread_id = \"my-thread\".to_string();\n+ ev1.thread_tag = \"my-thread\".to_string();\n state.apply_event(Event::Ingest(ev1));\n- assert_eq!(state.threads.get(\"my-thread\").unwrap().last_activity_ts, 100);\n+ assert_eq!(state.forum_threads.get(&key).unwrap().last_activity_ts, 100);\n \n let mut ev2 = match ingest_event(200, \"~/t/b {b}\\n\") { Event::Ingest(i) => i, _ => unreachable!() };\n- ev2.thread_id = \"my-thread\".to_string();\n+ ev2.thread_tag = \"my-thread\".to_string();\n state.apply_event(Event::Ingest(ev2));\n- assert_eq!(state.threads.get(\"my-thread\").unwrap().last_activity_ts, 200);\n+ assert_eq!(state.forum_threads.get(&key).unwrap().last_activity_ts, 200);\n \n // Ingest with earlier timestamp should NOT regress\n let mut ev3 = match ingest_event(150, \"~/t/c {c}\\n\") { Event::Ingest(i) => i, _ => unreachable!() };\n- ev3.thread_id = \"my-thread\".to_string();\n+ ev3.thread_tag = \"my-thread\".to_string();\n state.apply_event(Event::Ingest(ev3));\n assert_eq!(\n- state.threads.get(\"my-thread\").unwrap().last_activity_ts,\n+ state.forum_threads.get(&key).unwrap().last_activity_ts,\n 200,\n \"thread timestamp should not regress to an earlier value\"\n );\n@@ -692,7 +694,7 @@ fn test_thread_id_is_used_for_votes_and_indexes() {\n Event::Ingest(i) => i,\n _ => unreachable!(),\n };\n- ev.thread_id = \"first\".to_string();\n+ ev.thread_tag = \"first\".to_string();\n state.apply_event(Event::Ingest(ev));\n \n let content = state.public();\n@@ -702,8 +704,8 @@ fn test_thread_id_is_used_for_votes_and_indexes() {\n .unwrap()\n .front()\n .unwrap();\n- assert_eq!(vote.thread_id, \"first\");\n- assert!(state.ingests_by_thread.contains_key(\"first\"));\n+ assert_eq!(vote.thread_tag, \"first\");\n+ assert!(state.ingests_by_scope_thread.contains_key(&(ScopeId::Public, \"first\".to_string())));\n assert!(state\n .public()\n .item_threads\n@@ -772,10 +774,13 @@ fn test_ingests_by_thread_ordering() {\n let mut state = ReducerState::default();\n for ts in [100, 200, 300] {\n let mut ev = match ingest_event(ts, \"~/t/a {a}\\n\") { Event::Ingest(i) => i, _ => unreachable!() };\n- ev.thread_id = \"order-thread\".to_string();\n+ ev.thread_tag = \"order-thread\".to_string();\n state.apply_event(Event::Ingest(ev));\n }\n- let thread_ingests = state.ingests_by_thread.get(\"order-thread\").unwrap();\n+ let thread_ingests = state\n+ .ingests_by_scope_thread\n+ .get(&(ScopeId::Public, \"order-thread\".to_string()))\n+ .unwrap();\n assert_eq!(thread_ingests.len(), 3);\n // Most recent first (push_front ordering)\n assert_eq!(thread_ingests[0], \"test-300\");\ndiff --git a/server/tests/debug_query_params.rs b/server/tests/debug_query_params.rs\ndeleted file mode 100644\nindex 42ec000a57716060a3d14ffb5adc121405f37714..0000000000000000000000000000000000000000\n--- a/server/tests/debug_query_params.rs\n+++ /dev/null\n@@ -1,49 +0,0 @@\n-use axum::{\n- body::Body,\n- http::{Request, StatusCode},\n-};\n-use tower::ServiceExt as _;\n-\n-use slugsocial_server::state::{AppConfig, AppState};\n-\n-#[tokio::test]\n-async fn debug_query_parses_repeated_open_array_and_decodes() {\n- let tmp = tempfile::tempdir().expect(\"tempdir\");\n- let data_dir = tmp.path().to_string_lossy().to_string();\n- let event_log_path = format!(\"{}/events.jsonl\", data_dir);\n-\n- let state = AppState::new(AppConfig {\n- data_dir,\n- event_log_path,\n- });\n-\n- let app = slugsocial_server::create_app(state);\n-\n- // ~%2Fmodels%2Fllms == \"~/models/llms\"\n- let uri = \"/debug/query?open=~%2Fmodels%2Fllms&open=~%2Fmusic%2Fjazz&selected=~%2Fmodels%2Fllms&q=hello+world\";\n-\n- let resp = app\n- .oneshot(\n- Request::builder()\n- .method(\"GET\")\n- .uri(uri)\n- .body(Body::empty())\n- .unwrap(),\n- )\n- .await\n- .unwrap();\n-\n- assert_eq!(resp.status(), StatusCode::OK);\n- let body = axum::body::to_bytes(resp.into_body(), usize::MAX)\n- .await\n- .unwrap();\n- let v: serde_json::Value = serde_json::from_slice(&body).unwrap();\n-\n- assert_eq!(\n- v[\"open\"],\n- serde_json::json!([\"~/models/llms\", \"~/music/jazz\"])\n- );\n- assert_eq!(v[\"selected\"], serde_json::json!(\"~/models/llms\"));\n- assert_eq!(v[\"q\"], serde_json::json!(\"hello world\"));\n-}\n-\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex eaa25f2c5cc21df3096d9b8f54c060c83207bfd6..9162bd5bee84035e3908bfb9c8e201b7878ca339 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -21,6 +21,27 @@ fn test_bearer() -> String {\n format!(\"slug_{token_id}_{secret}\")\n }\n \n+/// `commands` is a JSON array of RPC commands (`RpcBatch` is a transparent `Vec`).\n+async fn rpc_batch(\n+ client: &reqwest::Client,\n+ addr: SocketAddr,\n+ bearer: Option<&str>,\n+ commands: serde_json::Value,\n+) -> serde_json::Value {\n+ let url = format!(\"http://{}/api/v0/rpc\", addr);\n+ let mut req = client.post(url).json(&commands);\n+ if let Some(b) = bearer {\n+ req = req.header(\"Authorization\", format!(\"Bearer {b}\"));\n+ }\n+ let response = req.send().await.unwrap();\n+ assert!(\n+ response.status().is_success(),\n+ \"rpc http {}\",\n+ response.status()\n+ );\n+ response.json().await.unwrap()\n+}\n+\n async fn seed_test_token(state: &AppState) {\n let token_id = \"testtok\";\n let secret = \"secret\";\n@@ -83,47 +104,41 @@ async fn test_index_page() {\n async fn test_ingest_actor_with_colons_is_detected_and_validated() {\n let (addr, _tmp, _log, _handle) = create_test_server().await;\n let client = reqwest::Client::new();\n+ let b = test_bearer();\n \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- \"thread\": \"t\",\n- \"text\": \"~/x {x}\\n\",\n- });\n-\n- let response = client\n- .post(&format!(\"http://{}/api/v0/ingest\", addr))\n- .json(&ingest_payload)\n- .send()\n- .await\n- .unwrap();\n-\n- assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);\n- let body: serde_json::Value = response.json().await.unwrap();\n- assert_eq!(body[\"ok\"], false);\n- assert_eq!(body[\"error\"], \"invalid delegate format\");\n- let hint = body[\"hint\"].as_str().unwrap_or_default();\n+ let bad_delegate = serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"t\",\n+ \"delegate\": \"aec1e31c:claudecode:anthropic/claude-sonnet-4.5\",\n+ \"text\": \"~/x {x}\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]);\n+ let body = rpc_batch(&client, addr, Some(&b), bad_delegate).await;\n+ let line = &body[\"results\"][0];\n+ assert_eq!(line[\"ok\"], false);\n+ assert_eq!(line[\"error\"], \"invalid delegate format\");\n+ let hint = line[\"hint\"].as_str().unwrap_or_default();\n assert!(\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+ let at_batch = serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"t\",\n+ \"delegate\": \"@00000000-0000-0000-0000-000000000000:test:local/test\",\n+ \"text\": \"~/x {x}\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]);\n+ let at_body = rpc_batch(&client, addr, Some(&b), at_batch).await;\n+ let at_line = &at_body[\"results\"][0];\n+ assert_eq!(at_line[\"ok\"], false);\n+ let at_hint = at_line[\"hint\"].as_str().unwrap_or_default();\n assert!(\n at_hint.contains('@'),\n \"hint should reject '@' in delegate, got: {at_hint}\"\n@@ -135,25 +150,20 @@ async fn test_vote_endpoint() {\n let (addr, _tmp, _log, _handle) = create_test_server().await;\n let client = reqwest::Client::new();\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- \"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-\n- let response = client\n- .post(format!(\"http://{}/api/v0/ingest\", addr))\n- .header(\"Authorization\", format!(\"Bearer {}\", test_bearer()))\n- .json(&ingest_payload)\n- .send()\n- .await\n- .unwrap();\n-\n- assert!(response.status().is_success());\n- let body: serde_json::Value = response.json().await.unwrap();\n- assert_eq!(body[\"ok\"], true);\n- assert!(body[\"next\"].is_object());\n+ let batch = serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"cli\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\n+ \"text\": \"~/clap {cli parser}\\n~/argh {cli parser}\\n~/clap 3:1 ~/argh {because clap is more full-featured}\\n\",\n+ \"return_rank_diff\": true\n+ }\n+ }]);\n+ let body = rpc_batch(&client, addr, Some(&test_bearer()), batch).await;\n+ let line = &body[\"results\"][0];\n+ assert_eq!(line[\"ok\"], true);\n+ let post = &line[\"result\"][\"PostOk\"];\n+ assert!(post[\"next\"].is_object());\n }\n \n #[tokio::test]\n@@ -161,37 +171,35 @@ async fn test_rank_endpoint() {\n let (addr, _tmp, _log, _handle) = create_test_server().await;\n let client = reqwest::Client::new();\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- \"thread\": \"langs\",\n- \"text\": \"~/rust {systems}\\n~/go {concurrency}\\n~/rust 3:1 ~/go {because i prefer rust for systems work}\\n\",\n- });\n- client\n- .post(format!(\"http://{}/api/v0/ingest\", addr))\n- .header(\"Authorization\", format!(\"Bearer {}\", test_bearer()))\n- .json(&ingest_payload)\n- .send()\n- .await\n- .unwrap();\n-\n- // Then query ranking (global: parent=~ — default empty parent only ranks direct children of \"\")\n- let response = client\n- .get(&format!(\"http://{}/api/v0/rank\", addr))\n- .query(&[(\"parent\", \"~\")])\n- .send()\n- .await\n- .unwrap();\n-\n- assert!(response.status().is_success());\n- let body: serde_json::Value = response.json().await.unwrap();\n- assert!(body[\"components\"].is_array());\n- let components = body[\"components\"].as_array().unwrap();\n+ let ingest = serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"langs\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\n+ \"text\": \"~/rust {systems}\\n~/go {concurrency}\\n~/rust 3:1 ~/go {because i prefer rust for systems work}\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]);\n+ rpc_batch(&client, addr, Some(&test_bearer()), ingest).await;\n+\n+ let rank_batch = serde_json::json!([{\n+ \"GetGardenRank\": {\n+ \"room\": \"public\",\n+ \"parent_path\": \"~\",\n+ \"depth\": 1\n+ }\n+ }]);\n+ let body = rpc_batch(&client, addr, None, rank_batch).await;\n+ let line = &body[\"results\"][0];\n+ assert_eq!(line[\"ok\"], true);\n+ let rank = &line[\"result\"][\"GardenRank\"];\n+ assert!(rank[\"components\"].is_array());\n+ let components = rank[\"components\"].as_array().unwrap();\n assert_eq!(components.len(), 1);\n let ranking = components[0][\"ranking\"].as_array().unwrap();\n assert_eq!(ranking.len(), 2);\n assert_eq!(ranking[0][\"item\"], \"https://slug.social/~/rust\");\n- assert!(body[\"unranked_items\"].is_array());\n+ assert!(rank[\"unranked_items\"].is_array());\n }\n \n #[tokio::test]\n@@ -199,32 +207,30 @@ async fn test_check_endpoint_does_not_commit() {\n let (addr, tmp, _log, _handle) = create_test_server().await;\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- \"thread\": \"t\",\n- \"text\": \"~/a {x}\\n~/b {y}\\n~/a 2:1 ~/b {because}\\n\",\n- });\n- let resp = client\n- .post(&format!(\"http://{}/api/v0/check\", addr))\n- .json(&check_payload)\n- .send()\n- .await\n- .unwrap();\n- assert!(resp.status().is_success());\n+ let check_batch = serde_json::json!([{\n+ \"Check\": {\n+ \"room\": \"public\",\n+ \"text\": \"~/a {x}\\n~/b {y}\\n~/a 2:1 ~/b {because}\\n\",\n+ }\n+ }]);\n+ let resp_body = rpc_batch(&client, addr, None, check_batch).await;\n+ let line = &resp_body[\"results\"][0];\n+ assert_eq!(line[\"ok\"], true);\n \n // It should not write events.jsonl.\n let log_path = tmp.path().join(\"events.jsonl\");\n let content = std::fs::read_to_string(&log_path).unwrap_or_default();\n assert!(content.trim().is_empty(), \"check must not append events\");\n \n- // And the live state should still have no threads (check is dry-run).\n- let threads_resp = client\n- .get(&format!(\"http://{}/api/v0/threads\", addr))\n- .send()\n- .await\n- .unwrap();\n- let body: serde_json::Value = threads_resp.json().await.unwrap();\n- assert!(body[\"threads\"].as_array().unwrap().is_empty());\n+ // And the live state should still have no forum threads (check is dry-run).\n+ let list_batch = serde_json::json!([{\n+ \"ListForumThreads\": { \"room\": \"public\" }\n+ }]);\n+ let threads_body = rpc_batch(&client, addr, None, list_batch).await;\n+ let tline = &threads_body[\"results\"][0];\n+ assert_eq!(tline[\"ok\"], true);\n+ let threads = tline[\"result\"][\"ForumThreads\"][\"threads\"].as_array().unwrap();\n+ assert!(threads.is_empty());\n }\n \n #[tokio::test]\n@@ -232,36 +238,38 @@ async fn test_ontology_item_page_shows_body_children_and_collapsible_votes() {\n // HTML routes are offline during the auth-v3 refactor.\n }\n \n-/// Thread connective tissue: item_threads and VoteData.thread are exposed by item, pair, and matchup APIs.\n+/// Thread connective tissue: item_threads and VoteData.thread_tag are exposed by item, pair, and matchup RPC.\n #[tokio::test]\n async fn test_garden_item_pair_matchup_include_threads() {\n let (addr, _tmp, _log, _handle) = create_test_server().await;\n let client = reqwest::Client::new();\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- \"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- let ingest_resp = client\n- .post(format!(\"http://{}/api/v0/ingest\", addr))\n- .header(\"Authorization\", format!(\"Bearer {}\", test_bearer()))\n- .json(&ingest_payload)\n- .send()\n- .await\n- .unwrap();\n- assert!(ingest_resp.status().is_success(), \"ingest should succeed\");\n-\n- // GET item: body + threads\n- let item_resp = client\n- .get(&format!(\"http://{}/api/v0/item\", addr))\n- .query(&[(\"item\", \"~/sorts/insertion\")])\n- .send()\n- .await\n- .unwrap();\n- assert!(item_resp.status().is_success());\n- let item: serde_json::Value = item_resp.json().await.unwrap();\n+ let ingest_batch = serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"sorting-hat\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000000:test:local/test\",\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+ \"return_rank_diff\": false\n+ }\n+ }]);\n+ let ing = rpc_batch(&client, addr, Some(&test_bearer()), ingest_batch).await;\n+ assert_eq!(ing[\"results\"][0][\"ok\"], true, \"ingest should succeed\");\n+\n+ let item_body = rpc_batch(\n+ &client,\n+ addr,\n+ None,\n+ serde_json::json!([{\n+ \"GetGardenItem\": {\n+ \"room\": \"public\",\n+ \"item_path\": \"~/sorts/insertion\",\n+ \"full\": true\n+ }\n+ }]),\n+ )\n+ .await;\n+ let item = &item_body[\"results\"][0][\"result\"][\"GardenItem\"];\n assert_eq!(item[\"item\"], \"https://slug.social/~/sorts/insertion\");\n assert!(item[\"body\"].as_str().unwrap().contains(\"O(n^2)\"));\n let threads: Vec<&str> = item[\"threads\"]\n@@ -272,15 +280,19 @@ async fn test_garden_item_pair_matchup_include_threads() {\n .collect();\n assert!(threads.contains(&\"sorting-hat\"), \"item threads should contain sorting-hat: {:?}\", threads);\n \n- // GET pair (under sorts): left, right, threads\n- let pair_resp = client\n- .get(&format!(\"http://{}/api/v0/pair\", addr))\n- .query(&[(\"parent\", \"~/sorts\")])\n- .send()\n- .await\n- .unwrap();\n- assert!(pair_resp.status().is_success());\n- let pair: serde_json::Value = pair_resp.json().await.unwrap();\n+ let pair_body = rpc_batch(\n+ &client,\n+ addr,\n+ None,\n+ serde_json::json!([{\n+ \"GetPair\": {\n+ \"room\": \"public\",\n+ \"parent_path\": \"~/sorts\"\n+ }\n+ }]),\n+ )\n+ .await;\n+ let pair = &pair_body[\"results\"][0][\"result\"][\"Pair\"];\n let pair_threads: Vec<&str> = pair[\"threads\"]\n .as_array()\n .unwrap()\n@@ -289,15 +301,19 @@ async fn test_garden_item_pair_matchup_include_threads() {\n .collect();\n assert!(pair_threads.contains(&\"sorting-hat\"), \"pair threads should contain sorting-hat: {:?}\", pair_threads);\n \n- // GET matchup: vote history with thread per vote\n- let matchup_resp = client\n- .get(&format!(\"http://{}/api/v0/matchup\", addr))\n- .query(&[(\"item\", \"~/sorts/insertion\")])\n- .send()\n- .await\n- .unwrap();\n- assert!(matchup_resp.status().is_success());\n- let matchup: serde_json::Value = matchup_resp.json().await.unwrap();\n+ let matchup_body = rpc_batch(\n+ &client,\n+ addr,\n+ None,\n+ serde_json::json!([{\n+ \"GetMatchup\": {\n+ \"room\": \"public\",\n+ \"item_path\": \"~/sorts/insertion\"\n+ }\n+ }]),\n+ )\n+ .await;\n+ let matchup = &matchup_body[\"results\"][0][\"result\"][\"Matchup\"];\n assert_eq!(matchup[\"item\"], \"https://slug.social/~/sorts/insertion\");\n let votes = matchup[\"votes\"].as_array().unwrap();\n assert!(!votes.is_empty(), \"matchup should have at least one vote\");\n@@ -326,42 +342,50 @@ async fn test_rank_history() {\n let client = reqwest::Client::new();\n \n let bearer = test_bearer();\n- let ingest = |payload: serde_json::Value| {\n+ let ingest = |delegate: &str, text: &str| {\n let client = client.clone();\n let addr = addr;\n let bearer = bearer.clone();\n+ let text = text.to_string();\n+ let delegate = delegate.to_string();\n async move {\n- client\n- .post(format!(\"http://{}/api/v0/ingest\", addr))\n- .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .json(&payload)\n- .send()\n- .await\n- .unwrap()\n+ rpc_batch(\n+ &client,\n+ addr,\n+ Some(&bearer),\n+ serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"hist-test\",\n+ \"delegate\": delegate,\n+ \"text\": text,\n+ \"return_rank_diff\": false\n+ }\n+ }]),\n+ )\n+ .await\n }\n };\n \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- \"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+ \"00000000-0000-0000-0000-000000000001:rig:test/model\",\n+ \"~/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 .await;\n \n- // History for rust should have one entry with two caused_by votes.\n- let resp: serde_json::Value = client\n- .get(&format!(\"http://{}/api/v0/rank-history\", addr))\n- .query(&[(\"item\", \"~/hist/rust\")])\n- .send()\n- .await\n- .unwrap()\n- .json()\n- .await\n- .unwrap();\n-\n+ let hist_rust = rpc_batch(\n+ &client,\n+ addr,\n+ None,\n+ serde_json::json!([{\n+ \"GetRankHistory\": {\n+ \"room\": \"public\",\n+ \"item_path\": \"~/hist/rust\"\n+ }\n+ }]),\n+ )\n+ .await;\n+ let resp = &hist_rust[\"results\"][0][\"result\"][\"RankHistory\"];\n assert_eq!(resp[\"item\"], \"https://slug.social/~/hist/rust\");\n let history = resp[\"history\"].as_array().unwrap();\n assert_eq!(history.len(), 1, \"one ingest → one history entry\");\n@@ -371,45 +395,46 @@ async fn test_rank_history() {\n let caused_by = entry[\"caused_by\"].as_array().unwrap();\n assert_eq!(caused_by.len(), 2, \"both votes in the ingest touched rust\");\n \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- \"thread\": \"hist-test\",\n- \"text\": \"~/hist/python 3:1 ~/hist/go { dynamic typing is worth it }\\n\",\n- }),\n+ \"00000000-0000-0000-0000-000000000002:rig:test/model\",\n+ \"~/hist/python 3:1 ~/hist/go { dynamic typing is worth it }\\n\",\n )\n .await;\n \n- // Python history: two entries (appeared in first ingest, then voted again here).\n- let resp2: serde_json::Value = client\n- .get(&format!(\"http://{}/api/v0/rank-history\", addr))\n- .query(&[(\"item\", \"~/hist/python\")])\n- .send()\n- .await\n- .unwrap()\n- .json()\n- .await\n- .unwrap();\n+ let hist_py = rpc_batch(\n+ &client,\n+ addr,\n+ None,\n+ serde_json::json!([{\n+ \"GetRankHistory\": {\n+ \"room\": \"public\",\n+ \"item_path\": \"~/hist/python\"\n+ }\n+ }]),\n+ )\n+ .await;\n+ let resp2 = &hist_py[\"results\"][0][\"result\"][\"RankHistory\"];\n let hist2 = resp2[\"history\"].as_array().unwrap();\n assert_eq!(hist2.len(), 2, \"python touched in both ingests\");\n \n- // Second entry for python: caused_by has one vote (python 3:1 go).\n let caused_by2 = hist2[1][\"caused_by\"].as_array().unwrap();\n assert_eq!(caused_by2.len(), 1);\n assert!(caused_by2[0][\"a\"].as_str().unwrap().ends_with(\"python\") ||\n caused_by2[0][\"b\"].as_str().unwrap().ends_with(\"python\"));\n \n- // Rust was NOT directly voted on in the second ingest — no new history entry.\n- let resp3: serde_json::Value = client\n- .get(&format!(\"http://{}/api/v0/rank-history\", addr))\n- .query(&[(\"item\", \"~/hist/rust\")])\n- .send()\n- .await\n- .unwrap()\n- .json()\n- .await\n- .unwrap();\n+ let hist_rust2 = rpc_batch(\n+ &client,\n+ addr,\n+ None,\n+ serde_json::json!([{\n+ \"GetRankHistory\": {\n+ \"room\": \"public\",\n+ \"item_path\": \"~/hist/rust\"\n+ }\n+ }]),\n+ )\n+ .await;\n+ let resp3 = &hist_rust2[\"results\"][0][\"result\"][\"RankHistory\"];\n assert_eq!(\n resp3[\"history\"].as_array().unwrap().len(),\n 1,\n@@ -422,66 +447,71 @@ async fn pair_returns_connectivity_stats() {\n let (addr, _tmp, _log, _handle) = create_test_server().await;\n let client = reqwest::Client::new();\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- \"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- let resp = client\n- .post(format!(\"http://{}/api/v0/ingest\", addr))\n- .header(\"Authorization\", format!(\"Bearer {}\", test_bearer()))\n- .json(&doc)\n- .send()\n- .await\n- .unwrap();\n- assert!(resp.status().is_success(), \"ingest should succeed\");\n-\n- // Request pair under ~/conn — should include connectivity stats.\n- let pair_resp = client\n- .get(&format!(\"http://{}/api/v0/pair\", addr))\n- .query(&[(\"parent\", \"~/conn\")])\n- .send()\n- .await\n- .unwrap();\n- assert!(pair_resp.status().is_success());\n- let pair: serde_json::Value = pair_resp.json().await.unwrap();\n+ let doc = serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"connectivity-test\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000001:testrig:test/model\",\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+ \"return_rank_diff\": false\n+ }\n+ }]);\n+ let resp = rpc_batch(&client, addr, Some(&test_bearer()), doc).await;\n+ assert_eq!(resp[\"results\"][0][\"ok\"], true, \"ingest should succeed\");\n+\n+ let pair_body = rpc_batch(\n+ &client,\n+ addr,\n+ None,\n+ serde_json::json!([{\n+ \"GetPair\": {\n+ \"room\": \"public\",\n+ \"parent_path\": \"~/conn\"\n+ }\n+ }]),\n+ )\n+ .await;\n+ let pair = &pair_body[\"results\"][0][\"result\"][\"Pair\"];\n \n let conn = &pair[\"connectivity\"];\n assert!(!conn.is_null(), \"pair response should include connectivity stats\");\n assert_eq!(conn[\"items\"].as_u64().unwrap(), 4, \"4 items in scope\");\n- // 1 component (a,b) + 2 isolates (c,d) = 3 components\n assert_eq!(conn[\"components\"].as_u64().unwrap(), 3, \"3 components (1 connected + 2 isolates)\");\n assert_eq!(conn[\"comparisons_until_connected\"].as_u64().unwrap(), 2, \"need 2 more comparisons\");\n assert_eq!(conn[\"pairs_voted\"].as_u64().unwrap(), 1, \"1 pair voted\");\n assert_eq!(conn[\"pairs_possible\"].as_u64().unwrap(), 6, \"4*3/2 = 6 possible pairs\");\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- \"thread\": \"connectivity-test\",\n- \"text\": \"~/conn/c 2:1 ~/conn/a { c beats a }\\n\",\n- });\n- let resp2 = client\n- .post(format!(\"http://{}/api/v0/ingest\", addr))\n- .header(\"Authorization\", format!(\"Bearer {}\", test_bearer()))\n- .json(&doc2)\n- .send()\n- .await\n- .unwrap();\n- let resp2_status = resp2.status();\n- let resp2_body: serde_json::Value = resp2.json().await.unwrap();\n- assert!(resp2_status.is_success(), \"second ingest failed: {}\", resp2_body);\n-\n- let pair_resp2 = client\n- .get(&format!(\"http://{}/api/v0/pair\", addr))\n- .query(&[(\"parent\", \"~/conn\")])\n- .send()\n- .await\n- .unwrap();\n- let pair2: serde_json::Value = pair_resp2.json().await.unwrap();\n+ let doc2 = serde_json::json!([{\n+ \"Post\": {\n+ \"room\": \"public\",\n+ \"thread_tag\": \"connectivity-test\",\n+ \"delegate\": \"00000000-0000-0000-0000-000000000001:testrig:test/model\",\n+ \"text\": \"~/conn/c 2:1 ~/conn/a { c beats a }\\n\",\n+ \"return_rank_diff\": false\n+ }\n+ }]);\n+ let resp2 = rpc_batch(&client, addr, Some(&test_bearer()), doc2).await;\n+ assert_eq!(\n+ resp2[\"results\"][0][\"ok\"],\n+ true,\n+ \"second ingest failed: {:?}\",\n+ resp2\n+ );\n+\n+ let pair_body2 = rpc_batch(\n+ &client,\n+ addr,\n+ None,\n+ serde_json::json!([{\n+ \"GetPair\": {\n+ \"room\": \"public\",\n+ \"parent_path\": \"~/conn\"\n+ }\n+ }]),\n+ )\n+ .await;\n+ let pair2 = &pair_body2[\"results\"][0][\"result\"][\"Pair\"];\n let conn2 = &pair2[\"connectivity\"];\n- // Now: component (a,b,c) + isolate (d) = 2 components\n assert_eq!(conn2[\"components\"].as_u64().unwrap(), 2, \"2 components after connecting c\");\n assert_eq!(conn2[\"comparisons_until_connected\"].as_u64().unwrap(), 1, \"1 more comparison to connect\");\n assert_eq!(conn2[\"pairs_voted\"].as_u64().unwrap(), 2, \"2 pairs voted now\");\ndiff --git a/test/grants.bb b/test/grants.bb\nindex f72799ae6fe099f48bdf316deee07f15882e9d45..7066c1f2a57f39a362052f8524206dccf37bb7b1 100644\n--- a/test/grants.bb\n+++ b/test/grants.bb\n@@ -1,12 +1,12 @@\n (ns test.grants\n- \"Grant enforcement integration test: private thread access control on POST /api/v0/ingest.\n+ \"Grant enforcement integration test: private room capabilities via POST /api/v0/rpc.\n \n Covers:\n- - user without any grant -> 403 on private thread\n- - user with View but no Post -> 403 for prose\n- - user with View + Post -> 200 for prose\n- - user with View + Post but no Vote -> 403 for vote (requires items in scope)\n- - user with View + Post + Vote -> 200 for vote\"\n+ - user without any grant -> RPC line ok=false on private room post\n+ - user with View but no Post -> ok=false for prose\n+ - user with View + Post -> ok=true for prose\n+ - user with View + Post but no Vote -> ok=false for vote\n+ - user with View + Post + Vote -> ok=true for vote\"\n (:require [babashka.process :as p]\n [babashka.fs :as fs]\n [cheshire.core :as json]\n@@ -141,10 +141,21 @@\n \n (defn- bearer [token] {\"Authorization\" (str \"Bearer \" token)})\n \n-(defn- ingest! [base-url token thread delegate text]\n- (http-post-json (str base-url \"/api/v0/ingest\")\n- {:thread thread :delegate delegate :text text}\n- :headers (bearer token)))\n+(defn- rpc-batch! [base-url token cmds]\n+ (let [resp (http-post-json (str base-url \"/api/v0/rpc\") cmds :headers (bearer token))]\n+ {:status (:status resp)\n+ :parsed (json/parse-string (:body resp) false)}))\n+\n+(defn- rpc-line-ok? [parsed]\n+ (true? (get-in parsed [\"results\" 0 \"ok\"])))\n+\n+(defn- ingest! [base-url token room thread delegate text]\n+ (rpc-batch! base-url token\n+ [{\"Post\" {\"room\" room\n+ \"thread_tag\" thread\n+ \"delegate\" delegate\n+ \"text\" text\n+ \"return_rank_diff\" false}}]))\n \n (defn grants-test [& _args]\n (println \"\\n━━━ grants enforcement integration check ━━━\\n\")\n@@ -194,80 +205,77 @@\n \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n \"bob\")\n \n- ;; Alice creates a private thread.\n- _ (println \"\\nalice creates private thread…\")\n- create-resp (http-post-json (str base-url \"/api/v0/thread\")\n- {:slug \"secret-project\" :visibility \"private\"}\n- :headers (bearer alice-token))\n- _ (assert! (= 200 (:status create-resp)) \"thread create returns 200\")\n- thread-id (-> create-resp :body (json/parse-string true) :thread_id)\n- _ (assert! (some? thread-id) \"thread_id present in response\")]\n-\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- \"Hello from alice.\")))\n+ ;; Alice creates a private room.\n+ _ (println \"\\nalice creates private room…\")\n+ create (rpc-batch! base-url alice-token\n+ [{\"RoomCreate\" {\"slug\" \"secret-project\" \"visibility\" \"private\"}}])\n+ _ (assert! (= 200 (:status create)) \"room create HTTP 200\")\n+ _ (assert! (rpc-line-ok? (:parsed create)) \"room create RPC ok\")\n+ room-id (get-in (:parsed create) [\"results\" 0 \"result\" \"RoomCreated\" \"room_id\"])\n+ _ (assert! (some? room-id) \"room_id present in RPC result\")]\n+\n+ ;; Alice (owner) can post prose to her own private room.\n+ (println \"\\nalice posts prose to her private room…\")\n+ (assert! (rpc-line-ok? (:parsed (ingest! base-url alice-token room-id \"main\"\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+ ;; Bob has no grants — RPC line fails.\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- \"Hello from bob, unauthorized.\")))\n- \"bob without grants gets 403\")\n+ (assert! (not (rpc-line-ok? (:parsed (ingest! base-url bob-token room-id \"main\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"Hello from bob, unauthorized.\"))))\n+ \"bob without grants gets RPC failure\")\n \n- ;; Alice grants bob View only — still not enough to post prose.\n+ ;; Alice grants bob View only.\n (println \"\\nalice grants bob View only…\")\n- (assert! (= 200 (:status (http-post-json (str base-url \"/api/v0/thread/\" thread-id \"/grants\")\n- {:username \"bob\" :capabilities [\"view\"]}\n- :headers (bearer alice-token))))\n- \"grant View returns 200\")\n+ (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token\n+ [{\"RoomGrant\" {\"room\" room-id \"username\" \"bob\" \"capability\" \"view\"}}])))\n+ \"grant View RPC ok\")\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- \"Hello from bob, view only.\")))\n- \"bob with View but no Post gets 403\")\n+ (assert! (not (rpc-line-ok? (:parsed (ingest! base-url bob-token room-id \"main\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"Hello from bob, view only.\"))))\n+ \"bob with View but no Post gets RPC failure\")\n \n- ;; Alice grants bob Post — now prose should work.\n+ ;; Alice grants bob Post.\n (println \"\\nalice grants bob Post…\")\n- (assert! (= 200 (:status (http-post-json (str base-url \"/api/v0/thread/\" thread-id \"/grants\")\n- {:username \"bob\" :capabilities [\"post\"]}\n- :headers (bearer alice-token))))\n- \"grant Post returns 200\")\n+ (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token\n+ [{\"RoomGrant\" {\"room\" room-id \"username\" \"bob\" \"capability\" \"post\"}}])))\n+ \"grant Post RPC ok\")\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- \"Hello from bob, now authorised.\")))\n+ (assert! (rpc-line-ok? (:parsed (ingest! base-url bob-token room-id \"main\"\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- \"~/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+ ;; Alice defines two items and votes in the private room.\n+ (println \"\\nalice posts items + vote to private room…\")\n+ (assert! (rpc-line-ok? (:parsed (ingest! base-url alice-token room-id \"main\"\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 room succeeds\")\n \n- ;; Bob (View + Post, no Vote) tries to vote — should be 403.\n+ ;; Bob (View + Post, no Vote) tries to vote.\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- \"~/fruits/apple > ~/fruits/banana { bob's take }\")))\n- \"bob without Vote gets 403\")\n+ (assert! (not (rpc-line-ok? (:parsed (ingest! base-url bob-token room-id \"main\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"~/fruits/apple > ~/fruits/banana { bob's take }\"))))\n+ \"bob without Vote gets RPC failure\")\n \n- ;; Alice grants bob Vote — now voting should work.\n+ ;; Alice grants bob Vote.\n (println \"\\nalice grants bob Vote…\")\n- (assert! (= 200 (:status (http-post-json (str base-url \"/api/v0/thread/\" thread-id \"/grants\")\n- {:username \"bob\" :capabilities [\"vote\"]}\n- :headers (bearer alice-token))))\n- \"grant Vote returns 200\")\n+ (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token\n+ [{\"RoomGrant\" {\"room\" room-id \"username\" \"bob\" \"capability\" \"vote\"}}])))\n+ \"grant Vote RPC ok\")\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- \"~/fruits/apple > ~/fruits/banana { bob's take }\")))\n+ (assert! (rpc-line-ok? (:parsed (ingest! base-url bob-token room-id \"main\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"~/fruits/apple > ~/fruits/banana { bob's take }\")))\n \"bob with Vote succeeds\"))\n \n (finally\ndiff --git a/test/integration.bb b/test/integration.bb\nindex 4ff5d625840be3bf5e4162cef107a3cbd45aec4d..c71dcf20021056421ab27b1313e4dec94570ddaa 100644\n--- a/test/integration.bb\n+++ b/test/integration.bb\n@@ -146,7 +146,7 @@\n \n ;; 2.5 check endpoint — disconnected components not flattened (before OAuth; no events.jsonl yet)\n (println \"\\nchecking (dry-run) returns discrete ranking groups…\")\n- (bind check-result (common/run-cli cli-bin base-url [\"check\" \"--json\" \"--thread\" \"integration-test\"] :input check-doc-disconnected))\n+ (bind check-result (common/run-cli cli-bin base-url [\"public\" \"check\" \"--json\" \"--thread\" \"integration-test\"] :input check-doc-disconnected))\n (assert! (zero? (:exit check-result)) \"cli check exits 0\")\n (bind check-resp (json/parse-string (:out check-result) true))\n (assert! (:ok check-resp) \"check response ok=true\")\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\" \"--delegate\" \"00000000-0000-0000-0000-000000000000:cli:local/dev\"] :input sorter-doc :extra-env token-env))\n+ (bind ingest1-result (common/run-cli cli-bin base-url [\"public\" \"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@@ -176,7 +176,7 @@\n \n ;; 4. query rankings via CLI\n (println \"\\nquerying garden children via CLI…\")\n- (bind children-result (common/run-cli cli-bin base-url [\"garden\" \"children\" \"languages\" \"--json\"]))\n+ (bind children-result (common/run-cli cli-bin base-url [\"public\" \"garden\" \"children\" \"languages\" \"--json\"]))\n (assert! (zero? (:exit children-result)) \"cli garden children exits 0\")\n (bind children-resp (json/parse-string (:out children-result) true))\n (bind ranked (mapv :item (mapcat :ranking (:components children-resp))))\n@@ -195,14 +195,14 @@\n \n ;; 6. query forum via CLI\n (println \"\\nquerying forum via CLI…\")\n- (bind forum-result (common/run-cli cli-bin base-url [\"forum\" \"--json\"]))\n+ (bind forum-result (common/run-cli cli-bin base-url [\"public\" \"forum\" \"--json\"]))\n (assert! (zero? (:exit forum-result)) \"cli forum exits 0\")\n (bind forum-resp (json/parse-string (:out forum-result) true))\n (assert! (some (fn [t] (= \"#integration-test\" (:thread t))) (:threads forum-resp))\n \"thread '#integration-test' visible in forum\")\n \n ;; 7. query item body via CLI\n- (bind body-result (common/run-cli cli-bin base-url [\"garden\" \"body\" \"languages/rust\" \"--json\"]))\n+ (bind body-result (common/run-cli cli-bin base-url [\"public\" \"garden\" \"body\" \"languages/rust\" \"--json\"]))\n (assert! (zero? (:exit body-result)) \"cli garden body exits 0\")\n (bind body-resp (json/parse-string (:out body-result) true))\n (assert! (str/includes? (or (:body body-resp) \"\") \"ownership\")\n@@ -210,7 +210,7 @@\n \n ;; 9. global rank endpoint\n (println \"\\ntesting global rank endpoint…\")\n- (bind grank-result (common/run-cli cli-bin base-url [\"garden\" \"rank\" \"--json\"]))\n+ (bind grank-result (common/run-cli cli-bin base-url [\"public\" \"garden\" \"rank\" \"--json\"]))\n (assert! (zero? (:exit grank-result)) \"cli garden rank exits 0\")\n (bind grank-resp (json/parse-string (:out grank-result) true))\n (assert! (pos? (:ranked_total grank-resp)) \"global rank has ranked items\")\n@@ -218,13 +218,13 @@\n (assert! (str/ends-with? (:item (first (:items grank-resp))) \"languages/rust\")\n (str \"rust is #1 globally (got \" (:item (first (:items grank-resp))) \")\"))\n \n- (bind grank-pct-result (common/run-cli cli-bin base-url [\"garden\" \"rank\" \"--percent\" \"--limit\" \"2\" \"--json\"]))\n+ (bind grank-pct-result (common/run-cli cli-bin base-url [\"public\" \"garden\" \"rank\" \"--percent\" \"--limit\" \"2\" \"--json\"]))\n (assert! (zero? (:exit grank-pct-result)) \"cli garden rank --percent --limit 2 exits 0\")\n (bind grank-pct-resp (json/parse-string (:out grank-pct-result) true))\n (assert! (= 2 (count (:items grank-pct-resp))) \"limit=2 returns 2 items\")\n (assert! (= 100.0 (:percent (first (:items grank-pct-resp)))) \"top item has 100.0% score\")\n \n- (bind grank-off-result (common/run-cli cli-bin base-url [\"garden\" \"rank\" \"--limit\" \"1\" \"--offset\" \"1\" \"--json\"]))\n+ (bind grank-off-result (common/run-cli cli-bin base-url [\"public\" \"garden\" \"rank\" \"--limit\" \"1\" \"--offset\" \"1\" \"--json\"]))\n (assert! (zero? (:exit grank-off-result)) \"cli garden rank --offset 1 exits 0\")\n (bind grank-off-resp (json/parse-string (:out grank-off-result) true))\n (assert! (= (:item (second (:items grank-pct-resp))) (:item (first (:items grank-off-resp))))\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\" \"--delegate\" \"00000000-0000-0000-0000-000000000000:cli:local/dev\"] :input two-vote-doc :extra-env token-env))\n+ (bind hist-ingest (common/run-cli cli-bin base-url [\"public\" \"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 \n@@ -245,7 +245,7 @@\n (assert! (= 5 (count event-lines-after-hist))\n (str \"5 events in JSONL after second ingest (got \" (count event-lines-after-hist) \")\"))\n \n- (bind hist-result (common/run-cli cli-bin base-url [\"garden\" \"history\" \"languages/rust\" \"--json\"]))\n+ (bind hist-result (common/run-cli cli-bin base-url [\"public\" \"garden\" \"history\" \"languages/rust\" \"--json\"]))\n (assert! (zero? (:exit hist-result))\n (str \"cli garden history exits 0 (err: \" (:err hist-result) \")\"))\n (bind hist-resp (json/parse-string (:out hist-result) true))\n@@ -270,7 +270,7 @@\n \n ;; 12. same query, same result\n (println \"\\nquerying rankings after replay…\")\n- (bind replay-result (common/run-cli cli-bin base-url [\"garden\" \"children\" \"languages\" \"--json\"]))\n+ (bind replay-result (common/run-cli cli-bin base-url [\"public\" \"garden\" \"children\" \"languages\" \"--json\"]))\n (assert! (zero? (:exit replay-result)) \"cli garden children exits 0 after restart\")\n (bind replay-resp (json/parse-string (:out replay-result) true))\n (bind replay-ranked (mapv :item (mapcat :ranking (:components replay-resp))))\ndiff --git a/types/src/lib.rs b/types/src/lib.rs\nindex 92e6d122d573ea225f8fb86c548a81b3454425ad..a715fe5639f4a3a7bfedcc706b5fb312b98c9ac5 100644\n--- a/types/src/lib.rs\n+++ b/types/src/lib.rs\n@@ -100,12 +100,12 @@ pub struct PathSummary {\n pub web: String,\n }\n \n-#[derive(Debug, Serialize, Deserialize)]\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n pub struct ThreadsResponse {\n pub threads: Vec,\n }\n \n-#[derive(Debug, Serialize, Deserialize)]\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n pub struct ThreadSummary {\n pub thread: String,\n pub last_activity_ts: i64,\n@@ -220,15 +220,173 @@ pub struct FeedPost {\n pub body: String,\n }\n \n+// ---------------------------------------------------------------------------\n+// RPC batch API (`POST /api/v0/rpc`)\n+// ---------------------------------------------------------------------------\n+\n+#[derive(Debug, Serialize, Deserialize)]\n+#[serde(transparent)]\n+pub struct RpcBatch(pub Vec);\n+\n+#[derive(Debug, Serialize, Deserialize)]\n+#[serde(rename_all = \"PascalCase\")]\n+pub enum RpcCommand {\n+ Post {\n+ room: String,\n+ thread_tag: String,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ delegate: Option,\n+ text: String,\n+ #[serde(default)]\n+ return_rank_diff: bool,\n+ },\n+ Check {\n+ room: String,\n+ text: String,\n+ },\n+ GetGardenRank {\n+ room: String,\n+ parent_path: String,\n+ #[serde(default)]\n+ depth: Option,\n+ #[serde(default)]\n+ offset: Option,\n+ #[serde(default)]\n+ limit: Option,\n+ #[serde(default)]\n+ percent: Option,\n+ },\n+ GetGardenItem {\n+ room: String,\n+ item_path: String,\n+ #[serde(default)]\n+ full: Option,\n+ },\n+ GetForumThread {\n+ room: String,\n+ thread_tag: String,\n+ #[serde(default)]\n+ offset: Option,\n+ #[serde(default)]\n+ limit: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ since: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ before: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ actor: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ post_id: Option,\n+ },\n+ ListForumThreads {\n+ room: String,\n+ },\n+ RoomCreate {\n+ slug: String,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ visibility: Option,\n+ },\n+ RoomGrant {\n+ room: String,\n+ username: String,\n+ capability: String,\n+ },\n+ GetGlobalRank {\n+ room: String,\n+ #[serde(default)]\n+ limit: Option,\n+ #[serde(default)]\n+ offset: Option,\n+ #[serde(default)]\n+ percent: Option,\n+ },\n+ GetPair {\n+ room: String,\n+ parent_path: String,\n+ },\n+ GetMatchup {\n+ room: String,\n+ item_path: String,\n+ #[serde(default)]\n+ limit: Option,\n+ },\n+ GetRankHistory {\n+ room: String,\n+ item_path: String,\n+ },\n+ GetLeaves {\n+ room: String,\n+ },\n+ GetPaths {\n+ room: String,\n+ },\n+ GetRecentVotes {\n+ room: String,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ parent: Option,\n+ #[serde(default)]\n+ limit: Option,\n+ },\n+ Search {\n+ query: String,\n+ },\n+ GetFeed {\n+ actor: String,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ since: Option,\n+ #[serde(default)]\n+ limit: Option,\n+ },\n+}\n+\n+#[derive(Debug, Serialize, Deserialize)]\n+#[serde(rename_all = \"PascalCase\")]\n+pub enum RpcResult {\n+ PostOk {\n+ events_appended: usize,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ ranking_changes: Option>,\n+ threads: Vec,\n+ next: NextMoves,\n+ },\n+ CheckOk {\n+ rankings: Vec,\n+ threads: Vec,\n+ next: Vec,\n+ },\n+ GardenRank(RankResponse),\n+ GardenItem(ItemResponse),\n+ ForumThread(ThreadDetailResponse),\n+ ForumThreads(ThreadsResponse),\n+ RoomCreated {\n+ room_id: String,\n+ },\n+ GrantOk {},\n+ GlobalRank(GlobalRankResponse),\n+ Pair(PairResponse),\n+ Matchup(MatchupResponse),\n+ RankHistory(RankHistoryResponse),\n+ Leaves(LeavesResponse),\n+ Paths(PathsResponse),\n+ RecentVotes(RecentVotesResponse),\n+ Search(SearchResponse),\n+ Feed(FeedResponse),\n+}\n+\n+#[derive(Debug, Serialize, Deserialize)]\n+pub struct RpcLine {\n+ pub ok: bool,\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub result: Option,\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub error: Option,\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub hint: Option,\n+}\n+\n #[derive(Debug, Serialize, Deserialize)]\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- /// 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+pub struct RpcBatchResponse {\n+ pub results: Vec,\n }\n \n /// Start a browser-based OAuth login flow for a CLI agent.\n","role":"user"}],"model":"~x-ai/grok-latest"}