Give the mind a wire vocabulary and publish its two schemas

`huginn-mind::wire` names one request per `Mind` method plus `whoami`, and one
response per method plus the read side's refusal. The payloads are the
admission and read types unchanged, so a client speaks the organ's own
vocabulary rather than a projection of it. `operation()` and `instance()` say
what the envelope carries; `status()` derives the envelope's status from the
response, and a refusal is data in that response rather than a transport
failure.

`Mind::status` answers `whoami` from the image every time it is asked: the
instance, the schema epoch, and how many pipeline documents and receipts the
mind holds. Nothing stores a status and no configuration supplies one.

`schemas/cultnet/` publishes the JSON schema of each wire type, derived from
the type and pinned to it byte for byte by a test, so the schema catalog
cannot advertise a shape the crate does not speak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-16 22:52:53 +02:00
commit c4b2a0fa2e
7 changed files with 5229 additions and 0 deletions

1
Cargo.lock generated
View file

@ -252,6 +252,7 @@ dependencies = [
"rmp-serde",
"schemars",
"serde",
"serde_json",
"sha2",
"tempfile",
]

View file

@ -21,4 +21,5 @@ serde = { version = "1", features = ["derive"] }
sha2 = "0.10"
[dev-dependencies]
serde_json = "1"
tempfile = "3"

View file

@ -13,6 +13,9 @@
//! The read side derives status, joins the receipts and answers typed queries
//! through the same `docs` the rules use; nothing is stored for it.
//!
//! The wire vocabulary is here too, so the daemon and the client share one set
//! of types.
//!
//! Document shape, bounds, formats and keys are `epiphany-pipeline`'s; the
//! organ registers, prepares, decodes and validates through the leaf's four
//! doors and never re-derives a key. The store is CultLib's owned redb
@ -27,6 +30,7 @@ pub mod query;
pub mod receipt;
pub mod refusal;
pub mod store;
pub mod wire;
#[cfg(test)]
pub(crate) mod fixtures;
@ -40,6 +44,10 @@ pub use query::{
pub use receipt::{DocumentVersion, Faculty, HuginnCommitReceipt, PipelineProvenance, RECEIPT_SCHEMA_VERSION};
pub use refusal::MindRefusal;
pub use store::{MindStore, OwnedRedbMessagePackBackingStore};
pub use wire::{
HuginnMindRequest, HuginnMindResponse, MIND_REQUEST_SCHEMA, MIND_REQUEST_SCHEMA_JSON, MIND_RESPONSE_SCHEMA,
MIND_RESPONSE_SCHEMA_JSON, MIND_SERVICE_ID, MindStatus,
};
/// The leaf, whole, through the one crate that pins its rev: the daemon and
/// the client name `Slug`, `PipelineRef` and the document types from here, so

View file

@ -0,0 +1,217 @@
//! The organ's request and response vocabulary: one operation per `Mind`
//! method and `whoami`. Payloads are the admission and read types unchanged.
//! The daemon carries these in CultNet's operation envelope; the client
//! constructs them. Nothing here validates a document, derives a status or
//! names a transport.
use cultcache_rs::DatabaseEntry;
use epiphany_pipeline::{PIPELINE_SCHEMA_EPOCH, PipelineKind, PipelineRef, Slug};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::admission::{PipelineAdmissionBatch, PipelineAdmissionOutcome};
use crate::mind::Mind;
use crate::query::{
HistoryScope, PipelineDocumentView, PipelineOpenItems, PipelineQuery, PipelineQueryPage,
};
use crate::receipt::HuginnCommitReceipt;
use crate::refusal::MindRefusal;
use crate::store::MindStore;
/// The envelope's `service_id`: one service, one mind, whichever mind the
/// process opened.
pub const MIND_SERVICE_ID: &str = "huginn.mind";
pub const MIND_REQUEST_SCHEMA: &str = "huginn.mind_request.v1";
pub const MIND_RESPONSE_SCHEMA: &str = "huginn.mind_response.v1";
/// The published JSON schema of each wire type, as the catalog advertises it.
/// The files are derived from the types below and pinned byte for byte by
/// `published_wire_schemas_match_derivation`, so the catalog cannot advertise
/// a shape the crate does not speak.
pub const MIND_REQUEST_SCHEMA_JSON: &str =
include_str!("../../../schemas/cultnet/huginn.mind_request.v1.schema.json");
pub const MIND_RESPONSE_SCHEMA_JSON: &str =
include_str!("../../../schemas/cultnet/huginn.mind_response.v1.schema.json");
/// Every operation a mind answers. `Admit` carries the batch whole because the
/// batch already names the instance and the asker; a second `instance` beside
/// it would be two declarations. Every read names the instance because ruling
/// 14 refuses another mind's identity whatever the transport, and a read of
/// the wrong mind is that same collision on the read side. `Whoami` names
/// none: it is how a client learns which mind it reached.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum HuginnMindRequest {
Whoami,
Admit(PipelineAdmissionBatch),
View { instance: Slug, id: PipelineRef },
Query { instance: Slug, query: PipelineQuery },
OpenItems { instance: Slug, campaign: Slug },
History { instance: Slug, scope: HistoryScope },
}
impl HuginnMindRequest {
/// The envelope's `operation`.
pub fn operation(&self) -> &'static str {
match self {
Self::Whoami => "whoami",
Self::Admit(_) => "admit",
Self::View { .. } => "view",
Self::Query { .. } => "query",
Self::OpenItems { .. } => "open_items",
Self::History { .. } => "history",
}
}
/// The instance the request declares; `Whoami` declares none.
pub fn instance(&self) -> Option<&Slug> {
match self {
Self::Whoami => None,
Self::Admit(batch) => Some(&batch.instance),
Self::View { instance, .. }
| Self::Query { instance, .. }
| Self::OpenItems { instance, .. }
| Self::History { instance, .. } => Some(instance),
}
}
}
/// Every answer a mind gives. A refusal is data in the response, never a
/// transport failure: the read side carries `Refused`, the write side carries
/// the outcome's own `Refused`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum HuginnMindResponse {
Whoami(MindStatus),
Admit(PipelineAdmissionOutcome),
View(Option<PipelineDocumentView>),
Query(PipelineQueryPage),
OpenItems(PipelineOpenItems),
History(Vec<PipelineDocumentView>),
Refused(MindRefusal),
}
impl HuginnMindResponse {
/// The envelope's `status`, in the C# reference's vocabulary: `rejected`
/// exactly for a refusal, `accepted` otherwise. `Conflict` and
/// `AlreadyAdmitted` are answers about the mind, not refusals of the
/// request.
pub fn status(&self) -> &'static str {
match self {
Self::Refused(_) | Self::Admit(PipelineAdmissionOutcome::Refused(_)) => "rejected",
_ => "accepted",
}
}
}
/// What a mind says about itself: the typed state a dashboard projects.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct MindStatus {
pub instance: Slug,
pub schema_epoch: String,
/// Pipeline documents in the image; the epoch record and the receipts are
/// not documents and are not counted here.
pub documents: u32,
pub receipts: u32,
}
impl<S: MindStore> Mind<S> {
/// `whoami`, derived from the image every time it is asked. Nothing stores
/// a status and no configuration supplies one.
pub fn status(&self) -> MindStatus {
let mut documents = 0_u32;
let mut receipts = 0_u32;
for envelope in self.envelopes() {
if envelope.r#type == HuginnCommitReceipt::TYPE {
receipts += 1;
} else if PipelineKind::ALL.iter().any(|kind| kind.type_id() == envelope.r#type) {
documents += 1;
}
}
MindStatus {
instance: self.instance().clone(),
schema_epoch: PIPELINE_SCHEMA_EPOCH.to_string(),
documents,
receipts,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures::{CAMPAIGN, INSTANCE, OTHER_INSTANCE, instance, provenance, r, repo, seeded, slug};
use crate::receipt::Faculty;
fn requests() -> Vec<HuginnMindRequest> {
let batch = PipelineAdmissionBatch {
instance: slug(INSTANCE),
provenance: provenance(Faculty::Hands),
documents: vec![instance(INSTANCE)],
};
vec![
HuginnMindRequest::Whoami,
HuginnMindRequest::Admit(batch),
HuginnMindRequest::View { instance: slug(INSTANCE), id: r(PipelineKind::Campaign, CAMPAIGN) },
HuginnMindRequest::Query { instance: slug(INSTANCE), query: PipelineQuery::default() },
HuginnMindRequest::OpenItems { instance: slug(INSTANCE), campaign: slug(CAMPAIGN) },
HuginnMindRequest::History {
instance: slug(OTHER_INSTANCE),
scope: HistoryScope::Repo(repo("GameCult/Huginn")),
},
]
}
fn responses() -> Vec<HuginnMindResponse> {
let refusal = MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() };
vec![
HuginnMindResponse::Whoami(seeded().status()),
HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: "r1".into() }),
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Conflict { identities: vec![] }),
HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(refusal.clone())),
HuginnMindResponse::View(None),
HuginnMindResponse::Query(PipelineQueryPage { items: vec![], matched: 0 }),
HuginnMindResponse::OpenItems(seeded().open_items(&slug(CAMPAIGN)).unwrap()),
HuginnMindResponse::History(vec![]),
HuginnMindResponse::Refused(refusal),
]
}
/// The vocabulary is the mind's methods, the declared instance is on every
/// request that names a mind, and the envelope's status is derived from
/// the response rather than chosen by whoever sends it.
#[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", "open_items", "history"]);
for request in requests() {
let declared = request.instance().is_none();
assert_eq!(declared, matches!(request, HuginnMindRequest::Whoami), "{request:?}");
let bytes = rmp_serde::to_vec_named(&request).unwrap();
assert_eq!(rmp_serde::from_slice::<HuginnMindRequest>(&bytes).unwrap(), request);
}
assert_eq!(
requests()[5].instance(),
Some(&slug(OTHER_INSTANCE)),
"the request's instance is the one it declares, not the mind's"
);
for response in responses() {
let rejected = matches!(
response,
HuginnMindResponse::Refused(_) | HuginnMindResponse::Admit(PipelineAdmissionOutcome::Refused(_))
);
assert_eq!(response.status(), if rejected { "rejected" } else { "accepted" }, "{response:?}");
let bytes = rmp_serde::to_vec_named(&response).unwrap();
assert_eq!(rmp_serde::from_slice::<HuginnMindResponse>(&bytes).unwrap(), response);
}
}
/// The two files the catalog publishes are the derivation of the two
/// types, byte for byte.
#[test]
fn published_wire_schemas_match_derivation() {
let request = serde_json::to_string_pretty(&schemars::schema_for!(HuginnMindRequest)).unwrap();
let response = serde_json::to_string_pretty(&schemars::schema_for!(HuginnMindResponse)).unwrap();
assert_eq!(MIND_REQUEST_SCHEMA_JSON.replace("\r\n", "\n"), request);
assert_eq!(MIND_RESPONSE_SCHEMA_JSON.replace("\r\n", "\n"), response);
}
}

11
schemas/cultnet/README.md Normal file
View file

@ -0,0 +1,11 @@
# Huginn's CultNet contracts
`huginn.mind_request.v1` and `huginn.mind_response.v1` are the organ's own wire
schemas, published from here because Huginn owns them. `huginn-daemon` serves
them on a `cultnet.schema_catalog_request.v0`; nothing else reads them.
Each file is `serde_json::to_string_pretty` of `schemars::schema_for!` over the
type of the same name in `crates/huginn-mind/src/wire.rs`. Regenerate by hand
and the test refuses it:
`cargo test -p huginn-mind --lib -- --exact wire::tests::published_wire_schemas_match_derivation`
compares file to derivation byte for byte.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff