From dbdb4e86f885da082938eb3826d36101258d3943 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 05:30:48 +0200 Subject: [PATCH 01/16] 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(); From 6fde91fe0a2e9f312b7471d8963cad7421059359 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 05:47:00 +0200 Subject: [PATCH 02/16] Persona: compile fixes and the regenerated wire schemas The jsonschema error kind lives in jsonschema::error; the eureka-state tool closures own their input; huginn.mind_request/response.v1 carry the persona operations (derived by the existing published_wire_schemas test). Co-Authored-By: Claude Opus 5.5 --- crates/eureka-state/src/main.rs | 6 +- crates/huginn-mind/src/persona.rs | 3 +- .../huginn.mind_request.v1.schema.json | 54 ++++++ .../huginn.mind_response.v1.schema.json | 165 ++++++++++++++++++ 4 files changed, 224 insertions(+), 4 deletions(-) diff --git a/crates/eureka-state/src/main.rs b/crates/eureka-state/src/main.rs index 2138fa5..dcf65c6 100644 --- a/crates/eureka-state/src/main.rs +++ b/crates/eureka-state/src/main.rs @@ -260,7 +260,7 @@ impl EurekaState { description = "Admit one batch of pipeline documents into the mind, whole or not at all. Returns the outcome: committed with a receipt, already admitted, conflict, or a typed refusal. The instance is this server's configuration; do not pass one." )] async fn admit(&self, Parameters(input): Parameters) -> CallToolResult { - self.answer(|instance| { + self.answer(move |instance| { HuginnMindRequest::Admit(PipelineAdmissionBatch { instance, provenance: PipelineProvenance { @@ -277,14 +277,14 @@ impl EurekaState { #[tool(description = "Read one document by id, with its admission facts and status. null when the mind has no such document.")] async fn view(&self, Parameters(input): Parameters) -> CallToolResult { - self.answer(|instance| HuginnMindRequest::View { instance, id: input.id }).await + self.answer(move |instance| HuginnMindRequest::View { instance, id: input.id }).await } #[tool( description = "Query the mind with a CultNet typed selection, optionally ranked by semantic nearness. Returns one page; pass its cursor back in the selection for the next." )] async fn query(&self, Parameters(input): Parameters) -> CallToolResult { - self.answer(|instance| HuginnMindRequest::Query { + self.answer(move |instance| HuginnMindRequest::Query { instance, selection: input.selection, semantic: input.semantic, diff --git a/crates/huginn-mind/src/persona.rs b/crates/huginn-mind/src/persona.rs index 37639aa..f77a4a0 100644 --- a/crates/huginn-mind/src/persona.rs +++ b/crates/huginn-mind/src/persona.rs @@ -13,7 +13,8 @@ use std::sync::OnceLock; use cultcache_rs::{CultCacheEnvelope, DatabaseEntry}; use jsonschema::paths::LocationSegment; -use jsonschema::{ValidationErrorKind, Validator}; +use jsonschema::error::ValidationErrorKind; +use jsonschema::Validator; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index 909a8a8..c075586 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -78,6 +78,60 @@ "required": [ "Query" ] + }, + { + "description": "The mind's persona document, if one is stored.", + "type": "object", + "properties": { + "PersonaGet": { + "type": "object", + "properties": { + "instance": { + "$ref": "#/$defs/Slug" + } + }, + "required": [ + "instance" + ] + } + }, + "additionalProperties": false, + "required": [ + "PersonaGet" + ] + }, + { + "description": "Replaces the mind's persona document whole. `expected_updated_at` is\nthe stored document's `updatedAt`, none for the first put. The\nprovenance is the asker's, as for `Admit`.", + "type": "object", + "properties": { + "PersonaPut": { + "type": "object", + "properties": { + "expected_updated_at": { + "type": [ + "string", + "null" + ] + }, + "instance": { + "$ref": "#/$defs/Slug" + }, + "provenance": { + "$ref": "#/$defs/PipelineProvenance" + }, + "state": true + }, + "required": [ + "instance", + "provenance", + "state" + ] + } + }, + "additionalProperties": false, + "required": [ + "PersonaPut" + ] } ], "$defs": { diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 426cf7a..7e1eeda 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -70,6 +70,37 @@ "Refused" ] }, + { + "type": "object", + "properties": { + "Persona": { + "anyOf": [ + { + "$ref": "#/$defs/PersonaStateView" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "required": [ + "Persona" + ] + }, + { + "type": "object", + "properties": { + "PersonaPut": { + "$ref": "#/$defs/PersonaPutOutcome" + } + }, + "additionalProperties": false, + "required": [ + "PersonaPut" + ] + }, { "description": "The answer did not fit one send and is being held for the client to\nfetch. Nothing in a mind produces this: the daemon's serve module does,\nin place of an answer, and the answer it stands for is the named\nMessagePack of the `HuginnMindResponse` the mind gave.", "type": "object", @@ -1360,6 +1391,78 @@ "NotStewarded" ] }, + { + "description": "The persona document fails the published schema: `path` is the\ndocument location (a key the schema does not declare is `*`) and\n`message` the schema keyword that failed. Never the offending value.", + "type": "object", + "properties": { + "PersonaInvalid": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "message" + ] + } + }, + "additionalProperties": false, + "required": [ + "PersonaInvalid" + ] + }, + { + "description": "The document's `personaId` is not this mind's instance, which is\n`instance`.", + "type": "object", + "properties": { + "PersonaForeign": { + "type": "object", + "properties": { + "instance": { + "type": "string" + } + }, + "required": [ + "instance" + ] + } + }, + "additionalProperties": false, + "required": [ + "PersonaForeign" + ] + }, + { + "description": "The put's `expected_updated_at` is not the stored document's\n`updatedAt`, which is `stored` (none when no persona is stored).", + "type": "object", + "properties": { + "PersonaStale": { + "type": "object", + "properties": { + "stored": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false, + "required": [ + "PersonaStale" + ] + }, + { + "description": "The document's `provenance.authority` is not `canonical`: a projection\nor an import is not the mind's persona.", + "type": "string", + "const": "PersonaNotCanonical" + }, { "description": "A run claims work another run in force already holds: `item` is the\nclaimed document's id and `run` the id of the run that holds it.", "type": "object", @@ -1504,6 +1607,13 @@ "instance": { "$ref": "#/$defs/Slug" }, + "persona": { + "description": "The stored persona document's `updatedAt`; none until the first put.", + "type": [ + "string", + "null" + ] + }, "receipts": { "type": "integer", "format": "uint32", @@ -1582,6 +1692,61 @@ "type": "string", "maxLength": 4000 }, + "PersonaPutOutcome": { + "description": "How a put ended. A refusal is an answer, as for `admit`.", + "oneOf": [ + { + "type": "object", + "properties": { + "Committed": { + "type": "object", + "properties": { + "receipt_id": { + "type": "string" + } + }, + "required": [ + "receipt_id" + ] + } + }, + "additionalProperties": false, + "required": [ + "Committed" + ] + }, + { + "type": "object", + "properties": { + "Refused": { + "$ref": "#/$defs/MindRefusal" + } + }, + "additionalProperties": false, + "required": [ + "Refused" + ] + } + ] + }, + "PersonaStateView": { + "description": "What `PersonaGet` answers: the stored document, its `updatedAt`, and the\nreceipt of the put that wrote it.", + "type": "object", + "properties": { + "receipt_id": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": true + }, + "required": [ + "value", + "updated_at", + "receipt_id" + ] + }, "PipelineAdmissionOutcome": { "description": "How admission ended. `Committed.writes` names every document the batch\nlanded, derived writes included; Cut 11 indexes from it after `admit`\nreturns, and never inside it.", "oneOf": [ From 02cb3dadcd50b83cb9c539ec9daec2ee0af0ffcc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 06:01:01 +0200 Subject: [PATCH 03/16] huginn CLI over HuginnClient: whoami, view, query, recipes, admit with read-back, schema A new bin of eureka-state, crates/eureka-state/src/bin/huginn/: the agents' shell door to the mind. Text out, typed exit codes, strict decode of agent-written JSON (unknown key paths refused, never values), the named campaign-state recipes, and a read-back after every committed admission. lib.rs, main.rs and tests/mcp.rs are untouched. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 121 +++ crates/eureka-state/Cargo.toml | 7 +- crates/eureka-state/src/bin/huginn/config.rs | 67 ++ crates/eureka-state/src/bin/huginn/door.rs | 161 +++ crates/eureka-state/src/bin/huginn/main.rs | 395 ++++++++ crates/eureka-state/src/bin/huginn/recipes.rs | 287 ++++++ crates/eureka-state/src/bin/huginn/render.rs | 106 ++ crates/eureka-state/src/bin/huginn/schema.rs | 52 + crates/eureka-state/src/bin/huginn/trouble.rs | 79 ++ crates/eureka-state/tests/cli.rs | 951 ++++++++++++++++++ 10 files changed, 2224 insertions(+), 2 deletions(-) create mode 100644 crates/eureka-state/src/bin/huginn/config.rs create mode 100644 crates/eureka-state/src/bin/huginn/door.rs create mode 100644 crates/eureka-state/src/bin/huginn/main.rs create mode 100644 crates/eureka-state/src/bin/huginn/recipes.rs create mode 100644 crates/eureka-state/src/bin/huginn/render.rs create mode 100644 crates/eureka-state/src/bin/huginn/schema.rs create mode 100644 crates/eureka-state/src/bin/huginn/trouble.rs create mode 100644 crates/eureka-state/tests/cli.rs diff --git a/Cargo.lock b/Cargo.lock index a46de8b..64941e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -201,6 +251,52 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const-oid" version = "0.9.6" @@ -443,6 +539,7 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "clap", "cultnet-rs", "huginn-daemon", "huginn-mind", @@ -640,6 +737,12 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hmac" version = "0.12.1" @@ -759,6 +862,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -853,6 +962,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -1594,6 +1709,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.26.1" diff --git a/crates/eureka-state/Cargo.toml b/crates/eureka-state/Cargo.toml index 71ddd71..fd7f720 100644 --- a/crates/eureka-state/Cargo.toml +++ b/crates/eureka-state/Cargo.toml @@ -11,10 +11,13 @@ publish.workspace = true # the daemon cannot spell them differently; the content fetch and its # verification are `cultnet-rs`'s. # -# The MCP server (`main.rs`, the `eureka-state` binary) is the client's one -# consumer: four tools over stdio, one per wire operation, holding no state. +# The crate is the client core (`lib.rs`) and its consumers: the `huginn` CLI +# (`src/bin/huginn/`), the shell door every agent uses, and until +# delete-eureka-state-mcp the MCP server (`main.rs`, the `eureka-state` binary). +# Both hold no state. [dependencies] anyhow = "1" +clap = { version = "4", features = ["derive"] } cultnet-rs.workspace = true huginn-mind = { path = "../huginn-mind" } rmcp = { version = "2.2.0", default-features = false, features = ["server", "macros", "transport-io"] } diff --git a/crates/eureka-state/src/bin/huginn/config.rs b/crates/eureka-state/src/bin/huginn/config.rs new file mode 100644 index 0000000..d20e0e9 --- /dev/null +++ b/crates/eureka-state/src/bin/huginn/config.rs @@ -0,0 +1,67 @@ +//! The CLI's configuration: the mind is the session's environment, never an +//! argument. `EUREKA_INSTANCE` names the instance and `HUGINN_ENDPOINT` +//! (`rudp://:`) the daemon that holds it. + +use std::net::SocketAddr; +use std::time::Duration; + +use eureka_state::HuginnClient; +use huginn_mind::eureka_pipeline::Slug; +use huginn_mind::{HuginnMindRequest, HuginnMindResponse}; + +use crate::trouble::Trouble; + +/// The `provenance.tool` every admission from this binary carries. +pub const TOOL: &str = "huginn"; +/// How long one call may take, connection to last byte. +const TIMEOUT: Duration = Duration::from_secs(15); + +/// The two environment values as given, and the client they make when both +/// are valid. +pub struct Settings { + pub instance: Option, + pub endpoint: Option, + client: Result, +} + +impl Settings { + pub fn from_env() -> Self { + let instance = variable("EUREKA_INSTANCE"); + let endpoint = variable("HUGINN_ENDPOINT"); + let shown = |value: &Result, String>| value.as_ref().ok().cloned().flatten(); + let (shown_instance, shown_endpoint) = (shown(&instance), shown(&endpoint)); + Self { instance: shown_instance, endpoint: shown_endpoint, client: client(instance, endpoint) } + } + + pub fn client(&self) -> Result<&HuginnClient, Trouble> { + self.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone())) + } + + /// One wire call for the configured instance. The request is built from + /// that instance, never from anything the caller sent. No retry. + pub fn ask(&self, request: impl FnOnce(&Slug) -> HuginnMindRequest) -> Result { + let client = self.client()?; + client.call(request(client.instance())).map_err(Trouble::from) + } +} + +/// An environment variable: its value, `None` when unset, or why it cannot be read. +fn variable(name: &str) -> Result, String> { + match std::env::var(name) { + Ok(value) => Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} is not valid UTF-8")), + } +} + +fn client(instance: Result, String>, endpoint: Result, String>) -> Result { + let instance = instance?.ok_or("EUREKA_INSTANCE is not set")?; + let slug = Slug(instance); + slug.validate_slug().map_err(|refusal| format!("EUREKA_INSTANCE is not a valid instance name: {refusal:?}"))?; + let endpoint = endpoint?.ok_or("HUGINN_ENDPOINT is not set")?; + let address = endpoint + .strip_prefix("rudp://") + .and_then(|address| address.parse::().ok()) + .ok_or_else(|| format!("HUGINN_ENDPOINT is not rudp://:: {endpoint}"))?; + Ok(HuginnClient::new(address, slug, TIMEOUT)) +} diff --git a/crates/eureka-state/src/bin/huginn/door.rs b/crates/eureka-state/src/bin/huginn/door.rs new file mode 100644 index 0000000..ced8feb --- /dev/null +++ b/crates/eureka-state/src/bin/huginn/door.rs @@ -0,0 +1,161 @@ +//! The text door: agent-written JSON becomes typed values here, strictly, and +//! nowhere else. The store's decode stays lenient (serde ignores unknown +//! keys), so a misspelled key would be dropped and the batch committed +//! without it. The door refuses instead: every key path present in the input +//! with a value and absent from the typed value's re-serialisation is named in +//! the refusal. Paths only, never values. + +use std::io::Read; + +use cultnet_rs::Selection; +use huginn_mind::eureka_pipeline::{PipelineDocument, PipelineKind}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::trouble::Trouble; + +/// The text of an argument that is a literal, `@` or `-` for stdin. +pub fn argument(arg: &str) -> Result { + match arg.strip_prefix('@') { + Some(path) => file(path), + None if arg == "-" => stdin(), + None => Ok(arg.to_string()), + } +} + +/// The text of a file argument, or stdin when it is `-`. +pub fn file_or_stdin(arg: &str) -> Result { + if arg == "-" { stdin() } else { file(arg) } +} + +fn file(path: &str) -> Result { + std::fs::read_to_string(path).map_err(|error| Trouble::invalid(format!("cannot read {path}: {}", error.kind()))) +} + +fn stdin() -> Result { + let mut text = String::new(); + std::io::stdin() + .read_to_string(&mut text) + .map_err(|error| Trouble::invalid(format!("cannot read stdin: {}", error.kind())))?; + Ok(text) +} + +/// A batch: a JSON array of `{kind, value}` documents, or one such object. +pub fn documents(text: &str) -> Result, Trouble> { + let items = match parse(text)? { + Value::Array(items) => items, + object @ Value::Object(_) => vec![object], + _ => return Err(Trouble::invalid("the documents must be a JSON array of {kind, value} objects")), + }; + items + .into_iter() + .enumerate() + .map(|(index, item)| strict(item, &format!("documents[{index}]"))) + .collect() +} + +pub fn selection(text: &str) -> Result { + selection_value(parse(text)?) +} + +pub fn selection_value(value: Value) -> Result { + strict(value, "selection") +} + +/// The kind a pipeline id names in its middle segment (`::`). +pub fn kind_of(id: &str) -> Result { + let mut segments = id.splitn(3, ':'); + let (_, Some(name), Some(_)) = (segments.next(), segments.next(), segments.next()) else { + return Err(Trouble::invalid("a pipeline id is ::")); + }; + PipelineKind::ALL + .iter() + .copied() + .find(|kind| kind.name() == name) + .ok_or_else(|| Trouble::invalid("the id's kind segment is not a pipeline kind (`huginn schema` lists them)")) +} + +fn parse(text: &str) -> Result { + serde_json::from_str(text).map_err(|error| Trouble::invalid(format!("not JSON: {error}"))) +} + +fn strict(input: Value, at: &str) -> Result { + let typed: T = serde_json::from_value(input.clone()).map_err(|error| Trouble::invalid(format!("{at} does not decode: {error}")))?; + let kept = serde_json::to_value(&typed).map_err(|error| Trouble::Internal(format!("{at} does not re-serialise: {error}")))?; + let mut paths = Vec::new(); + dropped(&input, &kept, at, &mut paths); + if paths.is_empty() { + Ok(typed) + } else { + Err(Trouble::invalid(format!("keys the schema does not know (`huginn schema` prints it): {}", paths.join(", ")))) + } +} + +/// The paths of `input` that carry a value and are absent from `kept`. +fn dropped(input: &Value, kept: &Value, path: &str, out: &mut Vec) { + match (input, kept) { + (Value::Object(given), Value::Object(held)) => { + for (key, value) in given { + let here = format!("{path}.{key}"); + match held.get(key) { + Some(held) => dropped(value, held, &here, out), + None if !empty(value) => out.push(here), + None => {} + } + } + } + (Value::Array(given), Value::Array(held)) => { + for (index, (value, held)) in given.iter().zip(held).enumerate() { + dropped(value, held, &format!("{path}[{index}]"), out); + } + } + _ => {} + } +} + +/// A value whose loss changes nothing: the spellings of "not given". +fn empty(value: &Value) -> bool { + match value { + Value::Null => true, + Value::String(text) => text.is_empty(), + Value::Array(items) => items.is_empty(), + Value::Object(fields) => fields.is_empty(), + Value::Bool(_) | Value::Number(_) => false, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn paths(input: &Value, kept: &Value) -> Vec { + let mut out = Vec::new(); + dropped(input, kept, "d", &mut out); + out.sort(); + out + } + + #[test] + fn a_dropped_key_with_a_value_is_named_by_path() { + let input = json!({ "a": { "b": [ { "c": 1, "typo": "CANARY" } ] }, "top": 7 }); + let kept = json!({ "a": { "b": [ { "c": 1 } ] } }); + assert_eq!(paths(&input, &kept), vec!["d.a.b[0].typo".to_string(), "d.top".to_string()]); + } + + #[test] + fn a_dropped_key_without_a_value_is_not_a_loss() { + let input = json!({ "n": null, "s": "", "l": [], "o": {}, "zero": 0, "no": false }); + assert_eq!(paths(&input, &json!({})), vec!["d.no".to_string(), "d.zero".to_string()]); + } + + #[test] + fn kinds_come_from_the_middle_segment() { + assert_eq!(kind_of("eureka-substrate:cut_spec:cut-x.r1").ok(), Some(PipelineKind::CutSpec)); + assert!(kind_of("eureka-substrate:nonsense:x").is_err()); + assert!(kind_of("two:segments").is_err()); + assert!(kind_of("campaign").is_err()); + } +} diff --git a/crates/eureka-state/src/bin/huginn/main.rs b/crates/eureka-state/src/bin/huginn/main.rs new file mode 100644 index 0000000..a6a0232 --- /dev/null +++ b/crates/eureka-state/src/bin/huginn/main.rs @@ -0,0 +1,395 @@ +//! `huginn`: the agents' door to Huginn's mind, as shell commands. +//! +//! A caller of `HuginnClient` and nothing else. It owns argument parsing, the +//! strict decode of agent-written JSON into the leaf's types (`door`), the +//! recipe selections (`recipes`), the text rendering (`render`), the exit code +//! and the read-back after an admission. It owns no state, writes no file and +//! reads no configuration but two environment variables: `EUREKA_INSTANCE` +//! names the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that +//! holds it. Each call is a new process; there is no retry inside it, the +//! caller's rule retries `Unavailable` once. +//! +//! Answers go to stdout and the exit code says what the answer was: 0 answered, +//! 1 the mind said no (a refusal, a conflict, an absent document). Troubles go +//! to stderr as `: `: 2 the call is wrong, 3 Unavailable, 4 no +//! answer and a retry cannot help, 5 the read-back after a committed admission +//! differs from what was sent. + +mod config; +mod door; +mod recipes; +mod render; +mod schema; +mod trouble; + +use std::io::Write; +use std::process::ExitCode; +use std::sync::OnceLock; + +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; +use cultnet_rs::{PROJECTION_DOCUMENT, Selection}; +use huginn_mind::eureka_pipeline::{Line, PipelineDocument, PipelineRef, Short, Slug, pipeline_key}; +use huginn_mind::{ + BATCH_MAX, Faculty, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome, + PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery, +}; +use serde_json::Value; + +use config::{Settings, TOOL}; +use recipes::Recipe; +use trouble::Trouble; + +/// The page size when a selection names none. +const DEFAULT_LIMIT: u32 = 50; +/// The neighbours `--near` asks for when `--top-k` is not given. +const DEFAULT_TOP_K: u32 = 10; + +const ADMIT_HELP: &str = "\ +The file is a JSON array of 1 to 64 documents, each {\"kind\": , \"value\": {...}}, or - for stdin; one document object alone is a batch of one. \ +`huginn schema` lists the kinds and `huginn schema ` prints a value's JSON Schema. \ +Every key the schema does not know is refused (exit 2) with its path; nothing is sent. \ +After Committed or AlreadyAdmitted the batch is read back and compared with what was sent: `read back equal`, or exit 5 with the ids and field paths that differ."; + +#[derive(Parser, Debug)] +#[command(name = "huginn", about = "Huginn's mind: whoami, view, query, recipes, admit, schema", arg_required_else_help = true)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Who this process acts for and whether the daemon answers. + Whoami, + /// Read one document by id, with its admission facts and status. + View { + /// The document's id: ::. + id: String, + /// Print only the {kind, value} JSON, which `admit` accepts back. + #[arg(long)] + raw: bool, + }, + /// Query the mind with a CultNet selection. + Query { + /// The selection JSON, @, or - for stdin. + selection: String, + /// Rank by nearness to this text instead of ordering by key. + #[arg(long)] + near: Option, + /// How many neighbours (default 10). + #[arg(long, requires = "near")] + top_k: Option, + /// Print each item as `view` does. + #[arg(long)] + documents: bool, + }, + #[command(flatten)] + Recipe(Recipe), + /// Admit a batch of documents, whole or not at all, and read it back. + #[command(after_help = ADMIT_HELP)] + Admit { + #[arg(long, value_parser = faculty)] + faculty: Faculty, + /// The admitting agent's name. + #[arg(long)] + agent: String, + /// The admitting session. + #[arg(long)] + session: String, + /// The documents file, or - for stdin. + file: String, + }, + /// The JSON Schema of a document kind or of a selection; no name lists them. + Schema { name: Option }, +} + +fn faculty(text: &str) -> Result { + serde_json::from_value(Value::String(text.to_string())).map_err(|error| error.to_string()) +} + +/// The version, then the sha the binary was built from. +fn version() -> &'static str { + static VERSION: OnceLock = OnceLock::new(); + VERSION.get_or_init(|| format!("{} ({})", env!("CARGO_PKG_VERSION"), build_sha())) +} + +fn build_sha() -> &'static str { + option_env!("HUGINN_BUILD_SHA").unwrap_or("unknown") +} + +/// The command line, or the exit code of having answered it (help, version) or +/// refused it (usage, as `InvalidInput`). +fn parse() -> Result { + let mut command = Cli::command().version(version()); + for (name, text) in recipes::help() { + command = command.mut_subcommand(name, |recipe| recipe.after_help(text)); + } + match command.try_get_matches() { + Ok(matches) => Cli::from_arg_matches(&matches).map_err(|error| usage(&error)), + Err(error) => Err(usage(&error)), + } +} + +fn usage(error: &clap::Error) -> u8 { + if error.use_stderr() { + let text = error.to_string(); + eprintln!("InvalidInput: {}", text.trim_start_matches("error: ").trim_end()); + 2 + } else { + let _ = error.print(); + 0 + } +} + +fn main() -> ExitCode { + let cli = match parse() { + Ok(cli) => cli, + Err(code) => return ExitCode::from(code), + }; + match run(&Settings::from_env(), cli.command) { + Ok(code) => ExitCode::from(code), + Err(trouble) => { + eprintln!("{trouble}"); + ExitCode::from(trouble.exit()) + } + } +} + +fn say(text: impl std::fmt::Display) { + let _ = writeln!(std::io::stdout(), "{text}"); +} + +fn run(settings: &Settings, command: Command) -> Result { + match command { + Command::Whoami => whoami(settings), + Command::View { id, raw } => view(settings, &id, raw), + Command::Query { selection, near, top_k, documents } => { + let selection = door::selection(&door::argument(&selection)?)?; + let semantic = near.map(|text| SemanticQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K) }); + query(settings, selection, semantic, None, documents) + } + Command::Recipe(recipe) => { + let ask = recipe.ask()?; + query(settings, door::selection_value(ask.selection)?, ask.semantic, ask.page.cursor, ask.page.documents) + } + Command::Admit { faculty, agent, session, file } => admit(settings, faculty, agent, session, &file), + Command::Schema { name } => { + let text = schema::text(name.as_deref())?; + let _ = write!(std::io::stdout(), "{text}"); + Ok(0) + } + } +} + +fn whoami(settings: &Settings) -> Result { + say(format_args!("instance {}", settings.instance.as_deref().unwrap_or("unset"))); + say(format_args!("endpoint {}", settings.endpoint.as_deref().unwrap_or("unset"))); + say(format_args!("client {}", build_sha())); + match settings.ask(|_| HuginnMindRequest::Whoami) { + Ok(HuginnMindResponse::Whoami(status)) => { + say("reachable yes"); + say(format_args!( + "documents {} receipts {} index {} epoch {}", + status.documents, + status.receipts, + render::index_state(&status.index), + status.schema_epoch + )); + Ok(0) + } + Ok(other) => { + say("reachable no"); + Err(unexpected(&other)) + } + Err(trouble) => { + say("reachable no"); + Err(trouble) + } + } +} + +fn view(settings: &Settings, id: &str, raw: bool) -> Result { + let reference = PipelineRef { kind: door::kind_of(id)?, id: Short(id.to_string()) }; + match settings.ask(|instance| HuginnMindRequest::View { instance: instance.clone(), id: reference })? { + HuginnMindResponse::View(Some(found)) => { + say(render::view(&found, raw)); + Ok(0) + } + HuginnMindResponse::View(None) => { + say(format_args!("absent {id}")); + Ok(1) + } + HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)), + other => Err(unexpected(&other)), + } +} + +fn query( + settings: &Settings, + mut selection: Selection, + semantic: Option, + cursor: Option, + documents: bool, +) -> Result { + if documents { + selection.projection = PROJECTION_DOCUMENT.to_string(); + } + if cursor.is_some() { + selection.cursor = cursor; + } + selection.limit.get_or_insert(DEFAULT_LIMIT); + match settings.ask(|instance| HuginnMindRequest::Query { instance: instance.clone(), selection, semantic })? { + HuginnMindResponse::Query(page) => { + show(&page); + Ok(0) + } + HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)), + other => Err(unexpected(&other)), + } +} + +fn show(page: &PipelineSelectionPage) { + match &page.items { + PipelinePageItems::Headers(headers) => { + for header in headers { + say(render::header(header)); + } + } + PipelinePageItems::Documents(views) => { + for (index, found) in views.iter().enumerate() { + if index > 0 { + say(""); + } + say(render::view(found, false)); + } + } + } + say(format_args!("matched {} as_of {} next {}", page.matched, page.as_of, page.next.as_deref().unwrap_or("-"))); +} + +fn admit(settings: &Settings, faculty: Faculty, agent: String, session: String, file: &str) -> Result { + let documents = door::documents(&door::file_or_stdin(file)?)?; + if documents.is_empty() || documents.len() > BATCH_MAX { + return Err(Trouble::invalid(format!("a batch is 1 to {BATCH_MAX} documents, this one has {}", documents.len()))); + } + let sent = documents.clone(); + let provenance = PipelineProvenance { faculty, agent: Short(agent), session: Short(session), tool: Short(TOOL.to_string()) }; + let request = |instance: &Slug| { + HuginnMindRequest::Admit(PipelineAdmissionBatch { instance: instance.clone(), provenance, documents }) + }; + match settings.ask(request)? { + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { receipt_id, committed_at, writes }) => { + say(format_args!("Committed {receipt_id} at {committed_at}")); + for write in &writes { + say(format_args!("wrote {}", write.id.0)); + } + read_back(settings, &sent) + } + HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id }) => { + say(format_args!("AlreadyAdmitted {receipt_id}")); + read_back(settings, &sent) + } + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(refusal)) | HuginnMindResponse::Refused(refusal) => { + Ok(refused(&refusal)) + } + HuginnMindResponse::Admit(PipelineAdmissionOutcome::Conflict { identities }) => { + say("Conflict"); + for identity in &identities { + say(format_args!("conflicts {}", identity.id.0)); + } + Ok(1) + } + other => Err(unexpected(&other)), + } +} + +/// The end-to-end check that the mind kept what was sent: one `keys` query, +/// each stored document compared with the one sent. A difference is exit 5, +/// not silence, whatever dropped it (an older leaf, a serde default, skew). +fn read_back(settings: &Settings, sent: &[PipelineDocument]) -> Result { + let keys = sent + .iter() + .map(|document| pipeline_key(document).map_err(|refusal| Trouble::Internal(format!("a committed document has no key: {refusal:?}")))) + .collect::, _>>()?; + let selection = Selection { + keys: Some(keys.clone()), + projection: PROJECTION_DOCUMENT.to_string(), + limit: Some(u32::try_from(keys.len()).unwrap_or(u32::MAX)), + ..Selection::default() + }; + let page = match settings.ask(|instance| HuginnMindRequest::Query { instance: instance.clone(), selection, semantic: None })? { + HuginnMindResponse::Query(page) => page, + other => return Err(unexpected(&other)), + }; + let PipelinePageItems::Documents(stored) = page.items else { + return Err(Trouble::Internal("the read-back answered headers, not documents".to_string())); + }; + let mut differing = Vec::new(); + for (key, document) in keys.iter().zip(sent) { + let held = stored.iter().find(|found| &found.id.id.0 == key); + match held { + None => differing.push(format!("{key}: missing from the read-back")), + Some(found) if found.document == *document => {} + Some(found) => { + let (mut paths, sent_value, held_value) = (Vec::new(), to_value(document), to_value(&found.document)); + differences(&sent_value, &held_value, "", &mut paths); + let shown = if paths.is_empty() { "the document".to_string() } else { paths.join(", ") }; + differing.push(format!("{key}: {shown}")); + } + } + } + if differing.is_empty() { + say(format_args!("read back {} equal", sent.len())); + Ok(0) + } else { + Err(Trouble::ReadBackDiffers(format!( + "{}; the batch is committed, so supersede the documents that differ", + differing.join("; ") + ))) + } +} + +fn to_value(document: &PipelineDocument) -> Value { + serde_json::to_value(document).unwrap_or(Value::Null) +} + +/// The field paths at which two documents differ, including a field only one +/// of them carries. +fn differences(sent: &Value, held: &Value, path: &str, out: &mut Vec) { + match (sent, held) { + (Value::Object(sent), Value::Object(held)) => { + for key in sent.keys().chain(held.keys().filter(|key| !sent.contains_key(*key))) { + let here = if path.is_empty() { key.clone() } else { format!("{path}.{key}") }; + match (sent.get(key), held.get(key)) { + (Some(sent), Some(held)) => differences(sent, held, &here, out), + _ => out.push(here), + } + } + } + (Value::Array(sent), Value::Array(held)) if sent.len() == held.len() => { + for (index, (sent, held)) in sent.iter().zip(held).enumerate() { + differences(sent, held, &format!("{path}[{index}]"), out); + } + } + _ if sent == held => {} + _ => out.push(path.to_string()), + } +} + +/// A refusal is the mind's answer: stdout, exit 1. +fn refused(refusal: &MindRefusal) -> u8 { + say(format_args!("Refused {refusal:?}")); + 1 +} + +fn unexpected(answer: &HuginnMindResponse) -> Trouble { + let name = match answer { + HuginnMindResponse::Whoami(_) => "whoami", + HuginnMindResponse::Admit(_) => "admit", + HuginnMindResponse::View(_) => "view", + HuginnMindResponse::Query(_) => "query", + HuginnMindResponse::Refused(_) => "a refusal", + HuginnMindResponse::Deferred(_) => "a deferral", + }; + Trouble::Internal(format!("the daemon answered with {name}, which this call did not ask for")) +} diff --git a/crates/eureka-state/src/bin/huginn/recipes.rs b/crates/eureka-state/src/bin/huginn/recipes.rs new file mode 100644 index 0000000..78bf092 --- /dev/null +++ b/crates/eureka-state/src/bin/huginn/recipes.rs @@ -0,0 +1,287 @@ +//! The campaign progress view as named subcommands. Each recipe is exactly the +//! selection `references/campaign-state.md` gives (Eureka 278b881, "Recipes"), +//! built per call and never cached. Each one's `--help` prints its selection. + +use clap::{Args, Subcommand}; +use huginn_mind::SemanticQuery; +use huginn_mind::eureka_pipeline::{Line, PipelineKind}; +use serde_json::{Value, json}; + +use crate::door; +use crate::trouble::Trouble; + +/// The campaign whose `gap-` follow-ups are the substrate's known gaps. +const GAP_HOME: &str = "eureka-substrate"; +/// How many neighbours `precedent` asks the index for. +const PRECEDENT_TOP_K: u32 = 10; +/// Severities that block a cut. +const BLOCKING: [&str; 2] = ["Blocker", "High"]; + +#[derive(Args, Clone, Debug, Default)] +pub struct Paging { + /// The `next` cursor of the previous page. + #[arg(long)] + pub cursor: Option, + /// Print each item as `view` does, not as a header line. + #[arg(long)] + pub documents: bool, +} + +#[derive(Subcommand, Debug)] +pub enum Recipe { + /// Every campaign. + Campaigns(Paging), + /// The target in force. + Target { + campaign: String, + #[command(flatten)] + page: Paging, + }, + /// Rulings in force, operator directions included. + Rulings { + #[arg(required = true)] + campaigns: Vec, + #[command(flatten)] + page: Paging, + }, + /// Open questions and follow-ups. + Open { + #[arg(required = true)] + campaigns: Vec, + #[command(flatten)] + page: Paging, + }, + /// Open findings admitted by Soul. + Findings { + campaign: String, + /// Only Blocker and High. + #[arg(long)] + blocking: bool, + #[command(flatten)] + page: Paging, + }, + /// Specs with no report. + Unreported { + campaign: String, + #[command(flatten)] + page: Paging, + }, + /// Open questions raised in a spec: the spec waits on the operator. + Blocked { + spec_id: String, + #[command(flatten)] + page: Paging, + }, + /// Reports with no verdict: Soul's queue. + Unjudged { + campaign: String, + #[command(flatten)] + page: Paging, + }, + /// A subject's resolutions, newest first. + History { + id: String, + #[command(flatten)] + page: Paging, + }, + /// One cut's spec, reports, verdicts, findings and resolutions. + Cut { + campaign: String, + label: String, + #[command(flatten)] + page: Paging, + }, + /// Every spec and report, whole. + Ledger { + campaign: String, + #[command(flatten)] + page: Paging, + }, + /// Rulings, resolutions and findings nearest a question in plain words. + Precedent { + campaign: String, + text: String, + #[command(flatten)] + page: Paging, + }, + /// The substrate's known gaps. + Gaps(Paging), +} + +/// What a recipe asks the mind: the selection, the semantic part when it has +/// one, and the caller's paging flags. +pub struct Ask { + pub selection: Value, + pub semantic: Option, + pub page: Paging, +} + +impl Recipe { + pub fn ask(self) -> Result { + let (selection, semantic, page) = match self { + Self::Campaigns(page) => (campaigns(), None, page), + Self::Target { campaign, page } => (target(&campaign), None, page), + Self::Rulings { campaigns, page } => (rulings(&campaigns), None, page), + Self::Open { campaigns, page } => (open(&campaigns), None, page), + Self::Findings { campaign, blocking, page } => (findings(&campaign, blocking), None, page), + Self::Unreported { campaign, page } => (unreported(&campaign), None, page), + Self::Blocked { spec_id, page } => (blocked(&spec_id), None, page), + Self::Unjudged { campaign, page } => (unjudged(&campaign), None, page), + Self::History { id, page } => (history(door::kind_of(&id)?.type_id(), &id), None, page), + Self::Cut { campaign, label, page } => (cut(&campaign, &label), None, page), + Self::Ledger { campaign, page } => (ledger(&campaign), None, page), + Self::Precedent { campaign, text, page } => { + (precedent(&campaign), Some(SemanticQuery { text: Line(text), top_k: PRECEDENT_TOP_K }), page) + } + Self::Gaps(page) => (gaps(), None, page), + }; + Ok(Ask { selection, semantic, page }) + } +} + +fn schema(kind: PipelineKind) -> &'static str { + kind.type_id() +} + +fn root(campaigns: &[String]) -> Value { + json!({ "index": "root", "op": "any_of", "values": campaigns }) +} + +fn in_force() -> Value { + json!({ "index": "in_force", "op": "any_of", "values": ["true"] }) +} + +fn severity_blocking() -> Value { + json!({ "index": "severity", "op": "any_of", "values": BLOCKING }) +} + +fn campaigns() -> Value { + json!({ "schemas": [schema(PipelineKind::Campaign)] }) +} + +fn target(campaign: &str) -> Value { + json!({ + "schemas": [schema(PipelineKind::Target)], + "fields": [root(&[campaign.to_string()]), in_force()], + "projection": "document", + }) +} + +fn rulings(campaigns: &[String]) -> Value { + json!({ "schemas": [schema(PipelineKind::Ruling)], "fields": [root(campaigns), in_force()] }) +} + +fn open(campaigns: &[String]) -> Value { + json!({ + "schemas": [schema(PipelineKind::Question), schema(PipelineKind::FollowUp)], + "fields": [root(campaigns), in_force()], + }) +} + +fn findings(campaign: &str, blocking: bool) -> Value { + let mut fields = vec![ + root(&[campaign.to_string()]), + in_force(), + json!({ "index": "faculty", "op": "any_of", "values": ["Soul"] }), + ]; + if blocking { + fields.push(severity_blocking()); + } + json!({ "schemas": [schema(PipelineKind::Finding)], "fields": fields }) +} + +fn unreported(campaign: &str) -> Value { + json!({ + "schemas": [schema(PipelineKind::CutSpec)], + "fields": [root(&[campaign.to_string()]), in_force()], + "cited": { "role": "cut_spec", "exists": false }, + }) +} + +fn blocked(spec_id: &str) -> Value { + json!({ + "schemas": [schema(PipelineKind::Question)], + "fields": [in_force()], + "cites": { "target": { "schemaId": schema(PipelineKind::CutSpec), "recordKey": spec_id }, "role": "raised_in" }, + }) +} + +fn unjudged(campaign: &str) -> Value { + json!({ + "schemas": [schema(PipelineKind::CutReport)], + "fields": [root(&[campaign.to_string()])], + "cited": { "role": "cut_report", "exists": false }, + }) +} + +fn history(schema_id: &str, id: &str) -> Value { + json!({ + "schemas": [schema(PipelineKind::Resolution)], + "cites": { "target": { "schemaId": schema_id, "recordKey": id }, "role": "subject" }, + "descending": true, + }) +} + +fn cut(campaign: &str, label: &str) -> Value { + json!({ + "schemas": [ + schema(PipelineKind::CutSpec), + schema(PipelineKind::CutReport), + schema(PipelineKind::Verdict), + schema(PipelineKind::Finding), + schema(PipelineKind::Resolution), + ], + "fields": [root(&[campaign.to_string()]), { "index": "cut", "op": "any_of", "values": [label] }], + }) +} + +fn ledger(campaign: &str) -> Value { + json!({ + "schemas": [schema(PipelineKind::CutSpec), schema(PipelineKind::CutReport)], + "fields": [root(&[campaign.to_string()])], + "projection": "document", + }) +} + +fn precedent(campaign: &str) -> Value { + json!({ + "schemas": [schema(PipelineKind::Ruling), schema(PipelineKind::Resolution), schema(PipelineKind::Finding)], + "fields": [root(&[campaign.to_string()])], + }) +} + +fn gaps() -> Value { + json!({ + "schemas": [schema(PipelineKind::FollowUp)], + "fields": [root(&[GAP_HOME.to_string()]), in_force()], + }) +} + +/// Each recipe's `--help` addendum: its selection with placeholders in the +/// arguments' places. The same builders as execution, so help cannot drift +/// from what runs. +pub fn help() -> Vec<(&'static str, String)> { + let c = || "".to_string(); + let selection = |value: Value| format!("selection:\n{}", serde_json::to_string_pretty(&value).unwrap_or_default()); + let findings_help = + format!("{}\n--blocking adds the field predicate:\n{}", selection(findings(&c(), false)), severity_blocking()); + let precedent_help = format!( + "{}\nsemantic: {{ \"text\": \"\", \"top_k\": {PRECEDENT_TOP_K} }}", + selection(precedent(&c())) + ); + vec![ + ("campaigns", selection(campaigns())), + ("target", selection(target(&c()))), + ("rulings", selection(rulings(&[c()]))), + ("open", selection(open(&[c()]))), + ("findings", findings_help), + ("unreported", selection(unreported(&c()))), + ("blocked", selection(blocked(""))), + ("unjudged", selection(unjudged(&c()))), + ("history", selection(history("epiphany.pipeline..v2", ""))), + ("cut", selection(cut(&c(), "