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:
parent
f7170fadf9
commit
c4b2a0fa2e
7 changed files with 5229 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -252,6 +252,7 @@ dependencies = [
|
|||
"rmp-serde",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -21,4 +21,5 @@ serde = { version = "1", features = ["derive"] }
|
|||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
217
crates/huginn-mind/src/wire.rs
Normal file
217
crates/huginn-mind/src/wire.rs
Normal 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
11
schemas/cultnet/README.md
Normal 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.
|
||||
2030
schemas/cultnet/huginn.mind_request.v1.schema.json
Normal file
2030
schemas/cultnet/huginn.mind_request.v1.schema.json
Normal file
File diff suppressed because it is too large
Load diff
2961
schemas/cultnet/huginn.mind_response.v1.schema.json
Normal file
2961
schemas/cultnet/huginn.mind_response.v1.schema.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue