From dbdb4e86f885da082938eb3826d36101258d3943 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 05:30:48 +0200 Subject: [PATCH] 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 --- Cargo.lock | 386 +++++++++++++++++- crates/eureka-state/Cargo.toml | 2 +- crates/eureka-state/src/lib.rs | 25 +- crates/eureka-state/src/main.rs | 72 +++- crates/eureka-state/tests/client.rs | 48 ++- crates/eureka-state/tests/common/mod.rs | 48 ++- crates/eureka-state/tests/mcp.rs | 51 ++- crates/huginn-daemon/src/daemon.rs | 33 ++ crates/huginn-mind/Cargo.toml | 3 +- crates/huginn-mind/src/admission.rs | 82 ++++ crates/huginn-mind/src/envelope.rs | 1 + crates/huginn-mind/src/lib.rs | 2 + crates/huginn-mind/src/mind.rs | 17 +- crates/huginn-mind/src/persona.rs | 506 ++++++++++++++++++++++++ crates/huginn-mind/src/receipt.rs | 10 +- crates/huginn-mind/src/refusal.rs | 13 + crates/huginn-mind/src/wire.rs | 52 ++- 17 files changed, 1309 insertions(+), 42 deletions(-) create mode 100644 crates/huginn-mind/src/persona.rs diff --git a/Cargo.lock b/Cargo.lock index a46de8b..e288372 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/eureka-state/Cargo.toml b/crates/eureka-state/Cargo.toml index 71ddd71..a1c5fb6 100644 --- a/crates/eureka-state/Cargo.toml +++ b/crates/eureka-state/Cargo.toml @@ -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 diff --git a/crates/eureka-state/src/lib.rs b/crates/eureka-state/src/lib.rs index 5d0d502..c73300b 100644 --- a/crates/eureka-state/src/lib.rs +++ b/crates/eureka-state/src/lib.rs @@ -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 { + 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, + ) -> Result { + self.call(HuginnMindRequest::PersonaPut { + instance: self.instance.clone(), + provenance, + state, + expected_updated_at, + }) + } + pub fn call(&self, request: HuginnMindRequest) -> Result { let deadline = Instant::now() + self.timeout; let unavailable = diff --git a/crates/eureka-state/src/main.rs b/crates/eureka-state/src/main.rs index f48b8a0..2138fa5 100644 --- a/crates/eureka-state/src/main.rs +++ b/crates/eureka-state/src/main.rs @@ -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, } +#[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, +} + #[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 { + async fn call( + &self, + request: impl FnOnce(Slug) -> HuginnMindRequest + Send + 'static, + ) -> Result { + 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 + Send + 'static, + ) -> Result { 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) -> 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) -> 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.", ) } } diff --git a/crates/eureka-state/tests/client.rs b/crates/eureka-state/tests/client.rs index 123c508..38550b9 100644 --- a/crates/eureka-state/tests/client.rs +++ b/crates/eureka-state/tests/client.rs @@ -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] diff --git a/crates/eureka-state/tests/common/mod.rs b/crates/eureka-state/tests/common/mod.rs index ca0e19b..cb09085 100644 --- a/crates/eureka-state/tests/common/mod.rs +++ b/crates/eureka-state/tests/common/mod.rs @@ -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) -> 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) -> 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; diff --git a/crates/eureka-state/tests/mcp.rs b/crates/eureka-state/tests/mcp.rs index adfc2f8..f4b96d3 100644 --- a/crates/eureka-state/tests/mcp.rs +++ b/crates/eureka-state/tests/mcp.rs @@ -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}"); diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index 614129e..ef6f8b9 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -153,6 +153,13 @@ impl> Daemon { 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 diff --git a/crates/huginn-mind/Cargo.toml b/crates/huginn-mind/Cargo.toml index 104665f..bcbfde6 100644 --- a/crates/huginn-mind/Cargo.toml +++ b/crates/huginn-mind/Cargo.toml @@ -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 diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 5149f77..961b8b7 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -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 Mind { 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, + provenance: PipelineProvenance, + now: DateTime, + ) -> 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, + provenance: PipelineProvenance, + now: DateTime, + ) -> Result { + 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::>(); + 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 diff --git a/crates/huginn-mind/src/envelope.rs b/crates/huginn-mind/src/envelope.rs index 8152a54..a314e37 100644 --- a/crates/huginn-mind/src/envelope.rs +++ b/crates/huginn-mind/src/envelope.rs @@ -184,6 +184,7 @@ mod tests { documents: 0, receipts: 0, index: crate::wire::IndexStatus::Current, + persona: None, }), "accepted", ), diff --git a/crates/huginn-mind/src/lib.rs b/crates/huginn-mind/src/lib.rs index 5aa59f7..0e9dc19 100644 --- a/crates/huginn-mind/src/lib.rs +++ b/crates/huginn-mind/src/lib.rs @@ -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, diff --git a/crates/huginn-mind/src/mind.rs b/crates/huginn-mind/src/mind.rs index a026dd1..c33ee6a 100644 --- a/crates/huginn-mind/src/mind.rs +++ b/crates/huginn-mind/src/mind.rs @@ -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 { let mut cache = CultCache::new(); register_pipeline_document_types(&mut cache).map_err(unavailable)?; cache.register_entry_type::().map_err(unavailable)?; cache.register_entry_type::().map_err(unavailable)?; + cache.register_entry_type::().map_err(unavailable)?; Ok(cache) } fn is_known_type(type_id: &str) -> bool { type_id == HuginnMindEpoch::TYPE || type_id == HuginnCommitReceipt::TYPE + || type_id == HuginnPersonaEntry::TYPE || PipelineKind::ALL.iter().any(|kind| kind.type_id() == type_id) } @@ -168,7 +171,7 @@ impl Mind { /// 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 Mind { } /// 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(()) } diff --git a/crates/huginn-mind/src/persona.rs b/crates/huginn-mind/src/persona.rs new file mode 100644 index 0000000..37639aa --- /dev/null +++ b/crates/huginn-mind/src/persona.rs @@ -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, +} + +fn compiled() -> Result<&'static Compiled, MindRefusal> { + static COMPILED: OnceLock> = 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) { + 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::>(); + 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 { + rmp_serde::from_slice::(&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 Mind { + /// 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, 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, 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(mind: &mut Mind, 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 { + let mut mind = opened(store.clone(), INSTANCE); + seed(&mut mind); + mind + } + + fn persona_in_status(mind: &Mind) -> Option { + 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| 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| { + 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)); + } +} diff --git a/crates/huginn-mind/src/receipt.rs b/crates/huginn-mind/src/receipt.rs index 35705d9..446786e 100644 --- a/crates/huginn-mind/src/receipt.rs +++ b/crates/huginn-mind/src/receipt.rs @@ -256,9 +256,15 @@ pub(crate) fn commit( .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)?; diff --git a/crates/huginn-mind/src/refusal.rs b/crates/huginn-mind/src/refusal.rs index c771744..02f870f 100644 --- a/crates/huginn-mind/src/refusal.rs +++ b/crates/huginn-mind/src/refusal.rs @@ -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 }, + /// 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 }, diff --git a/crates/huginn-mind/src/wire.rs b/crates/huginn-mind/src/wire.rs index 5265c0d..10bbede 100644 --- a/crates/huginn-mind/src/wire.rs +++ b/crates/huginn-mind/src/wire.rs @@ -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, }, + /// 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, + }, } 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), Query(PipelineSelectionPage), Refused(MindRefusal), + Persona(Option), + 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, } impl Mind { @@ -192,6 +216,7 @@ impl Mind { 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();