Huginn holds an instance's persona document, validated and receipted
put_persona replaces the mind's gamecult.persona_state.v0 whole through the commit primitive: schema-valid against the published file, personaId is the instance, canonical provenance, CAS on updatedAt, monotone updatedAt. The document is an opaque seventeenth stored type, one per mind. Wire operations persona_get and persona_put, MindStatus.persona, HuginnClient methods and the eureka-state persona tools. Published wire schemas follow in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a7cc2ff069
commit
dbdb4e86f8
17 changed files with 1308 additions and 41 deletions
386
Cargo.lock
generated
386
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
@ -98,6 +127,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 +163,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 +199,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"
|
||||
|
|
@ -357,6 +413,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 +470,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"
|
||||
|
|
@ -455,6 +526,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 +555,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 +708,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 +742,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 +750,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,6 +815,7 @@ dependencies = [
|
|||
"cultcache-rs",
|
||||
"cultnet-rs",
|
||||
"eureka-pipeline",
|
||||
"jsonschema",
|
||||
"rmp-serde",
|
||||
"schemars",
|
||||
"serde",
|
||||
|
|
@ -776,6 +899,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 +1000,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"
|
||||
|
|
@ -838,6 +1020,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"
|
||||
|
|
@ -859,6 +1110,12 @@ 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"
|
||||
|
|
@ -867,7 +1124,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]]
|
||||
|
|
@ -879,11 +1146,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"
|
||||
|
|
@ -1049,6 +1329,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"
|
||||
|
|
@ -1057,7 +1346,7 @@ checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f"
|
|||
dependencies = [
|
||||
"libm",
|
||||
"lru",
|
||||
"parking_lot",
|
||||
"parking_lot 0.11.2",
|
||||
"smallvec",
|
||||
"spin",
|
||||
]
|
||||
|
|
@ -1082,6 +1371,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"
|
||||
|
|
@ -1383,6 +1718,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"
|
||||
|
|
@ -1547,6 +1903,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"
|
||||
|
|
@ -1605,12 +1967,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"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ publish.workspace = true
|
|||
# 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.
|
||||
# consumer: six tools over stdio, one per wire operation, holding no state.
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
cultnet-rs.workspace = true
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -259,6 +291,28 @@ impl EurekaState {
|
|||
})
|
||||
.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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.as_deref(), Some(T1));
|
||||
|
||||
// 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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"], Value::Null);
|
||||
|
||||
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!(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}");
|
||||
|
|
|
|||
|
|
@ -153,6 +153,13 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
|
|||
Ok(view) => HuginnMindResponse::View(view),
|
||||
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),
|
||||
|
|
@ -581,6 +588,7 @@ pub(crate) mod tests {
|
|||
documents: 1,
|
||||
receipts: 1,
|
||||
index: IndexStatus::Current,
|
||||
persona: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(daemon.runtime_id(), "huginn-yggdrasil");
|
||||
|
|
@ -672,6 +680,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, None);
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -21,13 +21,14 @@ 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"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
|
||||
# `test-support` exposes `Mind::store_path_for` and `Mind::open_with`, a
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ mod tests {
|
|||
documents: 0,
|
||||
receipts: 0,
|
||||
index: crate::wire::IndexStatus::Current,
|
||||
persona: None,
|
||||
}),
|
||||
"accepted",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
506
crates/huginn-mind/src/persona.rs
Normal file
506
crates/huginn-mind/src/persona.rs
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
//! 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::{ValidationErrorKind, Validator};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::mind::Mind;
|
||||
use crate::receipt::HuginnCommitReceipt;
|
||||
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 receipt_id = self
|
||||
.receipts()?
|
||||
.into_iter()
|
||||
.find(|receipt: &HuginnCommitReceipt| {
|
||||
receipt.writes.iter().any(|write| {
|
||||
write.document_type == envelope.r#type
|
||||
&& write.document_key == envelope.key
|
||||
&& write.payload_msgpack == envelope.payload
|
||||
})
|
||||
})
|
||||
.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>) -> Option<String> {
|
||||
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 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), None);
|
||||
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).as_deref(), Some(T1));
|
||||
|
||||
// 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 and reads back.
|
||||
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).as_deref(), Some(T1));
|
||||
|
||||
// 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 { .. })));
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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: String },
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -109,6 +127,8 @@ pub enum HuginnMindResponse {
|
|||
View(Option<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
|
||||
|
|
@ -135,7 +155,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",
|
||||
}
|
||||
}
|
||||
|
|
@ -171,6 +193,8 @@ pub struct MindStatus {
|
|||
pub documents: u32,
|
||||
pub receipts: u32,
|
||||
pub index: IndexStatus,
|
||||
/// The stored persona document's `updatedAt`; none until the first put.
|
||||
pub persona: Option<String>,
|
||||
}
|
||||
|
||||
impl<S: MindStore> Mind<S> {
|
||||
|
|
@ -192,6 +216,7 @@ impl<S: MindStore> Mind<S> {
|
|||
documents,
|
||||
receipts,
|
||||
index,
|
||||
persona: self.stored_updated_at().ok().flatten(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -214,6 +239,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()),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -233,6 +265,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()
|
||||
|
|
@ -247,7 +287,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:?}");
|
||||
|
|
@ -263,7 +303,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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue