Merge remote-tracking branch 'origin/main' into eureka-body/mind-launch

This commit is contained in:
Meta Vens 2026-10-10 19:16:19 +02:00
commit 5da8b06a6d
29 changed files with 4182 additions and 55 deletions

View file

@ -20,7 +20,7 @@ Studio's job, not Huginn's.
derived status included, over `cultcache-rs` and `eureka-pipeline`.
`huginn-daemon` serves one mind over CultNet RUDP through `cultnet-rs`, and reaches the leaf and the store type through `huginn-mind`
so the workspace's one CultLib revision (root `[workspace.dependencies]`) decides both. `eureka-state` is the client core: one call
to a daemon over CultNet, holding no state.
to a daemon over CultNet, holding no state; its `huginn` binary is the CLI agents reach the mind through, and it holds no state either.
- Owned: an instance's memory documents, their admission and the read side
that derives their status, in `huginn-mind`,
over a redb CultCache store at `<state_root>/minds/<instance>/mind.redb`;

513
Cargo.lock generated
View file

@ -48,6 +48,35 @@ dependencies = [
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.6"
@ -57,6 +86,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@ -98,6 +177,21 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
@ -119,6 +213,12 @@ dependencies = [
"generic-array",
]
[[package]]
name = "borrow-or-share"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "borsh"
version = "1.8.1"
@ -149,6 +249,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytecount"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]]
name = "bytes"
version = "1.12.1"
@ -201,6 +307,52 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "const-oid"
version = "0.9.6"
@ -357,6 +509,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "der"
version = "0.7.10"
@ -408,6 +566,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
dependencies = [
"serde",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -443,6 +610,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"cultnet-rs",
"huginn-daemon",
"huginn-mind",
@ -455,6 +623,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "fancy-regex"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912"
dependencies = [
"bit-set",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@ -473,6 +652,33 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "fluent-uri"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
dependencies = [
"borrow-or-share",
"ref-cast",
"serde",
]
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "fraction"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500"
dependencies = [
"num",
"num-bigint",
]
[[package]]
name = "fs2"
version = "0.4.3"
@ -599,9 +805,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -631,7 +839,7 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash",
"ahash 0.7.8",
]
[[package]]
@ -639,6 +847,17 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
@ -693,10 +912,12 @@ dependencies = [
"cultcache-rs",
"cultnet-rs",
"eureka-pipeline",
"jsonschema",
"rmp-serde",
"schemars",
"serde",
"serde_json",
"serde_path_to_error",
"sha2",
"tempfile",
]
@ -759,6 +980,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
@ -776,6 +1003,59 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonschema"
version = "0.58.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1"
dependencies = [
"ahash 0.8.12",
"bytecount",
"data-encoding",
"email_address",
"fancy-regex",
"fraction",
"getrandom 0.3.4",
"itoa",
"jsonschema-regex",
"jsonschema-value",
"num-cmp",
"num-traits",
"percent-encoding",
"referencing",
"regex",
"serde",
"serde_json",
"strum",
"unicode-general-category",
"uuid-simd",
]
[[package]]
name = "jsonschema-regex"
version = "0.58.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7"
dependencies = [
"regex-syntax",
]
[[package]]
name = "jsonschema-value"
version = "0.58.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96"
dependencies = [
"ahash 0.8.12",
"bytecount",
"fraction",
"getrandom 0.3.4",
"num-cmp",
"num-traits",
"serde_json",
"zmij",
]
[[package]]
name = "libc"
version = "0.2.189"
@ -824,6 +1104,12 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "micromap"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
[[package]]
name = "mind-body"
version = "0.1.0"
@ -841,6 +1127,75 @@ dependencies = [
"tempfile",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-cmp"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -856,12 +1211,24 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "parking_lot"
version = "0.11.2"
@ -870,7 +1237,17 @@ checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
dependencies = [
"instant",
"lock_api",
"parking_lot_core",
"parking_lot_core 0.8.6",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core 0.9.12",
]
[[package]]
@ -882,11 +1259,24 @@ dependencies = [
"cfg-if",
"instant",
"libc",
"redox_syscall",
"redox_syscall 0.2.16",
"smallvec",
"winapi",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
[[package]]
name = "pastey"
version = "0.2.3"
@ -1052,6 +1442,15 @@ dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "reed-solomon-erasure"
version = "6.0.0"
@ -1060,7 +1459,7 @@ checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f"
dependencies = [
"libm",
"lru",
"parking_lot",
"parking_lot 0.11.2",
"smallvec",
"spin",
]
@ -1085,6 +1484,52 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "referencing"
version = "0.58.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f"
dependencies = [
"ahash 0.8.12",
"fluent-uri",
"getrandom 0.3.4",
"hashbrown 0.17.1",
"itoa",
"micromap",
"parking_lot 0.12.5",
"percent-encoding",
"serde_json",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rmcp"
version = "2.2.0"
@ -1296,6 +1741,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
@ -1386,6 +1842,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "subtle"
version = "2.6.1"
@ -1550,6 +2027,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -1597,6 +2080,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.1"
@ -1608,12 +2097,28 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "uuid-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
dependencies = [
"outref",
"vsimd",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"

View file

@ -46,7 +46,9 @@ A Cargo workspace of three crates:
the process, and no rule. The operation envelope both sides speak is
`huginn-mind`'s.
- `crates/eureka-state`: the client core, `HuginnClient::call`: one request to a
daemon over CultNet RUDP, a deferred answer resolved, and no state.
daemon over CultNet RUDP, a deferred answer resolved, and no state. Its
`huginn` binary is the agents' CLI: whoami, view, query and named recipes,
admit with read-back, schema.
`huginn-mind` is live: it opens one instance's store (an owned redb CultCache
at `<state_root>/minds/<instance>/mind.redb`, locked for the mind's lifetime),

View file

@ -11,10 +11,28 @@ publish.workspace = true
# the daemon cannot spell them differently; the content fetch and its
# verification are `cultnet-rs`'s.
#
# The MCP server (`main.rs`, the `eureka-state` binary) is the client's one
# consumer: four tools over stdio, one per wire operation, holding no state.
# The crate is the client core (`lib.rs`) and its consumers: the `huginn` CLI
# (`src/bin/huginn/`), the shell door every agent uses, and until
# delete-eureka-state-mcp the MCP server (`main.rs`, the `eureka-state` binary).
# Both hold no state.
# `cli` (default) is the `huginn` binary's argument parser; a library consumer
# takes `default-features = false` and compiles none of it.
[features]
default = ["cli"]
cli = ["dep:clap"]
[[bin]]
name = "huginn"
path = "src/bin/huginn/main.rs"
required-features = ["cli"]
[[test]]
name = "cli"
required-features = ["cli"]
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"], optional = true }
cultnet-rs.workspace = true
huginn-mind = { path = "../huginn-mind" }
rmcp = { version = "2.2.0", default-features = false, features = ["server", "macros", "transport-io"] }

View file

@ -0,0 +1,67 @@
//! The CLI's configuration: the mind is the session's environment, never an
//! argument. `EUREKA_INSTANCE` names the instance and `HUGINN_ENDPOINT`
//! (`rudp://<ip>:<port>`) the daemon that holds it.
use std::net::SocketAddr;
use std::time::Duration;
use eureka_state::HuginnClient;
use huginn_mind::eureka_pipeline::Slug;
use huginn_mind::{HuginnMindRequest, HuginnMindResponse};
use crate::trouble::Trouble;
/// The `provenance.tool` every admission from this binary carries.
pub const TOOL: &str = "huginn";
/// How long one call may take, connection to last byte.
const TIMEOUT: Duration = Duration::from_secs(15);
/// The two environment values as given, and the client they make when both
/// are valid.
pub struct Settings {
pub instance: Option<String>,
pub endpoint: Option<String>,
client: Result<HuginnClient, String>,
}
impl Settings {
pub fn from_env() -> Self {
let instance = variable("EUREKA_INSTANCE");
let endpoint = variable("HUGINN_ENDPOINT");
let shown = |value: &Result<Option<String>, String>| value.as_ref().ok().cloned().flatten();
let (shown_instance, shown_endpoint) = (shown(&instance), shown(&endpoint));
Self { instance: shown_instance, endpoint: shown_endpoint, client: client(instance, endpoint) }
}
pub fn client(&self) -> Result<&HuginnClient, Trouble> {
self.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))
}
/// One wire call for the configured instance. The request is built from
/// that instance, never from anything the caller sent. No retry.
pub fn ask(&self, request: impl FnOnce(&Slug) -> HuginnMindRequest) -> Result<HuginnMindResponse, Trouble> {
let client = self.client()?;
client.call(request(client.instance())).map_err(Trouble::from)
}
}
/// An environment variable: its value, `None` when unset, or why it cannot be read.
fn variable(name: &str) -> Result<Option<String>, String> {
match std::env::var(name) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} is not valid UTF-8")),
}
}
fn client(instance: Result<Option<String>, String>, endpoint: Result<Option<String>, String>) -> Result<HuginnClient, String> {
let instance = instance?.ok_or("EUREKA_INSTANCE is not set")?;
let slug = Slug(instance);
slug.validate_slug().map_err(|refusal| format!("EUREKA_INSTANCE is not a valid instance name: {refusal:?}"))?;
let endpoint = endpoint?.ok_or("HUGINN_ENDPOINT is not set")?;
let address = endpoint
.strip_prefix("rudp://")
.and_then(|address| address.parse::<SocketAddr>().ok())
.ok_or_else(|| format!("HUGINN_ENDPOINT is not rudp://<ip>:<port>: {endpoint}"))?;
Ok(HuginnClient::new(address, slug, TIMEOUT))
}

View file

@ -0,0 +1,204 @@
//! The text door: agent-written JSON becomes typed values here, strictly, and
//! nowhere else. The store's decode stays lenient (serde ignores unknown
//! keys), so a misspelled key would be dropped and the batch committed
//! without it. The door refuses instead: every key path present in the input
//! with a value and absent from the typed value's re-serialisation is named in
//! the refusal. Paths only, never values.
use std::io::Read;
use cultnet_rs::Selection;
use huginn_mind::eureka_pipeline::{PipelineDocument, PipelineKind};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, error::Category};
use crate::trouble::Trouble;
/// The text of an argument that is a literal, `@<file>` or `-` for stdin.
pub fn argument(arg: &str) -> Result<String, Trouble> {
match arg.strip_prefix('@') {
Some(path) => file(path),
None if arg == "-" => stdin(),
None => Ok(arg.to_string()),
}
}
/// The text of a file argument, or stdin when it is `-`.
pub fn file_or_stdin(arg: &str) -> Result<String, Trouble> {
if arg == "-" { stdin() } else { file(arg) }
}
fn file(path: &str) -> Result<String, Trouble> {
std::fs::read_to_string(path).map_err(|error| Trouble::invalid(format!("cannot read {path}: {}", error.kind())))
}
fn stdin() -> Result<String, Trouble> {
let mut text = String::new();
std::io::stdin()
.read_to_string(&mut text)
.map_err(|error| Trouble::invalid(format!("cannot read stdin: {}", error.kind())))?;
Ok(text)
}
/// A batch: a JSON array of `{kind, value}` documents, or one such object.
pub fn documents(text: &str) -> Result<Vec<PipelineDocument>, Trouble> {
let value = parse(text)?;
let (items, typed): (&[Value], Vec<PipelineDocument>) = match &value {
Value::Array(items) => (items.as_slice(), objects(items).and_then(|()| from_text(text, "documents"))?),
Value::Object(_) => (std::slice::from_ref(&value), vec![from_text(text, "documents[0]")?]),
_ => return Err(Trouble::invalid("the documents must be a JSON array of {kind, value} objects")),
};
let kept = reserialise(&typed, "documents")?;
let mut lost = Vec::new();
for (index, (given, held)) in items.iter().zip(&kept).enumerate() {
dropped(given, held, &format!("documents[{index}]"), &mut lost);
}
refuse_lost(&lost)?;
Ok(typed)
}
pub fn selection(text: &str) -> Result<Selection, Trouble> {
let value = parse(text)?;
if !value.is_object() {
return Err(Trouble::invalid("selection must be a JSON object"));
}
let typed: Selection = from_text(text, "selection")?;
let kept = reserialise(std::slice::from_ref(&typed), "selection")?;
let mut lost = Vec::new();
dropped(&value, &kept[0], "selection", &mut lost);
refuse_lost(&lost)?;
Ok(typed)
}
pub fn selection_value(value: &Value) -> Result<Selection, Trouble> {
selection(&value.to_string())
}
/// The kind a pipeline id names in its middle segment (`<root>:<kind>:<local>`).
pub fn kind_of(id: &str) -> Result<PipelineKind, Trouble> {
let mut segments = id.splitn(3, ':');
let (_, Some(name), Some(_)) = (segments.next(), segments.next(), segments.next()) else {
return Err(Trouble::invalid("a pipeline id is <root>:<kind>:<local>"));
};
PipelineKind::ALL
.iter()
.copied()
.find(|kind| kind.name() == name)
.ok_or_else(|| Trouble::invalid("the id's kind segment is not a pipeline kind (`huginn schema` lists them)"))
}
fn parse(text: &str) -> Result<Value, Trouble> {
serde_json::from_str(text).map_err(|error| Trouble::invalid(format!("not JSON: {}", place(&error))))
}
/// Where and what kind of failure a decode hit, never what the input said:
/// serde's own message quotes the offending value whole.
fn place(error: &serde_json::Error) -> String {
let category = match error.classify() {
Category::Io => "unreadable input",
Category::Syntax => "syntax",
Category::Data => "a value of the wrong type, an unknown variant or a missing field",
Category::Eof => "unexpected end",
};
format!("{category} at line {} column {}", error.line(), error.column())
}
/// Serde would also read a struct from an array, positionally, so only an
/// object is a document or a selection.
fn objects(items: &[Value]) -> Result<(), Trouble> {
match items.iter().position(|item| !item.is_object()) {
None => Ok(()),
Some(index) => Err(Trouble::invalid(format!("documents[{index}] must be a JSON object"))),
}
}
/// The typed value of the text, decoded from the text itself so the failure
/// has a real line and column.
fn from_text<T: DeserializeOwned>(text: &str, at: &str) -> Result<T, Trouble> {
serde_json::from_str(text).map_err(|error| Trouble::invalid(format!("{at} does not decode: {}", place(&error))))
}
/// What the typed values hold, as the JSON items to compare with the input.
fn reserialise<T: Serialize>(typed: &[T], at: &str) -> Result<Vec<Value>, Trouble> {
typed
.iter()
.map(|item| serde_json::to_value(item).map_err(|_| Trouble::Internal(format!("{at} does not re-serialise"))))
.collect()
}
fn refuse_lost(paths: &[String]) -> Result<(), Trouble> {
if paths.is_empty() {
Ok(())
} else {
Err(Trouble::invalid(format!("keys the schema does not know (`huginn schema` prints it): {}", paths.join(", "))))
}
}
/// The paths of `input` that carry a value and are absent from `kept`.
fn dropped(input: &Value, kept: &Value, path: &str, out: &mut Vec<String>) {
match (input, kept) {
(Value::Object(given), Value::Object(held)) => {
for (key, value) in given {
let here = format!("{path}.{key}");
match held.get(key) {
Some(held) => dropped(value, held, &here, out),
None if !empty(value) => out.push(here),
None => {}
}
}
}
(Value::Array(given), Value::Array(held)) => {
for (index, (value, held)) in given.iter().zip(held).enumerate() {
dropped(value, held, &format!("{path}[{index}]"), out);
}
}
_ => {}
}
}
/// A value whose loss changes nothing: the spellings of "not given".
fn empty(value: &Value) -> bool {
match value {
Value::Null => true,
Value::String(text) => text.is_empty(),
Value::Array(items) => items.is_empty(),
Value::Object(fields) => fields.is_empty(),
Value::Bool(_) | Value::Number(_) => false,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn paths(input: &Value, kept: &Value) -> Vec<String> {
let mut out = Vec::new();
dropped(input, kept, "d", &mut out);
out.sort();
out
}
#[test]
fn a_dropped_key_with_a_value_is_named_by_path() {
let input = json!({ "a": { "b": [ { "c": 1, "typo": "CANARY" } ] }, "top": 7 });
let kept = json!({ "a": { "b": [ { "c": 1 } ] } });
assert_eq!(paths(&input, &kept), vec!["d.a.b[0].typo".to_string(), "d.top".to_string()]);
}
#[test]
fn a_dropped_key_without_a_value_is_not_a_loss() {
let input = json!({ "n": null, "s": "", "l": [], "o": {}, "zero": 0, "no": false });
assert_eq!(paths(&input, &json!({})), vec!["d.no".to_string(), "d.zero".to_string()]);
}
#[test]
fn kinds_come_from_the_middle_segment() {
assert_eq!(kind_of("eureka-substrate:cut_spec:cut-x.r1").ok(), Some(PipelineKind::CutSpec));
assert!(kind_of("eureka-substrate:nonsense:x").is_err());
assert!(kind_of("two:segments").is_err());
assert!(kind_of("campaign").is_err());
}
}

View file

@ -0,0 +1,399 @@
//! `huginn`: the agents' door to Huginn's mind, as shell commands.
//!
//! A caller of `HuginnClient` and nothing else. It owns argument parsing, the
//! strict decode of agent-written JSON into the leaf's types (`door`), the
//! recipe selections (`recipes`), the text rendering (`render`), the exit code
//! and the read-back after an admission. It owns no state, writes no file and
//! reads no configuration but two environment variables: `EUREKA_INSTANCE`
//! names the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that
//! holds it. Each call is a new process; there is no retry inside it, the
//! caller's rule retries `Unavailable` once.
//!
//! Answers go to stdout and the exit code says what the answer was: 0 answered,
//! 1 the mind said no (a refusal, a conflict, an absent document). Troubles go
//! to stderr as `<Code>: <detail>`: 2 the call is wrong, 3 Unavailable, 4 no
//! answer and a retry cannot help, 5 the read-back after a committed admission
//! differs from what was sent.
mod config;
mod door;
mod recipes;
mod render;
mod schema;
mod trouble;
use std::io::Write;
use std::process::ExitCode;
use std::sync::OnceLock;
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use cultnet_rs::{PROJECTION_DOCUMENT, Selection};
use huginn_mind::eureka_pipeline::{Line, PipelineDocument, PipelineRef, Short, Slug, pipeline_key};
use huginn_mind::{
BATCH_MAX, Faculty, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome,
PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery,
};
use serde_json::Value;
use config::{Settings, TOOL};
use recipes::Recipe;
use trouble::Trouble;
/// The page size when a selection names none.
const DEFAULT_LIMIT: u32 = 50;
/// The neighbours `--near` asks for when `--top-k` is not given.
const DEFAULT_TOP_K: u32 = 10;
const ADMIT_HELP: &str = "\
The file is a JSON array of 1 to 64 documents, each {\"kind\": <kind>, \"value\": {...}}, or - for stdin; one document object alone is a batch of one. \
`huginn schema` lists the kinds and `huginn schema <kind>` prints a value's JSON Schema. \
Every key the schema does not know is refused (exit 2) with its path; nothing is sent. \
After Committed or AlreadyAdmitted the batch is read back and compared with what was sent: `read back <n> equal`, or exit 5 with the ids and field paths that differ.";
#[derive(Parser, Debug)]
#[command(name = "huginn", about = "Huginn's mind: whoami, view, query, recipes, admit, schema", arg_required_else_help = true)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Who this process acts for and whether the daemon answers.
Whoami,
/// Read one document by id, with its admission facts and status.
View {
/// The document's id: <root>:<kind>:<local>.
id: String,
/// Print only the {kind, value} JSON, which `admit` accepts back.
#[arg(long)]
raw: bool,
},
/// Query the mind with a CultNet selection.
Query {
/// The selection JSON, @<file>, or - for stdin.
selection: String,
/// Rank by nearness to this text instead of ordering by key.
#[arg(long)]
near: Option<String>,
/// How many neighbours (default 10).
#[arg(long, requires = "near")]
top_k: Option<u32>,
/// Print each item as `view` does.
#[arg(long)]
documents: bool,
},
#[command(flatten)]
Recipe(Recipe),
/// Admit a batch of documents, whole or not at all, and read it back.
#[command(after_help = ADMIT_HELP)]
Admit {
#[arg(long, value_parser = faculty)]
faculty: Faculty,
/// The admitting agent's name.
#[arg(long)]
agent: String,
/// The admitting session.
#[arg(long)]
session: String,
/// The documents file, or - for stdin.
file: String,
},
/// The JSON Schema of a document kind or of a selection; no name lists them.
Schema { name: Option<String> },
}
fn faculty(text: &str) -> Result<Faculty, String> {
serde_json::from_value(Value::String(text.to_string())).map_err(|error| error.to_string())
}
/// The version, then the sha the binary was built from.
fn version() -> &'static str {
static VERSION: OnceLock<String> = OnceLock::new();
VERSION.get_or_init(|| format!("{} ({})", env!("CARGO_PKG_VERSION"), build_sha()))
}
fn build_sha() -> &'static str {
option_env!("HUGINN_BUILD_SHA").unwrap_or("unknown")
}
/// The command line, or the exit code of having answered it (help, version) or
/// refused it (usage, as `InvalidInput`).
fn parse() -> Result<Cli, u8> {
let mut command = Cli::command().version(version());
for (name, text) in recipes::help() {
command = command.mut_subcommand(name, |recipe| recipe.after_help(text));
}
match command.try_get_matches() {
Ok(matches) => Cli::from_arg_matches(&matches).map_err(|error| usage(&error)),
Err(error) => Err(usage(&error)),
}
}
fn usage(error: &clap::Error) -> u8 {
if error.use_stderr() {
let text = error.to_string();
eprintln!("InvalidInput: {}", text.trim_start_matches("error: ").trim_end());
2
} else {
let _ = error.print();
0
}
}
fn main() -> ExitCode {
let cli = match parse() {
Ok(cli) => cli,
Err(code) => return ExitCode::from(code),
};
match run(&Settings::from_env(), cli.command) {
Ok(code) => ExitCode::from(code),
Err(trouble) => {
eprintln!("{trouble}");
ExitCode::from(trouble.exit())
}
}
}
fn say(text: impl std::fmt::Display) {
let _ = writeln!(std::io::stdout(), "{text}");
}
fn run(settings: &Settings, command: Command) -> Result<u8, Trouble> {
match command {
Command::Whoami => whoami(settings),
Command::View { id, raw } => view(settings, &id, raw),
Command::Query { selection, near, top_k, documents } => {
let selection = door::selection(&door::argument(&selection)?)?;
let semantic = near.map(|text| SemanticQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K) });
query(settings, selection, semantic, None, documents)
}
Command::Recipe(recipe) => {
let ask = recipe.ask()?;
query(settings, door::selection_value(&ask.selection)?, ask.semantic, ask.page.cursor, ask.page.documents)
}
Command::Admit { faculty, agent, session, file } => admit(settings, faculty, agent, session, &file),
Command::Schema { name } => {
let text = schema::text(name.as_deref())?;
let _ = write!(std::io::stdout(), "{text}");
Ok(0)
}
}
}
fn whoami(settings: &Settings) -> Result<u8, Trouble> {
say(format_args!("instance {}", settings.instance.as_deref().unwrap_or("unset")));
say(format_args!("endpoint {}", settings.endpoint.as_deref().unwrap_or("unset")));
say(format_args!("client {}", build_sha()));
match settings.ask(|_| HuginnMindRequest::Whoami) {
Ok(HuginnMindResponse::Whoami(status)) => {
say("reachable yes");
say(format_args!(
"documents {} receipts {} index {} epoch {}",
status.documents,
status.receipts,
render::index_state(&status.index),
status.schema_epoch
));
say(format_args!("persona {}", render::persona_state(&status.persona)));
Ok(0)
}
Ok(other) => {
say("reachable no");
Err(unexpected(&other))
}
Err(trouble) => {
say("reachable no");
Err(trouble)
}
}
}
fn view(settings: &Settings, id: &str, raw: bool) -> Result<u8, Trouble> {
let reference = PipelineRef { kind: door::kind_of(id)?, id: Short(id.to_string()) };
match settings.ask(|instance| HuginnMindRequest::View { instance: instance.clone(), id: reference })? {
HuginnMindResponse::View(Some(found)) => {
say(render::view(&found, raw));
Ok(0)
}
HuginnMindResponse::View(None) => {
say(format_args!("absent {id}"));
Ok(1)
}
HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)),
other => Err(unexpected(&other)),
}
}
fn query(
settings: &Settings,
mut selection: Selection,
semantic: Option<SemanticQuery>,
cursor: Option<String>,
documents: bool,
) -> Result<u8, Trouble> {
if documents {
selection.projection = PROJECTION_DOCUMENT.to_string();
}
if cursor.is_some() {
selection.cursor = cursor;
}
selection.limit.get_or_insert(DEFAULT_LIMIT);
match settings.ask(|instance| HuginnMindRequest::Query { instance: instance.clone(), selection, semantic })? {
HuginnMindResponse::Query(page) => {
show(&page);
Ok(0)
}
HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)),
other => Err(unexpected(&other)),
}
}
fn show(page: &PipelineSelectionPage) {
match &page.items {
PipelinePageItems::Headers(headers) => {
for header in headers {
say(render::header(header));
}
}
PipelinePageItems::Documents(views) => {
for (index, found) in views.iter().enumerate() {
if index > 0 {
say("");
}
say(render::view(found, false));
}
}
}
say(format_args!("matched {} as_of {} next {}", page.matched, page.as_of, page.next.as_deref().unwrap_or("-")));
}
fn admit(settings: &Settings, faculty: Faculty, agent: String, session: String, file: &str) -> Result<u8, Trouble> {
let documents = door::documents(&door::file_or_stdin(file)?)?;
if documents.is_empty() || documents.len() > BATCH_MAX {
return Err(Trouble::invalid(format!("a batch is 1 to {BATCH_MAX} documents, this one has {}", documents.len())));
}
let sent = documents.clone();
let provenance = PipelineProvenance { faculty, agent: Short(agent), session: Short(session), tool: Short(TOOL.to_string()) };
let request = |instance: &Slug| {
HuginnMindRequest::Admit(PipelineAdmissionBatch { instance: instance.clone(), provenance, documents })
};
match settings.ask(request)? {
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { receipt_id, committed_at, writes }) => {
say(format_args!("Committed {receipt_id} at {committed_at}"));
for write in &writes {
say(format_args!("wrote {}", write.id.0));
}
read_back(settings, &sent)
}
HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id }) => {
say(format_args!("AlreadyAdmitted {receipt_id}"));
read_back(settings, &sent)
}
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(refusal)) | HuginnMindResponse::Refused(refusal) => {
Ok(refused(&refusal))
}
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Conflict { identities }) => {
say("Conflict");
for identity in &identities {
say(format_args!("conflicts {}", identity.id.0));
}
Ok(1)
}
other => Err(unexpected(&other)),
}
}
/// The end-to-end check that the mind kept what was sent: one `keys` query,
/// each stored document compared with the one sent. A difference is exit 5,
/// not silence, whatever dropped it (an older leaf, a serde default, skew).
fn read_back(settings: &Settings, sent: &[PipelineDocument]) -> Result<u8, Trouble> {
let keys = sent
.iter()
.map(|document| pipeline_key(document).map_err(|refusal| Trouble::Internal(format!("a committed document has no key: {refusal:?}"))))
.collect::<Result<Vec<_>, _>>()?;
let selection = Selection {
keys: Some(keys.clone()),
projection: PROJECTION_DOCUMENT.to_string(),
limit: Some(u32::try_from(keys.len()).unwrap_or(u32::MAX)),
..Selection::default()
};
let page = match settings.ask(|instance| HuginnMindRequest::Query { instance: instance.clone(), selection, semantic: None })? {
HuginnMindResponse::Query(page) => page,
HuginnMindResponse::Refused(refusal) => return Ok(refused(&refusal)),
other => return Err(unexpected(&other)),
};
let PipelinePageItems::Documents(stored) = page.items else {
return Err(Trouble::Internal("the read-back answered headers, not documents".to_string()));
};
let mut differing = Vec::new();
for (key, document) in keys.iter().zip(sent) {
let held = stored.iter().find(|found| &found.id.id.0 == key);
match held {
None => differing.push(format!("{key}: missing from the read-back")),
Some(found) if found.document == *document => {}
Some(found) => {
let (mut paths, sent_value, held_value) = (Vec::new(), to_value(document), to_value(&found.document));
differences(&sent_value, &held_value, "", &mut paths);
let shown = if paths.is_empty() { "the document".to_string() } else { paths.join(", ") };
differing.push(format!("{key}: {shown}"));
}
}
}
if differing.is_empty() {
say(format_args!("read back {} equal", sent.len()));
Ok(0)
} else {
Err(Trouble::ReadBackDiffers(format!(
"{}; the batch is committed, so supersede the documents that differ",
differing.join("; ")
)))
}
}
fn to_value(document: &PipelineDocument) -> Value {
serde_json::to_value(document).unwrap_or(Value::Null)
}
/// The field paths at which two documents differ, including a field only one
/// of them carries.
fn differences(sent: &Value, held: &Value, path: &str, out: &mut Vec<String>) {
match (sent, held) {
(Value::Object(sent), Value::Object(held)) => {
for key in sent.keys().chain(held.keys().filter(|key| !sent.contains_key(*key))) {
let here = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match (sent.get(key), held.get(key)) {
(Some(sent), Some(held)) => differences(sent, held, &here, out),
_ => out.push(here),
}
}
}
(Value::Array(sent), Value::Array(held)) if sent.len() == held.len() => {
for (index, (sent, held)) in sent.iter().zip(held).enumerate() {
differences(sent, held, &format!("{path}[{index}]"), out);
}
}
_ if sent == held => {}
_ => out.push(path.to_string()),
}
}
/// A refusal is the mind's answer: stdout, exit 1.
fn refused(refusal: &MindRefusal) -> u8 {
say(format_args!("Refused {refusal:?}"));
1
}
fn unexpected(answer: &HuginnMindResponse) -> Trouble {
let name = match answer {
HuginnMindResponse::Whoami(_) => "whoami",
HuginnMindResponse::Admit(_) => "admit",
HuginnMindResponse::View(_) => "view",
HuginnMindResponse::Query(_) => "query",
HuginnMindResponse::Persona(_) => "a persona",
HuginnMindResponse::PersonaPut(_) => "a persona put",
HuginnMindResponse::Refused(_) => "a refusal",
HuginnMindResponse::Deferred(_) => "a deferral",
};
Trouble::Internal(format!("the daemon answered with {name}, which this call did not ask for"))
}

View file

@ -0,0 +1,287 @@
//! The campaign progress view as named subcommands. Each recipe is exactly the
//! selection `references/campaign-state.md` gives (Eureka 278b881, "Recipes"),
//! built per call and never cached. Each one's `--help` prints its selection.
use clap::{Args, Subcommand};
use huginn_mind::SemanticQuery;
use huginn_mind::eureka_pipeline::{Line, PipelineKind};
use serde_json::{Value, json};
use crate::door;
use crate::trouble::Trouble;
/// The campaign whose `gap-` follow-ups are the substrate's known gaps.
const GAP_HOME: &str = "eureka-substrate";
/// How many neighbours `precedent` asks the index for.
const PRECEDENT_TOP_K: u32 = 10;
/// Severities that block a cut.
const BLOCKING: [&str; 2] = ["Blocker", "High"];
#[derive(Args, Clone, Debug, Default)]
pub struct Paging {
/// The `next` cursor of the previous page.
#[arg(long)]
pub cursor: Option<String>,
/// Print each item as `view` does, not as a header line.
#[arg(long)]
pub documents: bool,
}
#[derive(Subcommand, Debug)]
pub enum Recipe {
/// Every campaign.
Campaigns(Paging),
/// The target in force.
Target {
campaign: String,
#[command(flatten)]
page: Paging,
},
/// Rulings in force, operator directions included.
Rulings {
#[arg(required = true)]
campaigns: Vec<String>,
#[command(flatten)]
page: Paging,
},
/// Open questions and follow-ups.
Open {
#[arg(required = true)]
campaigns: Vec<String>,
#[command(flatten)]
page: Paging,
},
/// Open findings admitted by Soul.
Findings {
campaign: String,
/// Only Blocker and High.
#[arg(long)]
blocking: bool,
#[command(flatten)]
page: Paging,
},
/// Specs with no report.
Unreported {
campaign: String,
#[command(flatten)]
page: Paging,
},
/// Open questions raised in a spec: the spec waits on the operator.
Blocked {
spec_id: String,
#[command(flatten)]
page: Paging,
},
/// Reports with no verdict: Soul's queue.
Unjudged {
campaign: String,
#[command(flatten)]
page: Paging,
},
/// A subject's resolutions, newest first.
History {
id: String,
#[command(flatten)]
page: Paging,
},
/// One cut's spec, reports, verdicts, findings and resolutions.
Cut {
campaign: String,
label: String,
#[command(flatten)]
page: Paging,
},
/// Every spec and report, whole.
Ledger {
campaign: String,
#[command(flatten)]
page: Paging,
},
/// Rulings, resolutions and findings nearest a question in plain words.
Precedent {
campaign: String,
text: String,
#[command(flatten)]
page: Paging,
},
/// The substrate's known gaps.
Gaps(Paging),
}
/// What a recipe asks the mind: the selection, the semantic part when it has
/// one, and the caller's paging flags.
pub struct Ask {
pub selection: Value,
pub semantic: Option<SemanticQuery>,
pub page: Paging,
}
impl Recipe {
pub fn ask(self) -> Result<Ask, Trouble> {
let (selection, semantic, page) = match self {
Self::Campaigns(page) => (campaigns(), None, page),
Self::Target { campaign, page } => (target(&campaign), None, page),
Self::Rulings { campaigns, page } => (rulings(&campaigns), None, page),
Self::Open { campaigns, page } => (open(&campaigns), None, page),
Self::Findings { campaign, blocking, page } => (findings(&campaign, blocking), None, page),
Self::Unreported { campaign, page } => (unreported(&campaign), None, page),
Self::Blocked { spec_id, page } => (blocked(&spec_id), None, page),
Self::Unjudged { campaign, page } => (unjudged(&campaign), None, page),
Self::History { id, page } => (history(door::kind_of(&id)?.type_id(), &id), None, page),
Self::Cut { campaign, label, page } => (cut(&campaign, &label), None, page),
Self::Ledger { campaign, page } => (ledger(&campaign), None, page),
Self::Precedent { campaign, text, page } => {
(precedent(&campaign), Some(SemanticQuery { text: Line(text), top_k: PRECEDENT_TOP_K }), page)
}
Self::Gaps(page) => (gaps(), None, page),
};
Ok(Ask { selection, semantic, page })
}
}
fn schema(kind: PipelineKind) -> &'static str {
kind.type_id()
}
fn root(campaigns: &[String]) -> Value {
json!({ "index": "root", "op": "any_of", "values": campaigns })
}
fn in_force() -> Value {
json!({ "index": "in_force", "op": "any_of", "values": ["true"] })
}
fn severity_blocking() -> Value {
json!({ "index": "severity", "op": "any_of", "values": BLOCKING })
}
fn campaigns() -> Value {
json!({ "schemas": [schema(PipelineKind::Campaign)] })
}
fn target(campaign: &str) -> Value {
json!({
"schemas": [schema(PipelineKind::Target)],
"fields": [root(&[campaign.to_string()]), in_force()],
"projection": "document",
})
}
fn rulings(campaigns: &[String]) -> Value {
json!({ "schemas": [schema(PipelineKind::Ruling)], "fields": [root(campaigns), in_force()] })
}
fn open(campaigns: &[String]) -> Value {
json!({
"schemas": [schema(PipelineKind::Question), schema(PipelineKind::FollowUp)],
"fields": [root(campaigns), in_force()],
})
}
fn findings(campaign: &str, blocking: bool) -> Value {
let mut fields = vec![
root(&[campaign.to_string()]),
in_force(),
json!({ "index": "faculty", "op": "any_of", "values": ["Soul"] }),
];
if blocking {
fields.push(severity_blocking());
}
json!({ "schemas": [schema(PipelineKind::Finding)], "fields": fields })
}
fn unreported(campaign: &str) -> Value {
json!({
"schemas": [schema(PipelineKind::CutSpec)],
"fields": [root(&[campaign.to_string()]), in_force()],
"cited": { "role": "cut_spec", "exists": false },
})
}
fn blocked(spec_id: &str) -> Value {
json!({
"schemas": [schema(PipelineKind::Question)],
"fields": [in_force()],
"cites": { "target": { "schemaId": schema(PipelineKind::CutSpec), "recordKey": spec_id }, "role": "raised_in" },
})
}
fn unjudged(campaign: &str) -> Value {
json!({
"schemas": [schema(PipelineKind::CutReport)],
"fields": [root(&[campaign.to_string()])],
"cited": { "role": "cut_report", "exists": false },
})
}
fn history(schema_id: &str, id: &str) -> Value {
json!({
"schemas": [schema(PipelineKind::Resolution)],
"cites": { "target": { "schemaId": schema_id, "recordKey": id }, "role": "subject" },
"descending": true,
})
}
fn cut(campaign: &str, label: &str) -> Value {
json!({
"schemas": [
schema(PipelineKind::CutSpec),
schema(PipelineKind::CutReport),
schema(PipelineKind::Verdict),
schema(PipelineKind::Finding),
schema(PipelineKind::Resolution),
],
"fields": [root(&[campaign.to_string()]), { "index": "cut", "op": "any_of", "values": [label] }],
})
}
fn ledger(campaign: &str) -> Value {
json!({
"schemas": [schema(PipelineKind::CutSpec), schema(PipelineKind::CutReport)],
"fields": [root(&[campaign.to_string()])],
"projection": "document",
})
}
fn precedent(campaign: &str) -> Value {
json!({
"schemas": [schema(PipelineKind::Ruling), schema(PipelineKind::Resolution), schema(PipelineKind::Finding)],
"fields": [root(&[campaign.to_string()])],
})
}
fn gaps() -> Value {
json!({
"schemas": [schema(PipelineKind::FollowUp)],
"fields": [root(&[GAP_HOME.to_string()]), in_force()],
})
}
/// Each recipe's `--help` addendum: its selection with placeholders in the
/// arguments' places. The same builders as execution, so help cannot drift
/// from what runs.
pub fn help() -> Vec<(&'static str, String)> {
let c = || "<c>".to_string();
let selection = |value: Value| format!("selection:\n{}", serde_json::to_string_pretty(&value).unwrap_or_default());
let findings_help =
format!("{}\n--blocking adds the field predicate:\n{}", selection(findings(&c(), false)), severity_blocking());
let precedent_help = format!(
"{}\nsemantic: {{ \"text\": \"<text>\", \"top_k\": {PRECEDENT_TOP_K} }}",
selection(precedent(&c()))
);
vec![
("campaigns", selection(campaigns())),
("target", selection(target(&c()))),
("rulings", selection(rulings(&[c()]))),
("open", selection(open(&[c()]))),
("findings", findings_help),
("unreported", selection(unreported(&c()))),
("blocked", selection(blocked("<id>"))),
("unjudged", selection(unjudged(&c()))),
("history", selection(history("epiphany.pipeline.<kind>.v2", "<id>"))),
("cut", selection(cut(&c(), "<label>"))),
("ledger", selection(ledger(&c()))),
("precedent", precedent_help),
("gaps", selection(gaps())),
]
}

View file

@ -0,0 +1,137 @@
//! Text for stdout. Display only: nothing here decides an answer.
use std::fmt::Write;
use huginn_mind::{IndexStatus, PersonaStatus, PipelineDocumentSummary, PipelineDocumentView, PipelineStatus, PipelineStatusSummary};
use serde::Serialize;
use serde_json::Value;
/// A header line is cut here, so a page of them stays one screen per dozen.
const HEADER_MAX_CHARS: usize = 240;
/// The variant or string a serde-tagged value is spelled as: `Hands` for a
/// faculty, `Fixed` for an outcome.
pub fn name_of<T: Serialize>(value: &T) -> String {
match serde_json::to_value(value) {
Ok(Value::String(name)) => name,
Ok(Value::Object(fields)) => fields.keys().next().cloned().unwrap_or_default(),
_ => String::new(),
}
}
/// The persona door's state as one line: no value of the document, only the
/// vocabulary the door answers in.
pub fn persona_state(persona: &PersonaStatus) -> String {
match persona {
PersonaStatus::Absent => "absent".into(),
PersonaStatus::Stored { updated_at } => format!("stored updatedAt={updated_at}"),
PersonaStatus::Unreadable => "unreadable".into(),
}
}
/// An index status as one token and its numbers: `Behind pending=3`.
pub fn index_state(index: &IndexStatus) -> String {
let mut text = name_of(index);
if let Ok(Value::Object(variant)) = serde_json::to_value(index)
&& let Some(Value::Object(fields)) = variant.values().next()
{
for (key, value) in fields {
if let Some(shown) = scalar(value) {
let _ = write!(text, " {key}={shown}");
}
}
}
text
}
/// One document as `view` prints it: id and status, the admission facts, then
/// the `{kind, value}` document as pretty JSON. `raw` is the JSON alone.
pub fn view(view: &PipelineDocumentView, raw: bool) -> String {
let document = serde_json::to_string_pretty(&view.document).unwrap_or_default();
if raw {
return document;
}
let id = &view.id.id.0;
let status = match &view.status {
PipelineStatus::InForce => "InForce".to_string(),
PipelineStatus::Resolved { resolution, record } => format!("Resolved {} by {}", name_of(&record.outcome), resolution.id.0),
};
let admission = &view.admission;
let who = &admission.provenance;
format!(
"{id} {status}\nadmitted {} ordinal {} {}/{} session {} tool {} receipt {}\n{document}",
admission.admitted_at,
admission.ordinal,
name_of(&who.faculty),
who.agent.0,
who.session.0,
who.tool.0,
admission.receipt_id,
)
}
/// One header as a line: `<id> <status> <faculty> <title or label> k=v...`
/// over the header's scalar facts.
pub fn header(item: &PipelineDocumentSummary) -> String {
let status = match &item.status {
PipelineStatusSummary::InForce => "InForce".to_string(),
PipelineStatusSummary::Resolved { outcome, .. } => format!("Resolved:{}", name_of(outcome)),
};
let mut line = format!("{} {status} {}", item.id.id.0, name_of(&item.admission.provenance.faculty));
if let Ok(facts) = serde_json::to_value(&item.facts)
&& let Some(Value::Object(facts)) = facts.get("value")
{
if let Some(Value::String(name)) = facts.get("title").or_else(|| facts.get("label")) {
let _ = write!(line, " {name}");
}
for (key, value) in facts {
if key != "title"
&& key != "label"
&& let Some(shown) = scalar(value)
{
let _ = write!(line, " {key}={shown}");
}
}
}
line.chars().take(HEADER_MAX_CHARS).collect()
}
/// A fact as one token: a string, a number, a flag, a reference's id, or a
/// list of those joined by commas. Anything deeper is not a header fact.
fn scalar(value: &Value) -> Option<String> {
match value {
Value::String(text) if !text.is_empty() => Some(text.clone()),
Value::Number(number) => Some(number.to_string()),
Value::Bool(flag) => Some(flag.to_string()),
Value::Array(items) => {
let shown: Vec<String> = items.iter().filter_map(scalar).collect();
if shown.is_empty() { None } else { Some(shown.join(",")) }
}
Value::Object(fields) => match fields.get("id") {
Some(Value::String(id)) => Some(id.clone()),
_ => None,
},
Value::String(_) | Value::Null => None,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn a_fact_is_one_token_or_nothing() {
assert_eq!(scalar(&json!("")), None);
assert_eq!(scalar(&json!("a b")), Some("a b".to_string()));
assert_eq!(scalar(&json!(3)), Some("3".to_string()));
assert_eq!(scalar(&json!(true)), Some("true".to_string()));
assert_eq!(scalar(&json!(["a", "", "b"])), Some("a,b".to_string()));
assert_eq!(scalar(&json!([])), None);
assert_eq!(scalar(&json!(null)), None);
assert_eq!(scalar(&json!({ "kind": "Ruling", "id": "c:ruling:R1" })), Some("c:ruling:R1".to_string()));
assert_eq!(scalar(&json!({ "id": 3 })), None);
assert_eq!(scalar(&json!({ "Withdrawn": { "reason": "moot" } })), None);
}
}

View file

@ -0,0 +1,52 @@
//! `huginn schema`: the published schema of every document kind and of a
//! selection, compiled in, so the schema an agent reads is the schema of the
//! binary it runs.
use crate::trouble::Trouble;
/// CultLib's published shape of a selection, vendored because the selection
/// type carries no schema of its own.
const SELECTION: &str = include_str!("../../../schemas/cultnet.selection.schema.json");
macro_rules! kinds {
($($name:ident),* $(,)?) => {
/// Each kind's file in `schemas/cultnet`, named by `PipelineKind::name`.
const KINDS: &[(&str, &str)] = &[$((
stringify!($name),
include_str!(concat!("../../../../../schemas/cultnet/epiphany.pipeline.", stringify!($name), ".v2.schema.json")),
)),*];
};
}
kinds!(
campaign, target, question, ruling, cut_spec, cut_report, verdict, finding, follow_up, resolution, instance, stewardship,
hand_off, run,
);
/// The schema named, or the names when none is: the exit code is 2 for a name
/// that is neither a kind nor `selection`.
pub fn text(name: Option<&str>) -> Result<String, Trouble> {
match name {
None => Ok(KINDS.iter().map(|(kind, _)| *kind).chain(["selection"]).map(|kind| format!("{kind}\n")).collect()),
Some("selection") => Ok(SELECTION.to_string()),
Some(name) => KINDS
.iter()
.find(|(kind, _)| *kind == name)
.map(|(_, schema)| (*schema).to_string())
.ok_or_else(|| Trouble::invalid("no such schema; `huginn schema` lists the names")),
}
}
#[cfg(test)]
mod tests {
use huginn_mind::eureka_pipeline::PipelineKind;
use super::*;
#[test]
fn every_kind_has_exactly_its_schema() {
let names: Vec<&str> = KINDS.iter().map(|(kind, _)| *kind).collect();
let kinds: Vec<&str> = PipelineKind::ALL.iter().map(|kind| kind.name()).collect();
assert_eq!(names, kinds);
}
}

View file

@ -0,0 +1,79 @@
//! Why a call produced no answer, and the exit code each reason owns.
//!
//! Exit codes: 2 the call is wrong (usage, InvalidInput, TooLarge,
//! Misconfigured); 3 Unavailable (retry once); 4 no answer and a retry cannot
//! help (Rejected, Unencodable, Internal); 5 ReadBackDiffers. Exit 0 and 1 are
//! answers (the mind said yes, the mind said no) and are not troubles.
use std::fmt;
use eureka_state::ClientError;
#[derive(Debug)]
pub enum Trouble {
Misconfigured(String),
InvalidInput(String),
TooLarge { bytes: usize, limit: usize },
Unavailable(String),
Rejected(String),
Unencodable(String),
Internal(String),
ReadBackDiffers(String),
}
impl Trouble {
pub fn code(&self) -> &'static str {
match self {
Self::Misconfigured(_) => "Misconfigured",
Self::InvalidInput(_) => "InvalidInput",
Self::TooLarge { .. } => "TooLarge",
Self::Unavailable(_) => "Unavailable",
Self::Rejected(_) => "Rejected",
Self::Unencodable(_) => "Unencodable",
Self::Internal(_) => "Internal",
Self::ReadBackDiffers(_) => "ReadBackDiffers",
}
}
pub fn exit(&self) -> u8 {
match self {
Self::Misconfigured(_) | Self::InvalidInput(_) | Self::TooLarge { .. } => 2,
Self::Unavailable(_) => 3,
Self::Rejected(_) | Self::Unencodable(_) | Self::Internal(_) => 4,
Self::ReadBackDiffers(_) => 5,
}
}
pub fn invalid(detail: impl Into<String>) -> Self {
Self::InvalidInput(detail.into())
}
}
impl fmt::Display for Trouble {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: ", self.code())?;
match self {
Self::Misconfigured(detail)
| Self::InvalidInput(detail)
| Self::Unavailable(detail)
| Self::Rejected(detail)
| Self::Unencodable(detail)
| Self::Internal(detail)
| Self::ReadBackDiffers(detail) => f.write_str(detail),
Self::TooLarge { bytes, limit } => {
write!(f, "the request encodes to {bytes} bytes and one send carries at most {limit}")
}
}
}
}
impl From<ClientError> for Trouble {
fn from(error: ClientError) -> Self {
match error {
ClientError::Unavailable { endpoint, detail } => Self::Unavailable(format!("rudp://{endpoint}: {detail}")),
ClientError::Rejected { endpoint, code, detail } => Self::Rejected(format!("rudp://{endpoint}: {code}: {detail}")),
ClientError::TooLarge { bytes, limit } => Self::TooLarge { bytes, limit },
ClientError::Unencodable { detail } => Self::Unencodable(detail),
}
}
}

View file

@ -27,7 +27,8 @@ use cultnet_rs::{
};
use huginn_mind::envelope::{OperationFailure, decode_response, encode_request};
use huginn_mind::eureka_pipeline::Slug;
use huginn_mind::{HuginnMindRequest, HuginnMindResponse, MAX_DEFERRED_BODY_BYTES};
use huginn_mind::{HuginnMindRequest, HuginnMindResponse, MAX_DEFERRED_BODY_BYTES, PipelineProvenance};
use serde_json::Value;
/// The correlation key of the one request a session carries.
const MESSAGE_ID: &str = "eureka-state-call";
@ -108,6 +109,28 @@ impl HuginnClient {
&self.instance
}
/// The mind's persona document: `Persona(None)` until the first put.
pub fn persona_get(&self) -> Result<HuginnMindResponse, ClientError> {
self.call(HuginnMindRequest::PersonaGet { instance: self.instance.clone() })
}
/// Replaces the mind's persona document whole. `expected_updated_at` is
/// the stored document's `updatedAt`, none for the first put; a stale one
/// comes back as `PersonaPut(Refused(PersonaStale { .. }))`, an answer.
pub fn persona_put(
&self,
provenance: PipelineProvenance,
state: Value,
expected_updated_at: Option<String>,
) -> Result<HuginnMindResponse, ClientError> {
self.call(HuginnMindRequest::PersonaPut {
instance: self.instance.clone(),
provenance,
state,
expected_updated_at,
})
}
pub fn call(&self, request: HuginnMindRequest) -> Result<HuginnMindResponse, ClientError> {
let deadline = Instant::now() + self.timeout;
let unavailable =

View file

@ -1,7 +1,7 @@
//! `eureka-state`: Huginn's mind as four MCP tools over stdio.
//! `eureka-state`: Huginn's mind as six MCP tools over stdio.
//!
//! One tool per wire operation (`whoami`, `admit`, `view`, `query`), each a
//! `HuginnClient` call and nothing else. The declared identity is this
//! One tool per wire operation (`whoami`, `admit`, `view`, `query`,
//! `persona_get`, `persona_put`), each a `HuginnClient` call and nothing else. The declared identity is this
//! process's configuration, not a per-call argument: `EUREKA_INSTANCE` names
//! the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that holds
//! it. The server fills in `instance` and `provenance.tool`, so a caller can
@ -73,6 +73,21 @@ struct AdmitInput {
documents: Vec<PipelineDocument>,
}
#[derive(Deserialize, JsonSchema)]
struct PersonaPutInput {
/// The faculty the put is attributed to. Attribution, not authority.
faculty: Faculty,
/// The putting agent's name.
agent: Short,
/// The putting session, recorded on the receipt.
session: Short,
/// The whole `gamecult.persona_state.v0` document. It replaces the stored
/// one; there is no partial write.
state: Value,
/// The stored document's `updatedAt`; omit it for the first put.
expected_updated_at: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct ViewInput {
/// The document's id, as a query or an admission returned it.
@ -166,17 +181,32 @@ impl EurekaState {
/// One wire call for the configured instance. The request is built from
/// that instance, never from anything the caller sent.
async fn call(&self, request: impl FnOnce(Slug) -> HuginnMindRequest) -> Result<HuginnMindResponse, Trouble> {
async fn call(
&self,
request: impl FnOnce(Slug) -> HuginnMindRequest + Send + 'static,
) -> Result<HuginnMindResponse, Trouble> {
self.ask(move |client| client.call(request(client.instance().clone()))).await
}
/// One `HuginnClient` call for the configured instance, off the async
/// thread.
async fn ask(
&self,
call: impl FnOnce(&HuginnClient) -> Result<HuginnMindResponse, ClientError> + Send + 'static,
) -> Result<HuginnMindResponse, Trouble> {
let client = self.settings.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))?.clone();
let request = request(client.instance().clone());
tokio::task::spawn_blocking(move || client.call(request))
tokio::task::spawn_blocking(move || call(&client))
.await
.map_err(|error| Trouble::Internal(error.to_string()))?
.map_err(Trouble::Client)
}
async fn answer(&self, request: impl FnOnce(Slug) -> HuginnMindRequest) -> CallToolResult {
match self.call(request).await {
async fn answer(&self, request: impl FnOnce(Slug) -> HuginnMindRequest + Send + 'static) -> CallToolResult {
Self::replied(self.call(request).await)
}
fn replied(result: Result<HuginnMindResponse, Trouble>) -> CallToolResult {
match result {
Ok(response) => reply(payload(response), false),
Err(trouble) => reply(trouble.body(), true),
}
@ -191,6 +221,8 @@ fn payload(response: HuginnMindResponse) -> Value {
HuginnMindResponse::Admit(outcome) => serde_json::to_value(outcome),
HuginnMindResponse::View(view) => serde_json::to_value(view),
HuginnMindResponse::Query(page) => serde_json::to_value(page),
HuginnMindResponse::Persona(view) => serde_json::to_value(view),
HuginnMindResponse::PersonaPut(outcome) => serde_json::to_value(outcome),
other => serde_json::to_value(other),
};
value.expect("wire types serialize")
@ -228,7 +260,7 @@ impl EurekaState {
description = "Admit one batch of pipeline documents into the mind, whole or not at all. Returns the outcome: committed with a receipt, already admitted, conflict, or a typed refusal. The instance is this server's configuration; do not pass one."
)]
async fn admit(&self, Parameters(input): Parameters<AdmitInput>) -> CallToolResult {
self.answer(|instance| {
self.answer(move |instance| {
HuginnMindRequest::Admit(PipelineAdmissionBatch {
instance,
provenance: PipelineProvenance {
@ -245,20 +277,42 @@ impl EurekaState {
#[tool(description = "Read one document by id, with its admission facts and status. null when the mind has no such document.")]
async fn view(&self, Parameters(input): Parameters<ViewInput>) -> CallToolResult {
self.answer(|instance| HuginnMindRequest::View { instance, id: input.id }).await
self.answer(move |instance| HuginnMindRequest::View { instance, id: input.id }).await
}
#[tool(
description = "Query the mind with a CultNet typed selection, optionally ranked by semantic nearness. Returns one page; pass its cursor back in the selection for the next."
)]
async fn query(&self, Parameters(input): Parameters<QueryInput>) -> CallToolResult {
self.answer(|instance| HuginnMindRequest::Query {
self.answer(move |instance| HuginnMindRequest::Query {
instance,
selection: input.selection,
semantic: input.semantic,
})
.await
}
#[tool(
description = "The mind's persona document (gamecult.persona_state.v0) with its updatedAt and the receipt of the put that wrote it. null until the first put."
)]
async fn persona_get(&self) -> CallToolResult {
Self::replied(self.ask(HuginnClient::persona_get).await)
}
#[tool(
description = "Replace the mind's persona document whole. The document must pass the published schema, name this instance as personaId, carry provenance.authority canonical, and have an updatedAt later than the stored one; expected_updated_at must be the stored updatedAt (omit it for the first put). A refusal is an answer; a stale put names the stored updatedAt to retry against."
)]
async fn persona_put(&self, Parameters(input): Parameters<PersonaPutInput>) -> CallToolResult {
let provenance = PipelineProvenance {
faculty: input.faculty,
agent: input.agent,
session: input.session,
tool: Short(TOOL.into()),
};
Self::replied(
self.ask(move |client| client.persona_put(provenance, input.state, input.expected_updated_at)).await,
)
}
}
/// Every tool error is a typed JSON body. The ones this server writes already
@ -289,7 +343,7 @@ impl ServerHandler for EurekaState {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
"Huginn's typed mind for this instance: whoami, admit, view, query. Refusals are answers; isError means the daemon could not be reached or rejected the request.",
"Huginn's typed mind for this instance: whoami, admit, view, query, persona_get, persona_put. Refusals are answers; isError means the daemon could not be reached or rejected the request.",
)
}
}

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,9 @@ use huginn_mind::eureka_pipeline::{
PipelineCutSpec, PipelineDocument, PipelineKind, PipelineRef, Short, StructuralDelta, Title,
VerificationTest,
};
use huginn_mind::{DeferredAnswer, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionOutcome};
use huginn_mind::{
DeferredAnswer, HuginnMindRequest, HuginnMindResponse, MindRefusal, PersonaPutOutcome, PipelineAdmissionOutcome,
};
mod common;
use common::*;
@ -195,6 +197,50 @@ fn every_operation_round_trips_against_a_live_daemon() {
assert_eq!(view.id, campaign_ref);
}
/// The persona methods against a live daemon over the socket: put then get
/// returns the value and the receipt, and a stale put or another mind's name is
/// an answer, not an error.
#[test]
fn client_persona_round_trip_against_a_live_daemon() {
const T1: &str = "2026-10-10T10:00:00Z";
const T2: &str = "2026-10-10T10:00:01Z";
let (root, daemon) = mind(vec![]);
let server = serve(root, daemon);
let client = client(server.addr);
assert_eq!(client.persona_get().unwrap(), HuginnMindResponse::Persona(None));
let HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id }) =
client.persona_put(provenance(), persona(INSTANCE, T1), None).unwrap()
else {
panic!("the first put commits");
};
let HuginnMindResponse::Persona(Some(view)) = client.persona_get().unwrap() else { panic!("the persona is stored") };
assert_eq!(view.value, persona(INSTANCE, T1));
assert_eq!((view.updated_at.as_str(), view.receipt_id.as_str()), (T1, receipt_id.as_str()));
let HuginnMindResponse::Whoami(status) = client.call(HuginnMindRequest::Whoami).unwrap() else { panic!("a status") };
assert_eq!(status.persona, huginn_mind::wire::PersonaStatus::Stored { updated_at: T1.into() });
// A stale put is an answer.
assert_eq!(
client.persona_put(provenance(), persona(INSTANCE, T2), None).unwrap(),
HuginnMindResponse::PersonaPut(PersonaPutOutcome::Refused(MindRefusal::PersonaStale { stored: Some(T1.into()) }))
);
let HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { .. }) =
client.persona_put(provenance(), persona(INSTANCE, T2), Some(T1.into())).unwrap()
else {
panic!("a put that read the stored document commits");
};
// Another mind's name is an answer too.
let other = HuginnClient::new(server.addr, slug(OTHER), TIMEOUT);
let foreign = MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() };
assert_eq!(other.persona_get().unwrap(), HuginnMindResponse::Refused(foreign.clone()));
assert_eq!(
other.persona_put(provenance(), persona(INSTANCE, "2026-10-10T10:00:02Z"), Some(T2.into())).unwrap(),
HuginnMindResponse::Refused(foreign)
);
}
/// A refusal is data. A read that names another mind, a write that declares
/// another mind, and a document the mind does not hold each come back as `Ok`.
#[test]

View file

@ -21,6 +21,7 @@ use huginn_mind::{
Faculty, HuginnMindRequest, HuginnMindResponse, IndexStatus, Mind, MindStore, OwnedRedbMessagePackBackingStore,
PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineProvenance,
};
use serde_json::{Value, json};
use tempfile::TempDir;
pub const INSTANCE: &str = "eureka";
@ -38,19 +39,46 @@ pub fn identity(name: &str) -> PipelineDocument {
})
}
pub fn batch(instance: &str, documents: Vec<PipelineDocument>) -> PipelineAdmissionBatch {
PipelineAdmissionBatch {
instance: slug(instance),
provenance: PipelineProvenance {
faculty: Faculty::Hands,
agent: Short("claude".into()),
session: Short("session-1".into()),
tool: Short("admit".into()),
},
documents,
pub fn provenance() -> PipelineProvenance {
PipelineProvenance {
faculty: Faculty::Hands,
agent: Short("claude".into()),
session: Short("session-1".into()),
tool: Short("admit".into()),
}
}
pub fn batch(instance: &str, documents: Vec<PipelineDocument>) -> PipelineAdmissionBatch {
PipelineAdmissionBatch { instance: slug(instance), provenance: provenance(), documents }
}
/// A minimal `gamecult.persona_state.v0` document: the published schema's
/// required fields and nothing else.
pub fn persona(id: &str, updated_at: &str) -> Value {
json!({
"schemaVersion": "gamecult.persona_state.v0",
"provenance": {
"sourceSystem": "huginn", "sourceDocumentId": id, "sourceUpdatedAt": updated_at,
"exportedAt": updated_at, "authority": "canonical"
},
"personaId": id,
"publicName": "Test Persona",
"presentation": { "voiceSummary": "plain" },
"activationProfile": {
"underlyingOrganization": {}, "stableDispositions": {}, "behavioralDimensions": {},
"presentationStrategy": {}, "voiceStyle": {}, "situationalState": {}
},
"thoughtMemory": { "shortTerm": [], "memories": [], "incubation": [] },
"agencyPressure": { "pressures": [] },
"candidateActions": { "actions": [] },
"affect": {
"needs": [], "socialBonds": [], "statusReads": [], "moodDimensions": [],
"socialBiases": [], "doctrineStances": []
},
"updatedAt": updated_at
})
}
/// No index: the client's subject is the transport, not the projection.
pub struct Inert;

View file

@ -148,15 +148,15 @@ fn keys_below_root(schema: &Value, key: &str) -> usize {
schema.as_object().unwrap().values().map(|inner| count(inner, key)).sum()
}
/// Four tools, each input schema a self-contained document: every `$ref` is
/// Six tools, each input schema a self-contained document: every `$ref` is
/// internal and resolves, and the selection's definitions are bundled in.
#[test]
fn tools_list_offers_four_tools_with_self_contained_schemas() {
fn tools_list_offers_six_tools_with_self_contained_schemas() {
let mut mcp = Mcp::at(INSTANCE, closed_port());
let listed = mcp.rpc("tools/list", json!({}));
let tools = listed["result"]["tools"].as_array().expect("tools");
let names: BTreeSet<&str> = tools.iter().map(|tool| tool["name"].as_str().unwrap()).collect();
assert_eq!(names, BTreeSet::from(["whoami", "admit", "view", "query"]));
assert_eq!(names, BTreeSet::from(["whoami", "admit", "view", "query", "persona_get", "persona_put"]));
for tool in tools {
let schema = &tool["inputSchema"];
let mut found = Vec::new();
@ -248,6 +248,49 @@ fn every_tool_round_trips_against_a_live_daemon() {
assert_eq!(counted["status"]["receipts"], json!(3));
}
/// The persona tools against a live daemon: the document, the stale answer,
/// and `whoami` showing the stored `updatedAt`. The server fills in the
/// instance and the tool, so the asker names neither.
#[test]
fn the_persona_tools_round_trip_against_a_live_daemon() {
const T1: &str = "2026-10-10T10:00:00Z";
const T2: &str = "2026-10-10T10:00:01Z";
let (root, daemon) = mind(vec![]);
let server = serve(root, daemon);
let mut mcp = Mcp::at(INSTANCE, server.addr);
let put = |state: Value, expected: Option<&str>| {
let mut arguments = json!({ "faculty": "SelfFaculty", "agent": "claude", "session": "session-7", "state": state });
if let Some(expected) = expected {
arguments["expected_updated_at"] = json!(expected);
}
arguments
};
let (error, nothing) = mcp.call("persona_get", json!({}));
assert!(!error);
assert_eq!(nothing, Value::Null);
let (_, whoami) = mcp.call("whoami", json!({}));
assert_eq!(whoami["status"]["persona"], json!("Absent"));
let (error, committed) = mcp.call("persona_put", put(persona(INSTANCE, T1), None));
assert!(!error);
let receipt = committed["Committed"]["receipt_id"].as_str().expect("committed").to_string();
let (error, view) = mcp.call("persona_get", json!({}));
assert!(!error);
assert_eq!(view["value"], persona(INSTANCE, T1));
assert_eq!((view["updated_at"].as_str(), view["receipt_id"].as_str()), (Some(T1), Some(receipt.as_str())));
let (_, whoami) = mcp.call("whoami", json!({}));
assert_eq!(whoami["status"]["persona"], json!({ "Stored": { "updated_at": T1 } }));
let (error, stale) = mcp.call("persona_put", put(persona(INSTANCE, T2), None));
assert!(!error, "a stale put is an answer");
assert_eq!(stale["Refused"]["PersonaStale"]["stored"], json!(T1));
let (_, page) = mcp.call("query", json!({ "selection": { "schemas": ["epiphany.pipeline.instance.v2"] } }));
assert_eq!(page["matched"], json!(1), "the persona is not a pipeline document");
}
/// Declared identity is configuration, not a per-call argument. Input that
/// names another instance, or another tool, changes neither: the batch is
/// admitted for the configured instance, and the receipt says `eureka-state`.
@ -367,6 +410,8 @@ fn bad_configuration_is_served_and_reported() {
("view", json!({ "id": absent() })),
("query", json!({ "selection": {} })),
("admit", faculty_args(vec![])),
("persona_get", json!({})),
("persona_put", json!({ "faculty": "Hands", "agent": "claude", "session": "s", "state": {} })),
] {
let (error, refusal) = mcp.call(tool, arguments);
assert!(error, "{tool}: {expected}");

View file

@ -153,6 +153,13 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
Ok(view) => HuginnMindResponse::View(view.map(Box::new)),
Err(refusal) => HuginnMindResponse::Refused(refusal),
},
HuginnMindRequest::PersonaGet { .. } => match self.mind.persona() {
Ok(view) => HuginnMindResponse::Persona(view),
Err(refusal) => HuginnMindResponse::Refused(refusal),
},
HuginnMindRequest::PersonaPut { instance, provenance, state, expected_updated_at } => {
HuginnMindResponse::PersonaPut(self.mind.put_persona(&instance, state, expected_updated_at, provenance, now))
}
HuginnMindRequest::Query { selection, semantic: None, .. } => match self.mind.query(&selection) {
Ok(page) => HuginnMindResponse::Query(page),
Err(refusal) => HuginnMindResponse::Refused(refusal),
@ -583,6 +590,7 @@ pub(crate) mod tests {
documents: 1,
receipts: 1,
index: IndexStatus::Current,
persona: huginn_mind::wire::PersonaStatus::Absent,
}
);
assert_eq!(daemon.runtime_id(), "huginn-yggdrasil");
@ -674,6 +682,31 @@ pub(crate) mod tests {
assert_eq!(status(&mut daemon).documents, 1, "nothing landed");
}
/// The persona operations name an instance like every other read: another
/// mind's name is refused before the mind is asked, and a mind with no
/// persona answers `Persona(None)`.
#[test]
fn the_persona_operations_name_this_mind_and_an_empty_mind_has_none() {
let (_root, mut daemon) = seeded();
assert_eq!(
daemon.handle(HuginnMindRequest::PersonaGet { instance: slug(INSTANCE) }, now()).answered(),
HuginnMindResponse::Persona(None)
);
let foreign = MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() };
assert_eq!(
daemon.handle(HuginnMindRequest::PersonaGet { instance: slug(OTHER) }, now()).answered(),
HuginnMindResponse::Refused(foreign.clone())
);
let put = HuginnMindRequest::PersonaPut {
instance: slug(OTHER),
provenance: provenance(),
state: serde_json::Value::Null,
expected_updated_at: None,
};
assert_eq!(daemon.handle(put, now()).answered(), HuginnMindResponse::Refused(foreign));
assert_eq!(status(&mut daemon).persona, huginn_mind::wire::PersonaStatus::Absent);
}
/// A declared instance outside `Slug`'s grammar is refused by its own
/// name, `InvalidFormat`, on the read path, before the identity
/// comparison ever runs: it is not merely a foreign mind, it names no

View file

@ -21,13 +21,15 @@ chrono = "0.4.44"
cultcache-rs.workspace = true
cultnet-rs.workspace = true
eureka-pipeline = { path = "../eureka-pipeline" }
jsonschema = { version = "0.58.6", default-features = false }
rmp-serde = "1"
schemars = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_path_to_error = "0.1"
sha2 = "0.10"
[dev-dependencies]
serde_json = "1"
tempfile = "3"
# `test-support` exposes `Mind::store_path_for` and `Mind::open_with`, a

View file

@ -37,9 +37,11 @@ use eureka_pipeline::{
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::docs::{CitationRole, Docs, Staged, citations, kind_of_id, outcome_citations};
use crate::mind::{HuginnMindEpoch, Mind, unavailable};
use crate::persona::{self, HuginnPersonaEntry, PersonaPutOutcome};
use crate::receipt::{self, CommitOutcome, PipelineProvenance};
use crate::refusal::{MindRefusal, RunId};
use crate::store::MindStore;
@ -178,6 +180,86 @@ impl<S: MindStore> Mind<S> {
CommitOutcome::Conflict(identities) => Ok(PipelineAdmissionOutcome::Conflict { identities }),
}
}
/// Replaces the mind's persona document whole, or refuses. One door for
/// the one document a mind holds, outside every pipeline batch: the same
/// instance and provenance checks as `admit`, then the persona's five
/// rules in order (schema-valid, `personaId` is this mind, canonical
/// provenance, `expected_updated_at` is the stored `updatedAt`, the new
/// `updatedAt` is later), then the same commit primitive. The stored
/// document is the commit's strong read, so the swap fails if it changed.
pub fn put_persona(
&mut self,
instance: &Slug,
state: Value,
expected_updated_at: Option<String>,
provenance: PipelineProvenance,
now: DateTime<Utc>,
) -> PersonaPutOutcome {
match self.persona_steps(instance, state, expected_updated_at, provenance, now) {
Ok(receipt_id) => PersonaPutOutcome::Committed { receipt_id },
Err(refusal) => PersonaPutOutcome::Refused(refusal),
}
}
fn persona_steps(
&mut self,
instance: &Slug,
state: Value,
expected_updated_at: Option<String>,
provenance: PipelineProvenance,
now: DateTime<Utc>,
) -> Result<String, MindRefusal> {
self.require_instance(instance)?;
refuse_provenance(&provenance)?;
// The persona rides a mind that has its identity (A6's analogue).
if self.is_empty() {
return Err(MindRefusal::MissingIdentity);
}
// P1
persona::validate_shape(&state)?;
// P2
let mind = self.instance().clone();
if state.get("personaId").and_then(Value::as_str) != Some(mind.0.as_str()) {
return Err(MindRefusal::PersonaForeign { instance: mind.0 });
}
// P3
if state.pointer("/provenance/authority").and_then(Value::as_str) != Some("canonical") {
return Err(MindRefusal::PersonaNotCanonical);
}
// P4
let stored = self.stored_updated_at()?;
if stored != expected_updated_at {
return Err(MindRefusal::PersonaStale { stored });
}
// P5
if let Some(stored) = &stored {
let instant = |stamp: &str| {
DateTime::parse_from_rfc3339(stamp).map_err(|_| MindRefusal::PersonaInvalid {
path: "/updatedAt".into(),
message: "is not an RFC 3339 date-time".into(),
})
};
let offered = state.get("updatedAt").and_then(Value::as_str).map(instant).transpose()?;
if offered <= Some(instant(stored)?) {
return Err(MindRefusal::PersonaInvalid {
path: "/updatedAt".into(),
message: "must be later than the stored updatedAt".into(),
});
}
}
let (envelope, _) = self
.cache()
.prepare_entry_named(mind.0.clone(), &HuginnPersonaEntry { value: state })
.map_err(unavailable)?;
let strong_reads = self.persona_envelope().cloned().into_iter().collect::<Vec<_>>();
let writes = vec![envelope];
let candidate = receipt::candidate(&mind, provenance, &strong_reads, &writes, receipt::head(self)? + 1, now)?;
match receipt::commit(self, candidate, strong_reads, writes)? {
CommitOutcome::Committed(receipt) => Ok(receipt.receipt_id),
CommitOutcome::Conflict(_) => Err(MindRefusal::PersonaStale { stored: self.stored_updated_at()? }),
}
}
}
/// The bound `eureka_pipeline::Short` declares, in UTF-8 bytes. The leaf

View file

@ -70,28 +70,50 @@ pub fn decode_request(message: &CultNetMessage) -> Result<(String, HuginnMindReq
));
};
if service_id != MIND_SERVICE_ID {
return Err(OperationFailure::new("wrong-service", format!("{service_id} is not {MIND_SERVICE_ID}")));
return Err(OperationFailure::new("wrong-service", format!("the service is not {MIND_SERVICE_ID}")));
}
if payload_schema != MIND_REQUEST_SCHEMA {
return Err(OperationFailure::new(
"wrong-payload-schema",
format!("{payload_schema} is not {MIND_REQUEST_SCHEMA}"),
format!("the payload schema is not {MIND_REQUEST_SCHEMA}"),
));
}
let bytes = STANDARD
.decode(payload)
.map_err(|error| OperationFailure::new("payload-not-base64", error.to_string()))?;
let request: HuginnMindRequest = rmp_serde::from_slice(&bytes)
.map_err(|error| OperationFailure::new("payload-not-a-request", error.to_string()))?;
.map_err(|_| OperationFailure::new("payload-not-base64", "the payload is not standard base64"))?;
let request = read_request(&bytes)?;
if operation != request.operation() {
return Err(OperationFailure::new(
"operation-mismatch",
format!("the envelope says {operation} and the payload is {}", request.operation()),
format!("the envelope's operation is not the payload's {}", request.operation()),
));
}
Ok((message_id.clone(), request))
}
/// The payload as a request, or a failure naming the field path and the class
/// of error and nothing the caller sent: a decoder's own text quotes the
/// offending value (an unknown variant, an integer), so no decoder text
/// leaves this function.
fn read_request(bytes: &[u8]) -> Result<HuginnMindRequest, OperationFailure> {
use rmp_serde::decode::Error as Decode;
let mut decoder = rmp_serde::Deserializer::new(bytes);
serde_path_to_error::deserialize(&mut decoder).map_err(|error| {
let class = match error.inner() {
Decode::InvalidMarkerRead(_) | Decode::InvalidDataRead(_) => "truncated",
Decode::TypeMismatch(_) => "wrong-type",
Decode::OutOfRange => "out-of-range",
Decode::LengthMismatch(_) => "wrong-length",
Decode::Uncategorized(_) | Decode::Syntax(_) => "invalid-value",
Decode::Utf8Error(_) => "invalid-text",
Decode::DepthLimitExceeded => "too-deep",
};
let path = error.path().to_string();
let at = if path == "." { "the payload".to_string() } else { format!("field {path}") };
OperationFailure::new("payload-not-a-request", format!("{at}: {class}"))
})
}
/// One answer, whose status the response derives.
pub fn encode_response(
message_id: &str,
@ -184,6 +206,7 @@ mod tests {
documents: 0,
receipts: 0,
index: crate::wire::IndexStatus::Current,
persona: crate::wire::PersonaStatus::Absent,
}),
"accepted",
),
@ -209,6 +232,82 @@ mod tests {
assert_eq!(decode_response(&message).unwrap(), ("m-2".into(), Err(failure)));
}
fn request_message(service_id: &str, operation: &str, schema: &str, payload: String) -> CultNetMessage {
CultNetMessage::OperationRequest {
message_id: "m-9".into(),
service_id: service_id.into(),
operation: operation.into(),
payload_schema: schema.into(),
payload_encoding: "messagepack-base64".into(),
payload,
source_runtime_id: None,
target_runtime_id: None,
}
}
/// The payload of `request` with the value at `path` replaced, encoded the
/// way a client does.
fn payload_with(request: &HuginnMindRequest, path: &[&str], bad: serde_json::Value) -> String {
let mut value = serde_json::to_value(request).unwrap();
let mut at = &mut value;
for key in path {
at = at.get_mut(*key).unwrap_or_else(|| panic!("no {key} in the request"));
}
*at = bad;
STANDARD.encode(rmp_serde::to_vec_named(&value).unwrap())
}
/// A refusal of the decoder names the field and the class of error and
/// nothing the caller sent: not an unknown variant, not an integer, not
/// an envelope field, not a byte of bad base64. Over the persona wire and
/// over the pipeline's, because the decoder is one.
#[test]
fn the_decoder_never_echoes_what_the_caller_sent() {
const CANARY: &str = "CANARY-5c0de1-do-not-echo";
const NUMBER: i64 = 90817263;
let put = HuginnMindRequest::PersonaPut {
instance: slug(INSTANCE),
provenance: provenance(Faculty::Hands),
state: serde_json::json!({}),
expected_updated_at: None,
};
let admit = HuginnMindRequest::Admit(PipelineAdmissionBatch {
instance: slug(INSTANCE),
provenance: provenance(Faculty::Hands),
documents: vec![instance(INSTANCE)],
});
let cases = [
(&put, vec!["PersonaPut", "provenance", "faculty"], serde_json::json!(CANARY), "provenance.faculty"),
(&put, vec!["PersonaPut", "provenance", "agent"], serde_json::json!(NUMBER), "provenance.agent"),
(&put, vec!["PersonaPut", "expected_updated_at"], serde_json::json!(NUMBER), "expected_updated_at"),
(&put, vec!["PersonaPut", "instance"], serde_json::json!(NUMBER), "instance"),
(&admit, vec!["Admit", "provenance", "faculty"], serde_json::json!(CANARY), "provenance.faculty"),
(&admit, vec!["Admit", "instance"], serde_json::json!(NUMBER), "instance"),
];
for (request, path, bad, field) in cases {
let message = request_message(MIND_SERVICE_ID, request.operation(), MIND_REQUEST_SCHEMA, payload_with(request, &path, bad));
let failure = decode_request(&message).unwrap_err();
assert_eq!(failure.code, "payload-not-a-request");
assert!(failure.message.contains(field), "{field} is named: {}", failure.message);
assert!(failure.message.ends_with(": invalid-value"), "the class is named: {}", failure.message);
for echoed in [CANARY.to_string(), NUMBER.to_string()] {
assert!(!failure.message.contains(&echoed), "{echoed} came back: {}", failure.message);
}
}
let good = payload_with(&put, &["PersonaPut", "expected_updated_at"], serde_json::Value::Null);
let envelope_cases = [
request_message(CANARY, "persona_put", MIND_REQUEST_SCHEMA, good.clone()),
request_message(MIND_SERVICE_ID, "persona_put", CANARY, good.clone()),
request_message(MIND_SERVICE_ID, CANARY, MIND_REQUEST_SCHEMA, good),
request_message(MIND_SERVICE_ID, "persona_put", MIND_REQUEST_SCHEMA, format!("{CANARY}!!")),
];
for message in envelope_cases {
let failure = decode_request(&message).unwrap_err();
assert!(!failure.message.contains(CANARY), "{} came back: {}", failure.code, failure.message);
}
}
/// The round trip the client and the daemon share, and the one code that
/// `serve::answer` cannot reach because it answers a foreign message with
/// `Error` instead.

View file

@ -30,6 +30,7 @@ mod docs;
pub mod envelope;
pub mod index;
pub mod mind;
pub mod persona;
mod rows;
pub mod query;
pub mod receipt;
@ -44,6 +45,7 @@ pub use docs::CitationRole;
pub use index::{INDEX_TEXT_MAX_BYTES, INDEX_TEXT_VERSION, IndexEntry, index_text};
pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome};
pub use mind::{HuginnMindEpoch, Mind};
pub use persona::{PersonaPutOutcome, PersonaStateView};
pub use query::{
AdmissionFacts, PipelineDocumentSummary, PipelineDocumentView, PipelineEdge, PipelineFacts, PipelinePageItems,
PipelineSelectionPage, PipelineStatus, PipelineStatusSummary, Ranked, SUMMARY_MAX_BYTES, SemanticQuery,
@ -53,7 +55,7 @@ pub use refusal::{MindRefusal, RunId};
pub use store::{MindStore, OwnedRedbMessagePackBackingStore};
pub use wire::{
DeferredAnswer, HuginnMindRequest, HuginnMindResponse, IndexStatus, MAX_DEFERRED_BODY_BYTES, MIND_REQUEST_SCHEMA, MIND_REQUEST_SCHEMA_JSON,
MIND_RESPONSE_SCHEMA, MIND_RESPONSE_SCHEMA_JSON, MIND_SERVICE_ID, MindStatus,
MIND_RESPONSE_SCHEMA, MIND_RESPONSE_SCHEMA_JSON, MIND_SERVICE_ID, MindStatus, PersonaStatus,
};
/// The leaf, whole, through the one crate that pins its rev: the daemon and

View file

@ -15,6 +15,7 @@ use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry, OwnedRedbMessage
use cultnet_rs::CursorKey;
use eureka_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineDocument, PipelineKind, Slug, register_pipeline_document_types};
use crate::persona::HuginnPersonaEntry;
use crate::receipt::HuginnCommitReceipt;
use crate::refusal::MindRefusal;
use crate::store::MindStore;
@ -71,20 +72,22 @@ fn require_grammatical_slug(field: &str, declared: &Slug) -> Result<(), MindRefu
})
}
/// A cache that knows the sixteen types a mind's store may hold: the leaf's
/// fourteen through its registrar, the epoch record and the commit receipt.
/// It is the cache every envelope is prepared against.
/// A cache that knows the seventeen types a mind's store may hold: the leaf's
/// fourteen through its registrar, the epoch record, the commit receipt and
/// the persona entry. It is the cache every envelope is prepared against.
pub(crate) fn schema_cache() -> Result<CultCache, MindRefusal> {
let mut cache = CultCache::new();
register_pipeline_document_types(&mut cache).map_err(unavailable)?;
cache.register_entry_type::<HuginnMindEpoch>().map_err(unavailable)?;
cache.register_entry_type::<HuginnCommitReceipt>().map_err(unavailable)?;
cache.register_entry_type::<HuginnPersonaEntry>().map_err(unavailable)?;
Ok(cache)
}
fn is_known_type(type_id: &str) -> bool {
type_id == HuginnMindEpoch::TYPE
|| type_id == HuginnCommitReceipt::TYPE
|| type_id == HuginnPersonaEntry::TYPE
|| PipelineKind::ALL.iter().any(|kind| kind.type_id() == type_id)
}
@ -168,7 +171,7 @@ impl<S: MindStore> Mind<S> {
/// Fail-closed, in this order, nothing attached until every step passes:
/// pull the raw envelopes; if anything is stored, exactly one epoch
/// record at the current epoch; every type is one of the sixteen; and
/// record at the current epoch; every type is one of the seventeen; and
/// exactly one `instance` document naming the declared instance; then
/// register, attach and pull. The epoch gate runs first so a store
/// written at a foreign epoch is refused as `ForeignEpoch`, never as
@ -276,11 +279,15 @@ impl<S: MindStore> Mind<S> {
}
/// Step 3: a runtime store, a Mind store or any other file passed by mistake
/// dies on its first foreign type.
/// dies on its first foreign type, and a second persona envelope is no mind's:
/// a mind holds one.
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() });
}
if raw.iter().filter(|envelope| envelope.r#type == HuginnPersonaEntry::TYPE).count() > 1 {
return Err(MindRefusal::ForeignStore { r#type: HuginnPersonaEntry::TYPE.into() });
}
Ok(())
}

View file

@ -0,0 +1,585 @@
//! The persona document: one `gamecult.persona_state.v0` per mind, stored
//! opaque and replaced whole.
//!
//! The shape is the published schema's, `schemas/cultnet/`, read here with
//! `include_bytes!`; the mind holds no Rust type for it, only the value that
//! passed the schema. `put_persona` (admission.rs) owns whether a document may
//! enter; this module owns what it is checked against and how it is stored and
//! read back. Nothing here echoes a caller's value: a refusal names a schema
//! location and a keyword, never the text that failed.
use std::collections::BTreeSet;
use std::sync::OnceLock;
use cultcache_rs::{CultCacheEnvelope, DatabaseEntry};
use jsonschema::paths::LocationSegment;
use jsonschema::error::ValidationErrorKind;
use jsonschema::Validator;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::mind::Mind;
use crate::refusal::MindRefusal;
use crate::store::MindStore;
const PERSONA_SCHEMA: &[u8] = include_bytes!("../../../schemas/cultnet/gamecult.persona_state.v0.schema.json");
/// The stored envelope's payload: the document, one slot, keyed by the mind's
/// instance. The cache needs a registered entry type to hold the envelope; the
/// document inside stays a value.
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "gamecult.persona_state.v0", schema = "HuginnPersonaEntry")]
pub struct HuginnPersonaEntry {
#[cultcache(key = 0)]
pub value: Value,
}
/// What `PersonaGet` answers: the stored document, its `updatedAt`, and the
/// receipt of the put that wrote it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct PersonaStateView {
pub value: Value,
pub updated_at: String,
pub receipt_id: String,
}
/// How a put ended. A refusal is an answer, as for `admit`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum PersonaPutOutcome {
Committed { receipt_id: String },
Refused(MindRefusal),
}
struct Compiled {
validator: Validator,
/// Every property name the schema declares. A path segment outside this
/// set is a caller's key (`traitMap` keys are free), so it is withheld.
declared: BTreeSet<String>,
}
fn compiled() -> Result<&'static Compiled, MindRefusal> {
static COMPILED: OnceLock<Result<Compiled, String>> = OnceLock::new();
COMPILED
.get_or_init(|| {
let schema: Value = serde_json::from_slice(PERSONA_SCHEMA)
.map_err(|error| format!("the published persona schema does not parse: {error}"))?;
let validator = jsonschema::draft7::new(&schema)
.map_err(|error| format!("the published persona schema does not compile: {error}"))?;
let mut declared = BTreeSet::new();
declared_names(&schema, &mut declared);
Ok(Compiled { validator, declared })
})
.as_ref()
.map_err(|detail| MindRefusal::Unavailable { detail: detail.clone() })
}
fn declared_names(node: &Value, names: &mut BTreeSet<String>) {
match node {
Value::Object(map) => {
if let Some(Value::Object(properties)) = map.get("properties") {
names.extend(properties.keys().cloned());
}
map.values().for_each(|child| declared_names(child, names));
}
Value::Array(items) => items.iter().for_each(|child| declared_names(child, names)),
_ => {}
}
}
/// Rule 1: the document is valid against the published schema. The first
/// failure is the refusal; it names where and which keyword, never the value.
pub(crate) fn validate_shape(state: &Value) -> Result<(), MindRefusal> {
let compiled = compiled()?;
let Err(error) = compiled.validator.validate(state) else { return Ok(()) };
let mut path = error
.instance_path()
.segments()
.map(|segment| match segment {
LocationSegment::Index(index) => index.to_string(),
LocationSegment::Property(name) if compiled.declared.contains(name.as_ref()) => name.into_owned(),
LocationSegment::Property(_) => "*".to_string(),
})
.collect::<Vec<_>>();
match error.kind() {
ValidationErrorKind::Required { property } => path.extend(property.as_str().map(str::to_string)),
ValidationErrorKind::AdditionalProperties { .. } => path.push("*".to_string()),
_ => {}
}
Err(MindRefusal::PersonaInvalid {
path: format!("/{}", path.join("/")),
message: format!("fails the published schema's {} rule", error.kind().keyword()),
})
}
/// A stored persona envelope's document.
pub(crate) fn decode(envelope: &CultCacheEnvelope) -> Result<Value, MindRefusal> {
rmp_serde::from_slice::<HuginnPersonaEntry>(&envelope.payload)
.map(|entry| entry.value)
.map_err(|error| MindRefusal::Unavailable { detail: format!("the stored persona does not decode: {error}") })
}
/// A document's `updatedAt`; the schema makes it a required string.
pub(crate) fn updated_at(value: &Value) -> Option<&str> {
value.get("updatedAt").and_then(Value::as_str)
}
impl<S: MindStore> Mind<S> {
/// This mind's persona envelope, if one is stored.
pub(crate) fn persona_envelope(&self) -> Option<&CultCacheEnvelope> {
self.raw_envelope(HuginnPersonaEntry::TYPE, &self.instance().0)
}
/// The stored `updatedAt`; none when no persona is stored.
pub(crate) fn stored_updated_at(&self) -> Result<Option<String>, MindRefusal> {
let Some(envelope) = self.persona_envelope() else { return Ok(None) };
let value = decode(envelope)?;
updated_at(&value)
.map(|stamp| Some(stamp.to_string()))
.ok_or_else(|| MindRefusal::Unavailable { detail: "the stored persona has no updatedAt".into() })
}
/// `PersonaGet`: the document, its `updatedAt` and the receipt of the put
/// that wrote exactly these bytes.
pub fn persona(&self) -> Result<Option<PersonaStateView>, MindRefusal> {
let Some(envelope) = self.persona_envelope() else { return Ok(None) };
let value = decode(envelope)?;
let integrity = |detail: &str| MindRefusal::Unavailable { detail: detail.into() };
let updated_at = updated_at(&value).ok_or_else(|| integrity("the stored persona has no updatedAt"))?.to_string();
let identity = (envelope.r#type.as_str(), envelope.key.as_str());
let receipt_id = self
.receipts()?
.into_iter()
.filter(|receipt| receipt.writes.iter().any(|write| write.identity() == identity))
.max_by_key(|receipt| receipt.ordinal)
.ok_or_else(|| integrity("the stored persona has no receipt"))?
.receipt_id;
Ok(Some(PersonaStateView { value, updated_at, receipt_id }))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures::{INSTANCE, OTHER_INSTANCE, epoch, instance, opened, prepare, provenance, seed, slug};
use crate::receipt::Faculty;
use crate::store::test_stores::{MemoryStore, RefusingStore, SwapCommand};
use eureka_pipeline::PipelineKind;
use serde_json::json;
const T1: &str = "2026-10-10T10:00:00Z";
const T2: &str = "2026-10-10T10:00:01Z";
const T3: &str = "2026-10-10T10:00:02Z";
const CANARY: &str = "CANARY-7f3a91-do-not-echo";
const NOT_LATER: &str = "must be later than the stored updatedAt";
/// A minimal document the published schema accepts. The test that follows
/// holds it to the schema file's own required list.
fn persona(id: &str, updated_at: &str) -> Value {
let traits = json!({
"underlyingOrganization": {}, "stableDispositions": {}, "behavioralDimensions": {},
"presentationStrategy": {}, "voiceStyle": {}, "situationalState": {}
});
json!({
"schemaVersion": "gamecult.persona_state.v0",
"provenance": {
"sourceSystem": "huginn", "sourceDocumentId": id, "sourceUpdatedAt": updated_at,
"exportedAt": updated_at, "authority": "canonical"
},
"personaId": id,
"publicName": "Test Persona",
"presentation": { "voiceSummary": "plain" },
"activationProfile": traits,
"thoughtMemory": { "shortTerm": [], "memories": [], "incubation": [] },
"agencyPressure": { "pressures": [] },
"candidateActions": { "actions": [] },
"affect": {
"needs": [], "socialBonds": [], "statusReads": [], "moodDimensions": [],
"socialBiases": [], "doctrineStances": []
},
"updatedAt": updated_at
})
}
fn schema_file() -> Value {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../schemas/cultnet/gamecult.persona_state.v0.schema.json");
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
}
fn put<S: MindStore>(mind: &mut Mind<S>, state: Value, expected: Option<&str>) -> PersonaPutOutcome {
let instance = mind.instance().clone();
mind.put_persona(&instance, state, expected.map(str::to_string), provenance(Faculty::Hands), crate::fixtures::now())
}
fn committed(outcome: PersonaPutOutcome) -> String {
match outcome {
PersonaPutOutcome::Committed { receipt_id } => receipt_id,
other => panic!("expected a commit, got {other:?}"),
}
}
fn refused(outcome: PersonaPutOutcome) -> MindRefusal {
match outcome {
PersonaPutOutcome::Refused(refusal) => refusal,
other => panic!("expected a refusal, got {other:?}"),
}
}
fn invalid(path: &str, rule: &str) -> MindRefusal {
MindRefusal::PersonaInvalid { path: path.into(), message: format!("fails the published schema's {rule} rule") }
}
fn not_later() -> MindRefusal {
MindRefusal::PersonaInvalid { path: "/updatedAt".into(), message: NOT_LATER.into() }
}
fn stale(stored: Option<&str>) -> MindRefusal {
MindRefusal::PersonaStale { stored: stored.map(str::to_string) }
}
fn seeded_over(store: &MemoryStore) -> Mind<MemoryStore> {
let mut mind = opened(store.clone(), INSTANCE);
seed(&mut mind);
mind
}
fn persona_in_status<S: MindStore>(mind: &Mind<S>) -> crate::wire::PersonaStatus {
mind.status(crate::wire::IndexStatus::Current).persona
}
#[test]
fn the_fixture_is_valid_against_the_published_file_and_covers_its_required_fields() {
let schema = schema_file();
let validator = jsonschema::draft7::new(&schema).unwrap();
let document = persona(INSTANCE, T1);
assert!(validator.is_valid(&document));
for name in schema["required"].as_array().unwrap() {
assert!(document.get(name.as_str().unwrap()).is_some(), "the fixture lacks {name}");
}
}
#[test]
fn declared_names_are_every_property_name_at_any_depth() {
let schema = json!({
"properties": { "a": { "properties": { "b": {} } } },
"allOf": [{ "properties": { "c": {} } }],
"definitions": { "d": { "properties": { "e": {} } } },
"required": ["not-a-property"]
});
let mut names = BTreeSet::new();
declared_names(&schema, &mut names);
assert_eq!(names, BTreeSet::from(["a", "b", "c", "e"].map(String::from)));
}
/// `identity-write-all-visible`: a put may change any field, the identity
/// fields included, and a change the next second is no less admissible.
#[test]
fn a_put_may_rewrite_identity_fields_and_every_change_lands() {
let store = MemoryStore::new();
let mut mind = seeded_over(&store);
committed(put(&mut mind, persona(INSTANCE, T1), None));
let mut renamed = persona(INSTANCE, T2);
renamed["publicName"] = json!("A New Name");
renamed["publicDescription"] = json!("who she says she is");
renamed["presentation"] = json!({ "voiceSummary": "warmer", "pronouns": "she/her" });
renamed["values"] = json!([{ "id": "v1", "label": "candor", "priority": 0.9 }]);
committed(put(&mut mind, renamed.clone(), Some(T1)));
assert_eq!(mind.persona().unwrap().unwrap().value, renamed);
let mut again = persona(INSTANCE, T3);
again["publicName"] = json!("Another Name");
committed(put(&mut mind, again.clone(), Some(T2)));
assert_eq!(mind.persona().unwrap().unwrap().value, again);
}
#[test]
fn the_view_names_the_receipt_of_the_latest_put() {
let store = MemoryStore::new();
let mut mind = seeded_over(&store);
let mut previous: Option<String> = None;
for second in 0..5 {
let stamp = format!("2026-10-10T10:00:0{second}Z");
let receipt = committed(put(&mut mind, persona(INSTANCE, &stamp), previous.as_deref()));
assert_eq!(mind.persona().unwrap().unwrap().receipt_id, receipt, "after put {second}");
previous = Some(stamp);
}
}
#[test]
fn a_persona_document_is_validated_at_the_door() {
let store = MemoryStore::new();
let mut mind = seeded_over(&store);
let before = store.rows();
for name in schema_file()["required"].as_array().unwrap() {
let name = name.as_str().unwrap();
let mut document = persona(INSTANCE, T1);
document.as_object_mut().unwrap().remove(name);
assert_eq!(refused(put(&mut mind, document, None)), invalid(&format!("/{name}"), "required"), "{name}");
}
let mut extra = persona(INSTANCE, T1);
extra["notInTheSchema"] = json!(1);
assert_eq!(refused(put(&mut mind, extra, None)), invalid("/*", "additionalProperties"));
let mut wrong_type = persona(INSTANCE, T1);
wrong_type["publicName"] = json!(5);
assert_eq!(refused(put(&mut mind, wrong_type, None)), invalid("/publicName", "type"));
let mut bad_stamp = persona(INSTANCE, T1);
bad_stamp["updatedAt"] = json!("yesterday");
assert_eq!(refused(put(&mut mind, bad_stamp, None)), invalid("/updatedAt", "format"));
let mut nested = persona(INSTANCE, T1);
nested["thoughtMemory"]["memories"] = json!([7]);
assert_eq!(refused(put(&mut mind, nested, None)), invalid("/thoughtMemory/memories/0", "type"));
let mut free_key = persona(INSTANCE, T1);
free_key["activationProfile"]["voiceStyle"]["any-key"] = json!({});
assert_eq!(
refused(put(&mut mind, free_key, None)),
invalid("/activationProfile/voiceStyle/*/mean", "required"),
"a key the schema does not declare is withheld from the path"
);
assert_eq!(store.rows(), before, "no refusal wrote anything");
assert_eq!(mind.persona().unwrap(), None);
}
#[test]
fn a_refusal_never_echoes_what_the_caller_sent() {
let store = MemoryStore::new();
let mut mind = seeded_over(&store);
committed(put(&mut mind, persona(INSTANCE, T2), None));
let mut free_key = persona(INSTANCE, T3);
free_key["activationProfile"]["voiceStyle"][CANARY] = json!({});
let mut extra_key = persona(INSTANCE, T3);
extra_key[CANARY] = json!(CANARY);
let mut bad_value = persona(INSTANCE, T3);
bad_value["updatedAt"] = json!(CANARY);
let mut bad_enum = persona(INSTANCE, T3);
bad_enum["provenance"]["authority"] = json!(CANARY);
let mut foreign = persona(CANARY, T3);
foreign["publicName"] = json!(CANARY);
let mut imported = persona(INSTANCE, T3);
imported["provenance"]["authority"] = json!("import");
imported["provenance"]["sourceSystem"] = json!(CANARY);
let mut late = persona(INSTANCE, T1);
late["publicName"] = json!(CANARY);
let cases: Vec<(Value, Option<&str>)> = vec![
(free_key, Some(T2)),
(extra_key, Some(T2)),
(bad_value, Some(T2)),
(bad_enum, Some(T2)),
(foreign, Some(T2)),
(imported, Some(T2)),
(late, Some(T2)),
(persona(INSTANCE, T3), Some(CANARY)),
];
for (document, expected) in cases {
let refusal = refused(put(&mut mind, document, expected));
assert!(!format!("{refusal:?}").contains(CANARY), "{refusal:?}");
assert!(!serde_json::to_string(&refusal).unwrap().contains(CANARY));
}
}
#[test]
fn persona_id_must_be_the_instance() {
let store = MemoryStore::new();
let mut mind = seeded_over(&store);
let before = store.rows();
let refusal = refused(put(&mut mind, persona(OTHER_INSTANCE, T1), None));
assert_eq!(refusal, MindRefusal::PersonaForeign { instance: INSTANCE.into() });
assert_eq!(store.rows(), before);
assert_eq!(mind.persona().unwrap(), None);
}
#[test]
fn only_a_canonical_document_is_the_minds_persona() {
let store = MemoryStore::new();
let mut mind = seeded_over(&store);
for authority in ["projection", "import"] {
let mut document = persona(INSTANCE, T1);
document["provenance"]["authority"] = json!(authority);
assert_eq!(refused(put(&mut mind, document, None)), MindRefusal::PersonaNotCanonical, "{authority}");
}
assert_eq!(mind.persona().unwrap(), None);
}
#[test]
fn a_persona_waits_for_the_minds_identity() {
let mut mind = opened(MemoryStore::new(), INSTANCE);
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T1), None)), MindRefusal::MissingIdentity);
assert!(mind.is_empty());
}
#[test]
fn a_put_must_name_this_mind_and_a_named_asker() {
let store = MemoryStore::new();
let mut mind = seeded_over(&store);
let before = store.rows();
let other = slug(OTHER_INSTANCE);
let outcome = mind.put_persona(&other, persona(INSTANCE, T1), None, provenance(Faculty::Hands), crate::fixtures::now());
assert!(matches!(refused(outcome), MindRefusal::ForeignInstance { .. }));
let mut unnamed = provenance(Faculty::Hands);
unnamed.agent = eureka_pipeline::Short(" ".into());
let instance = mind.instance().clone();
let outcome = mind.put_persona(&instance, persona(INSTANCE, T1), None, unnamed, crate::fixtures::now());
assert!(matches!(refused(outcome), MindRefusal::Document(_)));
assert_eq!(store.rows(), before);
}
#[test]
fn a_put_is_a_compare_and_swap_on_updated_at() {
let store = MemoryStore::new();
let mut mind = seeded_over(&store);
assert_eq!(persona_in_status(&mind), crate::wire::PersonaStatus::Absent);
let receipts = |mind: &Mind<MemoryStore>| mind.receipts().unwrap().len();
let base = receipts(&mind);
// A first put expects nothing; claiming a stored document is stale.
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T1), Some(T1))), stale(None));
let first = committed(put(&mut mind, persona(INSTANCE, T1), None));
assert_eq!(receipts(&mind), base + 1);
let view = mind.persona().unwrap().unwrap();
assert_eq!((view.updated_at.as_str(), view.receipt_id.as_str()), (T1, first.as_str()));
assert_eq!(view.value, persona(INSTANCE, T1));
assert_eq!(persona_in_status(&mind), crate::wire::PersonaStatus::Stored { updated_at: T1.into() });
// Two puts that read the same stored document: the second loses.
let second = committed(put(&mut mind, persona(INSTANCE, T2), Some(T1)));
assert_eq!(receipts(&mind), base + 2);
let rows = store.rows();
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T3), Some(T1))), stale(Some(T2)));
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T3), None)), stale(Some(T2)));
assert_eq!(store.rows(), rows, "a stale put wrote nothing");
// The right expectation with an updatedAt that is not later.
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T2), Some(T2))), not_later());
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T1), Some(T2))), not_later());
// Instants compare, not spellings: the same instant at another offset is not later.
assert_eq!(refused(put(&mut mind, persona(INSTANCE, "2026-10-10T12:00:01+02:00"), Some(T2))), not_later());
assert_eq!(store.rows(), rows);
assert_eq!(receipts(&mind), base + 2);
committed(put(&mut mind, persona(INSTANCE, "2026-10-10T12:00:02+02:00"), Some(T2)));
assert_eq!(receipts(&mind), base + 3);
// The second put's receipt read the first document and wrote the second.
let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == second).unwrap();
assert_eq!((receipt.strong_reads.len(), receipt.writes.len()), (1, 1));
assert_eq!(receipt.strong_reads[0].document_key, INSTANCE);
assert_ne!(receipt.strong_reads[0].payload_msgpack, receipt.writes[0].payload_msgpack);
assert_eq!(receipt.provenance, provenance(Faculty::Hands));
}
#[test]
fn a_put_whose_swap_is_lost_is_stale_and_changes_nothing() {
let store = RefusingStore::new();
let mut mind = opened(store.clone(), INSTANCE);
seed(&mut mind);
committed(put(&mut mind, persona(INSTANCE, T1), None));
let rows = store.rows();
store.command(SwapCommand::Lose);
assert_eq!(refused(put(&mut mind, persona(INSTANCE, T2), Some(T1))), stale(Some(T1)));
assert_eq!(store.rows(), rows);
}
#[test]
fn the_persona_is_not_a_pipeline_document_and_a_batch_cannot_carry_it() {
let store = MemoryStore::new();
let mut mind = seeded_over(&store);
let documents = mind.status(crate::wire::IndexStatus::Current).documents;
committed(put(&mut mind, persona(INSTANCE, T1), None));
assert_eq!(mind.status(crate::wire::IndexStatus::Current).documents, documents);
assert!(PipelineKind::ALL.iter().all(|kind| kind.type_id() != HuginnPersonaEntry::TYPE));
let forged = mind.persona_envelope().unwrap().clone();
let instance = mind.instance().clone();
let outcome = mind.admit_prepared(&instance, provenance(Faculty::Hands), vec![forged], crate::fixtures::now());
assert!(matches!(outcome, crate::admission::PipelineAdmissionOutcome::Refused(_)), "{outcome:?}");
}
#[test]
fn one_persona_per_mind_and_the_store_still_opens() {
let yggdrasil = slug(INSTANCE);
let cache = crate::mind::schema_cache().unwrap();
let entry = |key: &str, value: Value| cache.prepare_entry_named(key, &HuginnPersonaEntry { value }).unwrap().0;
let planted = |rows: Vec<CultCacheEnvelope>| {
let store = MemoryStore::new();
rows.into_iter().for_each(|row| store.plant(row));
store
};
let base = || vec![epoch(), prepare(&instance(INSTANCE))];
// A store written before this type existed opens unchanged.
assert!(Mind::open_with(planted(base()), &yggdrasil).is_ok());
// One persona opens; a planted row has no writing receipt, so the door and the status both call it unreadable.
let mut with_one = base();
with_one.push(entry(INSTANCE, persona(INSTANCE, T1)));
let mind = Mind::open_with(planted(with_one.clone()), &yggdrasil).unwrap();
assert_eq!(mind.envelopes().len(), 3);
assert_eq!(persona_in_status(&mind), crate::wire::PersonaStatus::Unreadable);
assert!(mind.persona().is_err());
// Two are no mind's.
with_one.push(entry("second", persona(INSTANCE, T2)));
assert_eq!(
Mind::open_with(planted(with_one), &yggdrasil).err(),
Some(MindRefusal::ForeignStore { r#type: HuginnPersonaEntry::TYPE.into() })
);
// The opener still refuses a type that is not one of the seventeen.
let mut stranger = base();
let mut foreign = entry(INSTANCE, persona(INSTANCE, T1));
foreign.r#type = "gamecult.persona_state.v1".into();
stranger.push(foreign);
assert!(matches!(Mind::open_with(planted(stranger), &yggdrasil).err(), Some(MindRefusal::ForeignStore { .. })));
}
/// Status answers in the door's own terms: a stored persona the door
/// cannot read is `Unreadable`, not the `Absent` of a mind with none, while
/// `PersonaGet` answers `Unavailable` for the same bytes.
#[test]
fn status_says_unreadable_where_the_door_says_unavailable() {
let yggdrasil = slug(INSTANCE);
let cache = crate::mind::schema_cache().unwrap();
let readable = cache.prepare_entry_named(INSTANCE, &HuginnPersonaEntry { value: persona(INSTANCE, T1) }).unwrap().0;
let no_stamp = cache.prepare_entry_named(INSTANCE, &HuginnPersonaEntry { value: json!({}) }).unwrap().0;
let mut undecodable = readable.clone();
undecodable.payload = vec![0xc1];
// Planted rows have no writing receipt: the door cannot read them, so neither may the status.
for (row, status) in [
(readable, crate::wire::PersonaStatus::Unreadable),
(no_stamp, crate::wire::PersonaStatus::Unreadable),
(undecodable, crate::wire::PersonaStatus::Unreadable),
] {
let store = MemoryStore::new();
store.plant(epoch());
store.plant(prepare(&instance(INSTANCE)));
store.plant(row);
let mind = Mind::open_with(store, &yggdrasil).unwrap();
assert_eq!(persona_in_status(&mind), status);
assert!(matches!(mind.persona(), Err(MindRefusal::Unavailable { .. })), "the door answers what the status says");
}
// A row a put wrote: status and door agree it is stored, with one stamp.
let store = MemoryStore::new();
let mut written = seeded_over(&store);
committed(put(&mut written, persona(INSTANCE, T1), None));
assert_eq!(persona_in_status(&written), crate::wire::PersonaStatus::Stored { updated_at: T1.into() });
assert_eq!(written.persona().unwrap().unwrap().updated_at, T1);
assert_eq!(persona_in_status(&seeded_over(&MemoryStore::new())), crate::wire::PersonaStatus::Absent);
}
#[test]
fn the_stored_bytes_are_the_one_slot_array_the_catalogue_describes() {
let store = MemoryStore::new();
let mut mind = seeded_over(&store);
committed(put(&mut mind, persona(INSTANCE, T1), None));
let envelope = mind.persona_envelope().unwrap();
assert_eq!((envelope.r#type.as_str(), envelope.key.as_str()), ("gamecult.persona_state.v0", INSTANCE));
let slots: (Value,) = rmp_serde::from_slice(&envelope.payload).unwrap();
assert_eq!(slots.0, persona(INSTANCE, T1));
}
}

View file

@ -256,9 +256,15 @@ pub(crate) fn commit<S: MindStore>(
.map(|(envelope, _)| envelope)
.map_err(unavailable)?;
// The store requires every expected identity to be replaced, so each
// strong read is re-inserted unchanged beside the writes.
// strong read is re-inserted unchanged beside the writes, unless a write
// replaces it (a persona put reads the document it overwrites).
let mut replacements = writes.clone();
replacements.extend(strong_reads.iter().cloned());
replacements.extend(
strong_reads
.iter()
.filter(|read| !writes.iter().any(|write| write.r#type == read.r#type && write.key == read.key))
.cloned(),
);
replacements.push(receipt_envelope);
let expected: &[CultCacheEnvelope] = &strong_reads;
let landed = MindStore::compare_and_swap_batch(mind.store(), expected, replacements).map_err(unavailable)?;

View file

@ -55,6 +55,19 @@ pub enum MindRefusal {
FindingWithoutEvidence,
UnknownInvariant { label: String },
NotStewarded { repo: String },
/// The persona document fails the published schema: `path` is the
/// document location (a key the schema does not declare is `*`) and
/// `message` the schema keyword that failed. Never the offending value.
PersonaInvalid { path: String, message: String },
/// The document's `personaId` is not this mind's instance, which is
/// `instance`.
PersonaForeign { instance: String },
/// The put's `expected_updated_at` is not the stored document's
/// `updatedAt`, which is `stored` (none when no persona is stored).
PersonaStale { stored: Option<String> },
/// The document's `provenance.authority` is not `canonical`: a projection
/// or an import is not the mind's persona.
PersonaNotCanonical,
/// A run claims work another run in force already holds: `item` is the
/// claimed document's id and `run` the id of the run that holds it.
AlreadyClaimed { item: String, run: RunId },

View file

@ -9,11 +9,13 @@ use cultnet_rs::{CultMeshCdnArtifactManifest, Selection};
use eureka_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineKind, PipelineRef, Slug};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::admission::{PipelineAdmissionBatch, PipelineAdmissionOutcome};
use crate::mind::Mind;
use crate::persona::{PersonaPutOutcome, PersonaStateView};
use crate::query::{PipelineDocumentView, PipelineSelectionPage, SemanticQuery};
use crate::receipt::HuginnCommitReceipt;
use crate::receipt::{HuginnCommitReceipt, PipelineProvenance};
use crate::refusal::MindRefusal;
use crate::store::MindStore;
@ -75,6 +77,17 @@ pub enum HuginnMindRequest {
selection: Selection,
semantic: Option<SemanticQuery>,
},
/// The mind's persona document, if one is stored.
PersonaGet { instance: Slug },
/// Replaces the mind's persona document whole. `expected_updated_at` is
/// the stored document's `updatedAt`, none for the first put. The
/// provenance is the asker's, as for `Admit`.
PersonaPut {
instance: Slug,
provenance: PipelineProvenance,
state: Value,
expected_updated_at: Option<String>,
},
}
impl HuginnMindRequest {
@ -85,6 +98,8 @@ impl HuginnMindRequest {
Self::Admit(_) => "admit",
Self::View { .. } => "view",
Self::Query { .. } => "query",
Self::PersonaGet { .. } => "persona_get",
Self::PersonaPut { .. } => "persona_put",
}
}
@ -93,7 +108,10 @@ impl HuginnMindRequest {
match self {
Self::Whoami => None,
Self::Admit(batch) => Some(&batch.instance),
Self::View { instance, .. } | Self::Query { instance, .. } => Some(instance),
Self::View { instance, .. }
| Self::Query { instance, .. }
| Self::PersonaGet { instance }
| Self::PersonaPut { instance, .. } => Some(instance),
}
}
}
@ -108,6 +126,8 @@ pub enum HuginnMindResponse {
View(Option<Box<PipelineDocumentView>>),
Query(PipelineSelectionPage),
Refused(MindRefusal),
Persona(Option<PersonaStateView>),
PersonaPut(PersonaPutOutcome),
/// The answer did not fit one send and is being held for the client to
/// fetch. Nothing in a mind produces this: the daemon's serve module does,
/// in place of an answer, and the answer it stands for is the named
@ -134,7 +154,9 @@ impl HuginnMindResponse {
/// envelope of a deferred answer to the status of the answer it stands for.
pub fn status(&self) -> &'static str {
match self {
Self::Refused(_) | Self::Admit(PipelineAdmissionOutcome::Refused(_)) => "rejected",
Self::Refused(_)
| Self::Admit(PipelineAdmissionOutcome::Refused(_))
| Self::PersonaPut(PersonaPutOutcome::Refused(_)) => "rejected",
_ => "accepted",
}
}
@ -170,6 +192,18 @@ pub struct MindStatus {
pub documents: u32,
pub receipts: u32,
pub index: IndexStatus,
/// The persona door as `PersonaGet` would find it.
pub persona: PersonaStatus,
}
/// What the persona door holds, in the one vocabulary the door itself answers
/// in: nothing yet, a document with this `updatedAt`, or a stored document the
/// door cannot read (`PersonaGet` and every put answer `Unavailable`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum PersonaStatus {
Absent,
Stored { updated_at: String },
Unreadable,
}
impl<S: MindStore> Mind<S> {
@ -191,6 +225,11 @@ impl<S: MindStore> Mind<S> {
documents,
receipts,
index,
persona: match self.persona() {
Ok(None) => PersonaStatus::Absent,
Ok(Some(view)) => PersonaStatus::Stored { updated_at: view.updated_at },
Err(_) => PersonaStatus::Unreadable,
},
}
}
}
@ -213,6 +252,13 @@ mod tests {
HuginnMindRequest::Admit(batch),
HuginnMindRequest::View { instance: slug(INSTANCE), id: r(PipelineKind::Campaign, CAMPAIGN) },
HuginnMindRequest::Query { instance: slug(OTHER_INSTANCE), selection: Selection::default(), semantic: None },
HuginnMindRequest::PersonaGet { instance: slug(INSTANCE) },
HuginnMindRequest::PersonaPut {
instance: slug(INSTANCE),
provenance: provenance(Faculty::Hands),
state: serde_json::json!({ "updatedAt": "2026-10-10T10:00:00Z", "n": [1, 2.5, null, "x"] }),
expected_updated_at: Some("2026-10-10T09:00:00Z".into()),
},
]
}
@ -232,6 +278,14 @@ mod tests {
edges: None,
}),
HuginnMindResponse::Refused(refusal),
HuginnMindResponse::Persona(None),
HuginnMindResponse::Persona(Some(PersonaStateView {
value: serde_json::json!({ "personaId": INSTANCE }),
updated_at: "2026-10-10T10:00:00Z".into(),
receipt_id: "mind-commit-1".into(),
})),
HuginnMindResponse::PersonaPut(PersonaPutOutcome::Committed { receipt_id: "mind-commit-2".into() }),
HuginnMindResponse::PersonaPut(PersonaPutOutcome::Refused(MindRefusal::PersonaNotCanonical)),
HuginnMindResponse::Deferred(DeferredAnswer {
manifest: cultnet_rs::pack_content("huginn.mind_response", "package", "", "", "", b"body", 4)
.unwrap()
@ -246,7 +300,7 @@ mod tests {
#[test]
fn the_wire_vocabulary_is_the_minds_methods_and_status_is_derived_from_the_response() {
let names: Vec<&str> = requests().iter().map(|request| request.operation()).collect();
assert_eq!(names, ["whoami", "admit", "view", "query"]);
assert_eq!(names, ["whoami", "admit", "view", "query", "persona_get", "persona_put"]);
for request in requests() {
let declared = request.instance().is_none();
assert_eq!(declared, matches!(request, HuginnMindRequest::Whoami), "{request:?}");
@ -262,7 +316,9 @@ mod tests {
for response in responses() {
let rejected = matches!(
response,
HuginnMindResponse::Refused(_) | HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(_))
HuginnMindResponse::Refused(_)
| HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(_))
| HuginnMindResponse::PersonaPut(PersonaPutOutcome::Refused(_))
);
assert_eq!(response.status(), if rejected { "rejected" } else { "accepted" }, "{response:?}");
let bytes = rmp_serde::to_vec_named(&response).unwrap();

View file

@ -78,6 +78,60 @@
"required": [
"Query"
]
},
{
"description": "The mind's persona document, if one is stored.",
"type": "object",
"properties": {
"PersonaGet": {
"type": "object",
"properties": {
"instance": {
"$ref": "#/$defs/Slug"
}
},
"required": [
"instance"
]
}
},
"additionalProperties": false,
"required": [
"PersonaGet"
]
},
{
"description": "Replaces the mind's persona document whole. `expected_updated_at` is\nthe stored document's `updatedAt`, none for the first put. The\nprovenance is the asker's, as for `Admit`.",
"type": "object",
"properties": {
"PersonaPut": {
"type": "object",
"properties": {
"expected_updated_at": {
"type": [
"string",
"null"
]
},
"instance": {
"$ref": "#/$defs/Slug"
},
"provenance": {
"$ref": "#/$defs/PipelineProvenance"
},
"state": true
},
"required": [
"instance",
"provenance",
"state"
]
}
},
"additionalProperties": false,
"required": [
"PersonaPut"
]
}
],
"$defs": {

View file

@ -70,6 +70,37 @@
"Refused"
]
},
{
"type": "object",
"properties": {
"Persona": {
"anyOf": [
{
"$ref": "#/$defs/PersonaStateView"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false,
"required": [
"Persona"
]
},
{
"type": "object",
"properties": {
"PersonaPut": {
"$ref": "#/$defs/PersonaPutOutcome"
}
},
"additionalProperties": false,
"required": [
"PersonaPut"
]
},
{
"description": "The answer did not fit one send and is being held for the client to\nfetch. Nothing in a mind produces this: the daemon's serve module does,\nin place of an answer, and the answer it stands for is the named\nMessagePack of the `HuginnMindResponse` the mind gave.",
"type": "object",
@ -1360,6 +1391,78 @@
"NotStewarded"
]
},
{
"description": "The persona document fails the published schema: `path` is the\ndocument location (a key the schema does not declare is `*`) and\n`message` the schema keyword that failed. Never the offending value.",
"type": "object",
"properties": {
"PersonaInvalid": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"path",
"message"
]
}
},
"additionalProperties": false,
"required": [
"PersonaInvalid"
]
},
{
"description": "The document's `personaId` is not this mind's instance, which is\n`instance`.",
"type": "object",
"properties": {
"PersonaForeign": {
"type": "object",
"properties": {
"instance": {
"type": "string"
}
},
"required": [
"instance"
]
}
},
"additionalProperties": false,
"required": [
"PersonaForeign"
]
},
{
"description": "The put's `expected_updated_at` is not the stored document's\n`updatedAt`, which is `stored` (none when no persona is stored).",
"type": "object",
"properties": {
"PersonaStale": {
"type": "object",
"properties": {
"stored": {
"type": [
"string",
"null"
]
}
}
}
},
"additionalProperties": false,
"required": [
"PersonaStale"
]
},
{
"description": "The document's `provenance.authority` is not `canonical`: a projection\nor an import is not the mind's persona.",
"type": "string",
"const": "PersonaNotCanonical"
},
{
"description": "A run claims work another run in force already holds: `item` is the\nclaimed document's id and `run` the id of the run that holds it.",
"type": "object",
@ -1525,6 +1628,10 @@
"instance": {
"$ref": "#/$defs/Slug"
},
"persona": {
"description": "The persona door as `PersonaGet` would find it.",
"$ref": "#/$defs/PersonaStatus"
},
"receipts": {
"type": "integer",
"format": "uint32",
@ -1539,7 +1646,8 @@
"schema_epoch",
"documents",
"receipts",
"index"
"index",
"persona"
]
},
"MutationRecord": {
@ -1603,6 +1711,93 @@
"type": "string",
"maxLength": 4000
},
"PersonaPutOutcome": {
"description": "How a put ended. A refusal is an answer, as for `admit`.",
"oneOf": [
{
"type": "object",
"properties": {
"Committed": {
"type": "object",
"properties": {
"receipt_id": {
"type": "string"
}
},
"required": [
"receipt_id"
]
}
},
"additionalProperties": false,
"required": [
"Committed"
]
},
{
"type": "object",
"properties": {
"Refused": {
"$ref": "#/$defs/MindRefusal"
}
},
"additionalProperties": false,
"required": [
"Refused"
]
}
]
},
"PersonaStateView": {
"description": "What `PersonaGet` answers: the stored document, its `updatedAt`, and the\nreceipt of the put that wrote it.",
"type": "object",
"properties": {
"receipt_id": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"value": true
},
"required": [
"value",
"updated_at",
"receipt_id"
]
},
"PersonaStatus": {
"description": "What the persona door holds, in the one vocabulary the door itself answers\nin: nothing yet, a document with this `updatedAt`, or a stored document the\ndoor cannot read (`PersonaGet` and every put answer `Unavailable`).",
"oneOf": [
{
"type": "string",
"enum": [
"Absent",
"Unreadable"
]
},
{
"type": "object",
"properties": {
"Stored": {
"type": "object",
"properties": {
"updated_at": {
"type": "string"
}
},
"required": [
"updated_at"
]
}
},
"additionalProperties": false,
"required": [
"Stored"
]
}
]
},
"PipelineAdmissionOutcome": {
"description": "How admission ended. `Committed.writes` names every document the batch\nlanded, derived writes included; Cut 11 indexes from it after `admit`\nreturns, and never inside it.",
"oneOf": [