diff --git a/AGENTS.md b/AGENTS.md index dccd3fd..8a5dbce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,10 +13,10 @@ Studio's job, not Huginn's. - Upstream: `https://github.com/GameCult/Huginn.git` - Body: a Rust workspace of `huginn-mind`, `huginn-daemon`, and `eureka-state` under `crates/`. Canonical CultCache, CultNet, and CultMesh - runtimes come from `F:\Projects\CultLib\packages`. The crates are stubs - with no dependencies; each named cut of the Eureka pipeline-state campaign - fills one in. Until then nothing here persists, admits, publishes, or - connects. + runtimes come from `F:\Projects\CultLib\packages`. `huginn-mind` is live: + it persists and admits an instance's mind, over `cultcache-rs` and + `epiphany-pipeline` pinned by git rev. `huginn-daemon` and `eureka-state` + are stubs with no dependencies; nothing here publishes or connects yet. - Owned: an instance's memory documents, their admission, and their CultNet publication. - Depends on: Qdrant, directly. Unreachable Qdrant is a loud refusal, never a diff --git a/Cargo.lock b/Cargo.lock index 2cb6132..233ae59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,10 +2,241 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cultcache-rs" +version = "0.2.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=a0813c6eed24d30bf88073ef615b633c77ebfcd6#a0813c6eed24d30bf88073ef615b633c77ebfcd6" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=a0813c6eed24d30bf88073ef615b633c77ebfcd6#a0813c6eed24d30bf88073ef615b633c77ebfcd6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "epiphany-pipeline" +version = "0.1.0" +source = "git+https://github.com/GameCult/Epiphany.git?rev=a65c6420f681d6641b842f94b319f0fa14166c96#a65c6420f681d6641b842f94b319f0fa14166c96" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs", + "rmp-serde", + "schemars", + "serde", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "eureka-state" version = "0.1.0" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "huginn-daemon" version = "0.1.0" @@ -13,3 +244,581 @@ version = "0.1.0" [[package]] name = "huginn-mind" version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs", + "epiphany-pipeline", + "rmp-serde", + "schemars", + "serde", + "sha2", + "tempfile", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redb" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb338a6c67830a61bed824b78c2bb034ab1ff401a20616bd7957524f4fdc2f22" +dependencies = [ + "libc", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.5", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/README.md b/README.md index 0d1466a..17ea65f 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,14 @@ A Cargo workspace of three crates: - `crates/huginn-daemon`: the CultNet surface and serve loop. - `crates/eureka-state`: typed state for the Eureka pipeline. -The crates are stubs. Each cut of the Eureka pipeline-state campaign fills one -in; the campaign's cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` -owns what each crate must do next. +`huginn-mind` is live: it opens one instance's store (an owned redb CultCache +at `/minds//mind.redb`, locked for the mind's lifetime), +and refuses a store whose `instance` document names another instance, whose +epoch record is foreign, or whose types are not a mind's. Document shape and +keys come from `epiphany-pipeline`; the store is CultLib's Rust CultCache. +`huginn-daemon` and `eureka-state` are stubs; the +campaign's cut map in `Epiphany/notes/eureka-pipeline-state-cut.md` owns what +each crate must do next. ```powershell cargo check --workspace diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index fd4762d..9c1ef96 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -5,4 +5,20 @@ edition.workspace = true license.workspace = true publish.workspace = true +# The organ's rule engine: storage, identity and admission of one instance's +# mind. Document shape, bounds, formats and keys come from `epiphany-pipeline`; +# the store comes from CultLib's Rust CultCache at the same rev Epiphany pins, +# so the leaf's wrappers and this crate's cache are one `cultcache-rs`. No +# network, no socket, no index: those are the daemon's (Cut 10, Cut 11). [dependencies] +anyhow = "1" +chrono = "0.4.44" +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultcache-rs" } +epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "a65c6420f681d6641b842f94b319f0fa14166c96", package = "epiphany-pipeline" } +rmp-serde = "1" +schemars = "1" +serde = { version = "1", features = ["derive"] } +sha2 = "0.10" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs new file mode 100644 index 0000000..a21a097 --- /dev/null +++ b/crates/huginn-mind/src/fixtures.rs @@ -0,0 +1,42 @@ +//! Test fixtures shared by the modules' tests: one valid document of each +//! kind the tests need, prepared through the leaf against the organ's cache. + +use cultcache_rs::CultCacheEnvelope; +use epiphany_pipeline::{Date, PipelineDocument, PipelineInstance, Short, Slug}; + +use crate::mind::{HuginnMindEpoch, schema_cache}; + +pub(crate) const INSTANCE: &str = "yggdrasil"; + +pub(crate) fn slug(value: &str) -> Slug { + value.into() +} + +pub(crate) fn s(value: &str) -> Short { + value.into() +} + +pub(crate) fn date() -> Date { + Date("2026-09-16".into()) +} + +pub(crate) fn instance(name: &str) -> PipelineDocument { + PipelineDocument::Instance(PipelineInstance { + instance: slug(name), + display_name: s(&format!("{name} mind")), + created_at: date(), + host: s(name), + }) +} + +/// The leaf's envelope for a document, keyed and encoded as the organ stores +/// it. +pub(crate) fn prepare(document: &PipelineDocument) -> CultCacheEnvelope { + document.validate().unwrap(); + document.prepare(&schema_cache().unwrap()).unwrap() +} + +/// The epoch record admission derives on the first write. +pub(crate) fn epoch() -> CultCacheEnvelope { + HuginnMindEpoch::envelope(&schema_cache().unwrap()).unwrap() +} diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index e69de29..83e4bf2 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -0,0 +1,29 @@ +//! `huginn-mind`: one instance's mind as typed CultCache state. +//! +//! The crate owns three decisions and nothing else. `Mind::open_with` owns +//! "may this store be this instance's mind": the opener refuses a foreign +//! type, a foreign epoch, a missing identity or a foreign identity before it +//! attaches anything. `receipt::commit` owns "did a batch enter, whole, with a +//! receipt": one compare-and-swap over the store, a receipt naming the exact +//! bytes read and written, and a typed conflict when the swap loses. The +//! admission path (Cut 8's third commit) owns "may this batch enter". +//! +//! Document shape, bounds, formats and keys are `epiphany-pipeline`'s; the +//! organ registers, prepares, decodes and validates through the leaf's four +//! doors and never re-derives a key. The store is CultLib's owned redb +//! CultCache, whose lifetime-long exclusive lock is the single-writer +//! invariant's mechanism. No function here reads a clock or the environment: +//! `now` is passed in. + +pub mod mind; +pub mod receipt; +pub mod refusal; +pub mod store; + +#[cfg(test)] +pub(crate) mod fixtures; + +pub use mind::{HuginnMindEpoch, Mind}; +pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION}; +pub use refusal::MindRefusal; +pub use store::MindStore; diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs new file mode 100644 index 0000000..ba34555 --- /dev/null +++ b/crates/huginn-mind/src/mind.rs @@ -0,0 +1,362 @@ +//! The mind: one instance's store, opened fail-closed, and its image. +//! +//! Ruling 14: a store is canonical to exactly one instance, and the identity +//! lives in the state, in the `instance` document, not in a path. The path +//! under the state root is derived from the slug for convenience; the +//! document is the authority, and a store moved to another instance's +//! directory is refused. Ruling 15: one writer, the owned redb store's +//! lifetime-long exclusive lock. Ruling 20's analogue: the opener validates +//! every gate before it attaches, so a refused store is never read into an +//! image. + +use std::path::{Path, PathBuf}; + +use cultcache_rs::{ + CacheBackingStore, CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessagePackBackingStore, +}; +use epiphany_pipeline::{ + PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types, +}; + +use crate::receipt::HuginnCommitReceipt; +use crate::refusal::MindRefusal; +use crate::store::MindStore; + +/// The store's record of the schema epoch it was written at, keyed by the +/// epoch string. Derived by admission on the first write; read by the opener, +/// which refuses a store written at any other epoch (`ForeignEpoch`) so a +/// breaking schema bump refuses the old store instead of misreading it. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "huginn.mind_epoch.v1", schema = "HuginnMindEpoch")] +pub struct HuginnMindEpoch { + #[cultcache(key = 0)] + pub schema_epoch: String, +} + +impl HuginnMindEpoch { + /// The one envelope the record ever has: the current epoch, keyed by + /// itself. Admission appends it to the first write. + pub(crate) fn envelope(cache: &CultCache) -> Result { + let record = Self { schema_epoch: PIPELINE_SCHEMA_EPOCH.to_string() }; + cache + .prepare_entry_named(PIPELINE_SCHEMA_EPOCH, &record) + .map(|(envelope, _)| envelope) + .map_err(unavailable) + } +} + +/// A store or cache error, carried as data. +pub(crate) fn unavailable(error: anyhow::Error) -> MindRefusal { + MindRefusal::Unavailable { detail: format!("{error:#}") } +} + +/// A cache that knows the fifteen types a mind's store may hold: the leaf's +/// thirteen through its registrar, the epoch record and the commit receipt. +/// It is the cache every envelope is prepared against. +pub(crate) fn schema_cache() -> Result { + let mut cache = CultCache::new(); + register_pipeline_document_types(&mut cache).map_err(unavailable)?; + cache.register_entry_type::().map_err(unavailable)?; + cache.register_entry_type::().map_err(unavailable)?; + Ok(cache) +} + +fn is_known_type(type_id: &str) -> bool { + type_id == HuginnMindEpoch::TYPE + || type_id == HuginnCommitReceipt::TYPE + || PipelineKind::ALL.iter().any(|kind| kind.type_id() == type_id) +} + +/// One instance's mind: the declared instance, the store, the registered +/// cache attached to it, and the image (the store's envelopes as last pulled, +/// in identity order). The image is a cache of the store and is re-pulled +/// after every commit; nothing writes it directly. +pub struct Mind { + instance: Slug, + store: S, + cache: CultCache, + image: Vec, +} + +impl Mind { + /// `/minds//mind.redb`. Derived for convenience; + /// the `instance` document inside is the identity. + pub fn path_for(state_root: &Path, instance: &Slug) -> PathBuf { + state_root.join("minds").join(&instance.0).join("mind.redb") + } + + /// Opens the instance's mind under the state root, taking the store's + /// exclusive lock for the mind's lifetime. A second owner of the same + /// path, in this process or another, is `MindAlreadyOwned`. An empty + /// store is a valid open; only the first admission may write into it. + pub fn open(state_root: &Path, instance: &Slug) -> Result { + let path = Self::path_for(state_root, instance); + let store = OwnedRedbMessagePackBackingStore::new(&path).map_err(|error| { + // The store reports a held lock as an error like any other; its + // message is the only signal, so it is matched here, once. + if format!("{error:#}").contains("already has an active owner") { + MindRefusal::MindAlreadyOwned { path: path.display().to_string() } + } else { + unavailable(error) + } + })?; + Self::open_with(store, instance) + } +} + +impl Mind { + /// Fail-closed, in this order, nothing attached until every step passes: + /// pull the raw envelopes; every type is one of the fifteen; if anything + /// is stored, exactly one epoch record at the current epoch and exactly + /// one `instance` document naming the declared instance; then register, + /// attach and pull. + pub fn open_with(store: S, instance: &Slug) -> Result { + let raw = store.pull_all().map_err(unavailable)?; + refuse_foreign_types(&raw)?; + refuse_foreign_epoch(&raw)?; + refuse_foreign_identity(&raw, instance)?; + let (cache, image) = attach(store.clone())?; + Ok(Self { instance: instance.clone(), store, cache, image }) + } + + pub fn instance(&self) -> &Slug { + &self.instance + } + + /// The image: every envelope the store held at the last pull, in + /// identity order. Cut 10's snapshot source reads this. + pub fn envelopes(&self) -> &[CultCacheEnvelope] { + &self.image + } + + pub fn is_empty(&self) -> bool { + self.image.is_empty() + } + + /// The stored bytes of one document, by kind and id. + pub fn envelope(&self, kind: PipelineKind, id: &str) -> Option<&CultCacheEnvelope> { + self.raw_envelope(kind.type_id(), id) + } + + /// One document, decoded through the leaf. + pub fn get(&self, kind: PipelineKind, id: &str) -> Result, MindRefusal> { + self.envelope(kind, id) + .map(|envelope| PipelineDocument::decode(envelope).map_err(MindRefusal::Document)) + .transpose() + } + + /// Every commit receipt in the image, in receipt-id order. + pub fn receipts(&self) -> Result, MindRefusal> { + self.image + .iter() + .filter(|envelope| envelope.r#type == HuginnCommitReceipt::TYPE) + .map(|envelope| { + rmp_serde::from_slice::(&envelope.payload).map_err(|error| { + MindRefusal::Unavailable { detail: format!("receipt {} does not decode: {error}", envelope.key) } + }) + }) + .collect() + } + + pub(crate) fn raw_envelope(&self, type_id: &str, key: &str) -> Option<&CultCacheEnvelope> { + self.image.iter().find(|envelope| envelope.r#type == type_id && envelope.key == key) + } + + pub(crate) fn cache(&self) -> &CultCache { + &self.cache + } + + pub(crate) fn store(&self) -> &S { + &self.store + } + + /// Re-pulls the image from the store through the attached cache. + pub(crate) fn refresh(&mut self) -> Result<(), MindRefusal> { + self.cache.pull_all_backing_stores().map_err(unavailable)?; + self.image = snapshot(&self.cache); + Ok(()) + } +} + +/// Step 2: a runtime store, a Mind store or any other file passed by mistake +/// dies on its first foreign type. +fn refuse_foreign_types(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> { + if let Some(foreign) = raw.iter().find(|envelope| !is_known_type(&envelope.r#type)) { + return Err(MindRefusal::ForeignStore { r#type: foreign.r#type.clone() }); + } + Ok(()) +} + +/// Step 3: a non-empty store carries exactly one epoch record, at the epoch +/// this binary writes. +fn refuse_foreign_epoch(raw: &[CultCacheEnvelope]) -> Result<(), MindRefusal> { + if raw.is_empty() { + return Ok(()); + } + let mut records = raw.iter().filter(|envelope| envelope.r#type == HuginnMindEpoch::TYPE); + let Some(record) = records.next() else { + return Err(MindRefusal::MissingIdentity); + }; + let record: HuginnMindEpoch = rmp_serde::from_slice(&record.payload) + .map_err(|error| MindRefusal::Unavailable { detail: format!("epoch record does not decode: {error}") })?; + if record.schema_epoch != PIPELINE_SCHEMA_EPOCH { + return Err(MindRefusal::ForeignEpoch { found: record.schema_epoch, expected: PIPELINE_SCHEMA_EPOCH.into() }); + } + Ok(()) +} + +/// Step 4: a non-empty store carries exactly one `instance` document, and it +/// names the declared instance. This is where a store moved to another +/// instance's directory is refused. +fn refuse_foreign_identity(raw: &[CultCacheEnvelope], instance: &Slug) -> Result<(), MindRefusal> { + if raw.is_empty() { + return Ok(()); + } + let mut documents = raw.iter().filter(|envelope| envelope.r#type == PipelineKind::Instance.type_id()); + let (Some(envelope), None) = (documents.next(), documents.next()) else { + return Err(MindRefusal::MissingIdentity); + }; + let PipelineDocument::Instance(stored) = PipelineDocument::decode(envelope)? else { + return Err(MindRefusal::MissingIdentity); + }; + if stored.instance != *instance { + return Err(MindRefusal::ForeignInstance { declared: instance.0.clone(), mind: stored.instance.0 }); + } + Ok(()) +} + +/// Step 5: the registered cache, the store as its one generic home, and the +/// image pulled through it. +fn attach(store: S) -> Result<(CultCache, Vec), MindRefusal> { + let mut cache = schema_cache()?; + cache.add_generic_backing_store(store).map_err(unavailable)?; + cache.pull_all_backing_stores().map_err(unavailable)?; + let image = snapshot(&cache); + Ok((cache, image)) +} + +fn snapshot(cache: &CultCache) -> Vec { + let mut image = cache.snapshot_envelopes(); + image.sort_by(|left, right| (&left.r#type, &left.key).cmp(&(&right.r#type, &right.key))); + image +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::{INSTANCE, epoch, instance, prepare, slug}; + use crate::store::test_stores::MemoryStore; + + fn planted(rows: Vec) -> MemoryStore { + let store = MemoryStore::new(); + for row in rows { + store.plant(row); + } + store + } + + fn foreign(type_id: &str) -> CultCacheEnvelope { + let mut envelope = epoch(); + envelope.r#type = type_id.into(); + envelope + } + + fn foreign_epoch() -> CultCacheEnvelope { + let cache = schema_cache().unwrap(); + let record = HuginnMindEpoch { schema_epoch: "epiphany.pipeline.epoch.v0".into() }; + cache.prepare_entry_named("epiphany.pipeline.epoch.v0", &record).unwrap().0 + } + + #[test] + fn the_opener_refuses_foreign_epoch_missing_identity_and_foreign_type_before_attaching() { + let yggdrasil = slug(INSTANCE); + let cases: Vec<(Vec, MindRefusal)> = vec![ + // Step 2 before everything: a runtime store passed by mistake. + ( + vec![foreign("epiphany.runtime_spine.v47"), epoch(), prepare(&instance(INSTANCE))], + MindRefusal::ForeignStore { r#type: "epiphany.runtime_spine.v47".into() }, + ), + // Step 3 before step 4: a foreign epoch with no identity at all. + ( + vec![foreign_epoch()], + MindRefusal::ForeignEpoch { + found: "epiphany.pipeline.epoch.v0".into(), + expected: PIPELINE_SCHEMA_EPOCH.into(), + }, + ), + // Step 3: an identity without an epoch record. + (vec![prepare(&instance(INSTANCE))], MindRefusal::MissingIdentity), + // Step 4: an epoch record without an identity. + (vec![epoch()], MindRefusal::MissingIdentity), + // Step 4: two identities are no identity. + ( + vec![epoch(), prepare(&instance(INSTANCE)), prepare(&instance("thought-cage"))], + MindRefusal::MissingIdentity, + ), + ]; + for (rows, expected) in cases { + let store = planted(rows); + let before = store.rows(); + let refusal = Mind::open_with(store.clone(), &yggdrasil).err().expect("refused"); + assert_eq!(refusal, expected); + assert_eq!(store.pull_count(), 1, "{expected:?}: refused before attaching"); + assert_eq!(store.rows(), before, "{expected:?}: bytes unchanged"); + } + // A store that passes every gate attaches, which is the second pull. + let store = planted(vec![epoch(), prepare(&instance(INSTANCE))]); + let mind = Mind::open_with(store.clone(), &yggdrasil).unwrap(); + assert_eq!(store.pull_count(), 2); + assert_eq!(mind.envelopes().len(), 2); + assert!(!mind.is_empty()); + let empty = MemoryStore::new(); + assert!(Mind::open_with(empty, &yggdrasil).unwrap().is_empty()); + } + + #[test] + fn the_instance_document_is_the_identity_not_the_path() { + // The same bytes opened under another name are refused. + let store = planted(vec![epoch(), prepare(&instance(INSTANCE))]); + assert_eq!( + Mind::open_with(store, &slug("thought-cage")).err(), + Some(MindRefusal::ForeignInstance { declared: "thought-cage".into(), mind: INSTANCE.into() }) + ); + + // A real store copied into another instance's directory is still + // yggdrasil's mind: it opens as yggdrasil, and not as thought-cage. + let root = tempfile::tempdir().unwrap(); + let written = Mind::path_for(root.path(), &slug(INSTANCE)); + { + let mut store = OwnedRedbMessagePackBackingStore::new(&written).unwrap(); + store.push(&epoch()).unwrap(); + store.push(&prepare(&instance(INSTANCE))).unwrap(); + } + let moved = Mind::path_for(root.path(), &slug("thought-cage")); + std::fs::create_dir_all(moved.parent().unwrap()).unwrap(); + std::fs::copy(&written, &moved).unwrap(); + assert_eq!( + Mind::open(root.path(), &slug("thought-cage")).err(), + Some(MindRefusal::ForeignInstance { declared: "thought-cage".into(), mind: INSTANCE.into() }) + ); + let store = OwnedRedbMessagePackBackingStore::new(&moved).unwrap(); + let mind = Mind::open_with(store, &slug(INSTANCE)).unwrap(); + assert_eq!(mind.instance(), &slug(INSTANCE)); + assert_eq!(mind.envelopes().len(), 2); + } + + #[test] + fn a_mind_has_one_owner_at_a_time() { + let root = tempfile::tempdir().unwrap(); + let yggdrasil = slug(INSTANCE); + let first = Mind::open(root.path(), &yggdrasil).unwrap(); + assert!(first.is_empty()); + let path = Mind::path_for(root.path(), &yggdrasil).display().to_string(); + assert_eq!( + Mind::open(root.path(), &yggdrasil).err(), + Some(MindRefusal::MindAlreadyOwned { path: path.clone() }) + ); + drop(first); + let second = Mind::open(root.path(), &yggdrasil).unwrap(); + assert!(second.is_empty()); + assert_eq!(Mind::path_for(root.path(), &yggdrasil), root.path().join("minds").join(INSTANCE).join("mind.redb")); + } +} diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs new file mode 100644 index 0000000..a74731b --- /dev/null +++ b/crates/huginn-mind/src/receipt.rs @@ -0,0 +1,171 @@ +//! The commit receipt: what a batch read and what it wrote, byte for byte. +//! +//! This is the bounded duplicate of `epiphany-core`'s receipt (FU-3), smaller +//! by design: no authority enum, no companion documents, no `invariant_owner`, +//! no `store_id`. Provenance is a field on the receipt, not a companion, and +//! the digest excludes it, so an exact replay across sessions and faculties +//! is idempotent. If a third consumer appears, extract the primitive into +//! CultLib rather than copy it again. +//! +//! Digest: `receipt_id = "mind-commit-" + hex(sha256(msgpack_named((instance, +//! strong_reads, writes))))`, where each list is `DocumentVersion`s in +//! `(document_type, document_key)` order and a version carries the exact +//! payload bytes and their SHA-256. `committed_at` and `provenance` are not +//! digested. + +use cultcache_rs::{CultCacheEnvelope, DatabaseEntry}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::refusal::MindRefusal; + +pub const RECEIPT_SCHEMA_VERSION: &str = "huginn.mind_commit_receipt.v1"; + +/// Which faculty a batch was admitted as. Attribution, not authority +/// (ruling 18): no admission rule trusts it, and Cut 9's views read it back +/// from the receipt. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum Faculty { + SelfFaculty, + Imagination, + Hands, + Soul, + MindSteward, + Eyes, + Operator, +} + +/// Who admitted a batch: the faculty, the agent, its session and the tool. +/// A field on the receipt and nothing else; the digest excludes it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PipelineProvenance { + pub faculty: Faculty, + pub agent: epiphany_pipeline::Short, + pub session: epiphany_pipeline::Short, + pub tool: epiphany_pipeline::Short, +} + +/// The exact bytes of one stored document, as read or as written. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct DocumentVersion { + pub document_type: String, + pub document_key: String, + pub schema_id: Option, + pub payload_msgpack: Vec, + pub payload_sha256: String, +} + +impl DocumentVersion { + pub fn from_envelope(envelope: &CultCacheEnvelope) -> Self { + Self { + document_type: envelope.r#type.clone(), + document_key: envelope.key.clone(), + schema_id: envelope.schema_id.clone(), + payload_msgpack: envelope.payload.clone(), + payload_sha256: sha256_hex(&envelope.payload), + } + } + + pub fn identity(&self) -> (&str, &str) { + (&self.document_type, &self.document_key) + } + + fn validate(&self) -> Result<(), MindRefusal> { + if self.document_type.is_empty() || self.document_key.is_empty() { + return Err(integrity("a document version names no identity")); + } + if self.payload_sha256 != sha256_hex(&self.payload_msgpack) { + return Err(integrity(&format!( + "document {}/{} payload digest mismatch", + self.document_type, self.document_key + ))); + } + Ok(()) + } +} + +/// The receipt of one admitted batch, stored beside the documents it names +/// and keyed by its id. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "huginn.mind_commit_receipt.v1", schema = "HuginnCommitReceipt")] +pub struct HuginnCommitReceipt { + #[cultcache(key = 0)] + pub schema_version: String, + #[cultcache(key = 1)] + pub receipt_id: String, + #[cultcache(key = 2)] + pub instance: String, + #[cultcache(key = 3)] + pub provenance: PipelineProvenance, + #[cultcache(key = 4)] + pub strong_reads: Vec, + #[cultcache(key = 5)] + pub writes: Vec, + #[cultcache(key = 6)] + pub committed_at: String, +} + +impl HuginnCommitReceipt { + /// The digest, from what was read and written and never from who wrote + /// it or when. + pub fn digest(&self) -> Result { + let bytes = rmp_serde::to_vec_named(&(&self.instance, &self.strong_reads, &self.writes)) + .map_err(|error| integrity(&format!("receipt digest input does not encode: {error}")))?; + Ok(format!("mind-commit-{}", sha256_hex(&bytes))) + } + + pub fn validate(&self) -> Result<(), MindRefusal> { + if self.schema_version != RECEIPT_SCHEMA_VERSION { + return Err(integrity(&format!("receipt schema version {:?} is foreign", self.schema_version))); + } + if self.instance.is_empty() { + return Err(integrity("receipt names no instance")); + } + if self.writes.is_empty() { + return Err(integrity("receipt writes nothing")); + } + for version in self.strong_reads.iter().chain(&self.writes) { + version.validate()?; + } + if !unique_identities(&self.strong_reads) || !unique_identities(&self.writes) { + return Err(integrity("receipt names an identity twice")); + } + if self.receipt_id != self.digest()? { + return Err(integrity(&format!("receipt {} does not match its digest", self.receipt_id))); + } + Ok(()) + } +} + +fn unique_identities(versions: &[DocumentVersion]) -> bool { + let identities = versions.iter().map(DocumentVersion::identity).collect::>(); + identities.len() == versions.len() +} + +fn integrity(detail: &str) -> MindRefusal { + MindRefusal::Unavailable { detail: detail.into() } +} + +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::{INSTANCE, instance, prepare}; + use epiphany_pipeline::{PipelineDocument, PipelineRefusal}; + + /// S5 closed against the real type: the organ's own receipt in a mind's + /// store is never decoded as a pipeline document. + #[test] + fn decode_refuses_the_organs_own_receipt() { + let mut envelope = prepare(&instance(INSTANCE)); + envelope.r#type = HuginnCommitReceipt::TYPE.into(); + assert_eq!( + PipelineDocument::decode(&envelope), + Err(PipelineRefusal::ForeignStore { r#type: "huginn.mind_commit_receipt.v1".into() }) + ); + } +} diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs new file mode 100644 index 0000000..cba00fa --- /dev/null +++ b/crates/huginn-mind/src/refusal.rs @@ -0,0 +1,83 @@ +//! Typed refusals of the organ. A refusal is data with a field an agent can +//! act on, never a transport error: the outcome carries it, the wire (Cut 10) +//! serialises it, the tools (Cut 13) show it. + +use epiphany_pipeline::{PipelineKind, PipelineRefusal}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Every way a mind refuses. `Document` wraps the leaf's four (bounds, +/// formats, key identity, foreign type); the rest are the organ's: store +/// identity, epoch, ownership, batch shape, references, and every cross-field +/// and cross-document rule of admission. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum MindRefusal { + Document( + #[serde(with = "DocumentRefusal")] + #[schemars(with = "DocumentRefusal")] + PipelineRefusal, + ), + ForeignInstance { declared: String, mind: String }, + MissingIdentity, + ForeignEpoch { found: String, expected: String }, + ForeignStore { r#type: String }, + MindAlreadyOwned { path: String }, + BatchSize { actual: u32 }, + IdentityCollision { kind: PipelineKind, id: String }, + MissingReference { kind: PipelineKind, id: String }, + AlreadyResolved { subject: String }, + IncompatibleResolution { subject_kind: PipelineKind, outcome: String }, + CitesResolvedDocument { kind: PipelineKind, id: String }, + EmptySupersession, + UnknownSupersessor { id: String }, + RevisionWithoutSupersession { kind: PipelineKind, revision: u32 }, + DuplicateLabel { field: String, label: String }, + InvalidOptions { question: String }, + InvalidChoice { ruling: String, choice: String }, + QuoteWithoutOperator { ruling: String }, + RepoNotStewarded { repo: String }, + RepoNotInCampaign { repo: String }, + CutReportWithoutSpec { report: String }, + SpecMismatch { field: String }, + RangeOutsideCommits { head: String }, + FalsifiedClaimWithoutConfirmedFinding { claim: String }, + UnprovenClaimWithConfirmedFinding { claim: String }, + PromiseWithoutVerdict { promise: String }, + UnknownMutationLabel { label: String }, + FindingWithoutRange, + FindingWithoutEvidence, + UnknownInvariant { label: String }, + NotStewarded { repo: String }, + Unavailable { detail: String }, +} + +/// The leaf's `PipelineRefusal` at the pinned rev derives neither `Serialize` +/// nor `JsonSchema`. This is serde's remote definition of it: the same four +/// variants, field for field, compiled against the leaf's type, so a leaf +/// change that renames or adds a field stops this crate building rather than +/// drifting. It is a projection for the wire, not a second vocabulary; the +/// refusal itself is still the leaf's value. When the leaf derives the two +/// traits this goes. +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(remote = "PipelineRefusal")] +#[allow(dead_code)] +enum DocumentRefusal { + FieldBound { field: String, limit: u32, actual: u32 }, + InvalidFormat { field: String, value: String }, + InvalidIdentity { kind: PipelineKind, key: String, expected: String }, + ForeignStore { r#type: String }, +} + +impl std::fmt::Display for MindRefusal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "mind refusal: {self:?}") + } +} + +impl std::error::Error for MindRefusal {} + +impl From for MindRefusal { + fn from(refusal: PipelineRefusal) -> Self { + Self::Document(refusal) + } +} diff --git a/crates/huginn-mind/src/store.rs b/crates/huginn-mind/src/store.rs new file mode 100644 index 0000000..d532fbd --- /dev/null +++ b/crates/huginn-mind/src/store.rs @@ -0,0 +1,208 @@ +//! The store port. `cultcache-rs` exposes compare-and-swap as inherent methods +//! on its concrete stores, not on `CacheBackingStore`, so the one narrow trait +//! here is how the commit primitive gets it injected: the owned redb store in +//! the daemon, a memory store in every admission test, a refusing store in the +//! atomicity test. Nothing else is abstracted. + +use anyhow::Result; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; + +/// A store a mind can be opened over. `Clone` shares ownership of the same +/// store (the owned redb store's clones share its lock and handle), which is +/// how the cache image and the commit path hold one store between them. +pub trait MindStore: CacheBackingStore + Clone + 'static { + /// One transactional step: every `expected` row must be stored exactly as + /// given, no `replacements` row outside `expected` may already exist, and + /// then every replacement lands, or nothing does and `Ok(false)` says so. + /// The store's own rule that every expected identity is also replaced is + /// kept: a strong read is re-inserted unchanged beside the writes. + fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result; +} + +impl MindStore for OwnedRedbMessagePackBackingStore { + fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + OwnedRedbMessagePackBackingStore::compare_and_swap_batch(self, expected, replacements) + } +} + +/// Test stores: a memory store with the redb store's batch semantics, and one +/// that can be told to lose or fail its next swap. +#[cfg(test)] +pub(crate) mod test_stores { + use std::collections::{BTreeMap, BTreeSet}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use anyhow::{Result, anyhow}; + use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, PushAllOptions}; + + use super::MindStore; + + type Identity = (String, String); + + fn identity(entry: &CultCacheEnvelope) -> Identity { + (entry.r#type.clone(), entry.key.clone()) + } + + fn unique(entries: &[CultCacheEnvelope], label: &str) -> Result> { + let ids = entries.iter().map(identity).collect::>(); + if ids.len() != entries.len() { + return Err(anyhow!("conditional batch {label} set contains duplicate identities")); + } + Ok(ids) + } + + /// One row per `(type, key)` behind a mutex; clones share it. Counts + /// `pull_all` calls so the opener test can prove nothing attached. + #[derive(Clone, Default)] + pub(crate) struct MemoryStore { + rows: Arc>>, + pulls: Arc, + } + + impl MemoryStore { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn pull_count(&self) -> usize { + self.pulls.load(Ordering::SeqCst) + } + + /// Every row, in identity order: the bytes a test compares before + /// and after a refused admission. + pub(crate) fn rows(&self) -> Vec { + self.rows.lock().unwrap().values().cloned().collect() + } + + /// Writes a row without the commit path, for the opener tests and the + /// one replay-planting test. + pub(crate) fn plant(&self, entry: CultCacheEnvelope) { + self.rows.lock().unwrap().insert(identity(&entry), entry); + } + } + + impl CacheBackingStore for MemoryStore { + fn pull_all(&self) -> Result> { + self.pulls.fetch_add(1, Ordering::SeqCst); + Ok(self.rows()) + } + + fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.plant(entry.clone()); + Ok(()) + } + + fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.rows.lock().unwrap().remove(&identity(entry)); + Ok(()) + } + + fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { + *self.rows.lock().unwrap() = entries.iter().map(|entry| (identity(entry), entry.clone())).collect(); + Ok(()) + } + } + + impl MindStore for MemoryStore { + fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + if replacements.is_empty() { + return Err(anyhow!("conditional batch requires a non-empty replacement set")); + } + let expected_ids = unique(expected, "expected")?; + let replacement_ids = unique(&replacements, "replacement")?; + if !expected_ids.is_subset(&replacement_ids) { + return Err(anyhow!("conditional batch must replace every expected identity")); + } + let mut rows = self.rows.lock().unwrap(); + for row in expected { + if rows.get(&identity(row)) != Some(row) { + return Ok(false); + } + } + for row in &replacements { + if !expected_ids.contains(&identity(row)) && rows.contains_key(&identity(row)) { + return Ok(false); + } + } + for row in replacements { + rows.insert(identity(&row), row); + } + Ok(true) + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(crate) enum SwapCommand { + Delegate, + Lose, + Fail, + } + + /// A memory store whose next swap can be commanded to lose (`Ok(false)`) + /// or fail (`Err`), so the commit path's two non-committing exits are + /// reachable without a second process. + #[derive(Clone)] + pub(crate) struct RefusingStore { + inner: MemoryStore, + command: Arc>, + } + + impl RefusingStore { + pub(crate) fn new() -> Self { + Self { inner: MemoryStore::new(), command: Arc::new(Mutex::new(SwapCommand::Delegate)) } + } + + pub(crate) fn command(&self, command: SwapCommand) { + *self.command.lock().unwrap() = command; + } + + pub(crate) fn rows(&self) -> Vec { + self.inner.rows() + } + } + + impl CacheBackingStore for RefusingStore { + fn pull_all(&self) -> Result> { + self.inner.pull_all() + } + + fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.inner.push(entry) + } + + fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> { + self.inner.delete(entry) + } + + fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result<()> { + self.inner.push_all(entries, options) + } + } + + impl MindStore for RefusingStore { + fn compare_and_swap_batch( + &self, + expected: &[CultCacheEnvelope], + replacements: Vec, + ) -> Result { + match *self.command.lock().unwrap() { + SwapCommand::Delegate => self.inner.compare_and_swap_batch(expected, replacements), + SwapCommand::Lose => Ok(false), + SwapCommand::Fail => Err(anyhow!("the store refused the swap on command")), + } + } + } +}