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:
Meta Vens 2026-10-10 05:30:48 +02:00
commit dbdb4e86f8
17 changed files with 1308 additions and 41 deletions

386
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"
@ -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"

View file

@ -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

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")
@ -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.",
)
}
}

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.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]

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"], 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}");

View file

@ -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

View file

@ -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

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;
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

@ -184,6 +184,7 @@ mod tests {
documents: 0,
receipts: 0,
index: crate::wire::IndexStatus::Current,
persona: None,
}),
"accepted",
),

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,

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,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));
}
}

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: String },

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),
}
}
}
@ -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();