Huginn/crates/huginn-mind/src/refusal.rs

251 lines
12 KiB
Rust

//! Typed refusals of the organ. A refusal is data with a field an agent can
//! act on, never a transport error: the outcome carries it, the wire (Cut 10)
//! serialises it, the tools (Cut 13) show it.
use cultnet_rs::SelectionRefusal;
use eureka_pipeline::{PipelineKind, PipelineRef, PipelineRefusal, Short};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Every way a mind refuses. `Document` wraps the leaf's four (bounds,
/// formats, key identity, foreign type); the rest are the organ's: store
/// identity, epoch, ownership, batch shape, references, and every cross-field
/// and cross-document rule of admission.
///
/// The two sequenced kinds refuse in the same pair of shapes: an
/// `OutOfSequence` when the writer's `sequence` is not the previous plus one,
/// and `AlreadyResolved`/`AlreadyStewarded` when a record of that scope is
/// still in force.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum MindRefusal {
Document(PipelineRefusal),
ForeignInstance { declared: String, mind: String },
MissingIdentity,
ForeignEpoch { found: String, expected: String },
ForeignStore { r#type: String },
MindAlreadyOwned { path: String },
BatchSize { actual: u32 },
IdentityCollision { kind: PipelineKind, id: String },
MissingReference { kind: PipelineKind, id: String },
/// `closed_by` is the resolution in force: to correct the closure, withdraw it (a resolution of `closed_by`), then resolve `subject` again at the next sequence.
AlreadyResolved { subject: String, closed_by: String },
ResolutionOutOfSequence { subject: String, expected: u32, actual: u32 },
AlreadyStewarded { repo: String },
StewardshipOutOfSequence { repo: String, expected: u32, actual: u32 },
IncompatibleResolution { subject_kind: PipelineKind, outcome: String },
WouldReinstateOverLater { subject: String, later: String },
CitesResolvedDocument { kind: PipelineKind, id: String },
EmptySupersession,
UnknownSupersessor { id: String },
/// A consolidation and the supersession of the memory it consumes come together: `memory` consolidates `consumed`, or is named as superseding it, without the other half in the batch.
UnpairedConsolidation { memory: String, consumed: String },
RevisionWithoutSupersession { kind: PipelineKind, revision: u32 },
DuplicateLabel { field: String, label: String },
InvalidOptions { question: String },
InvalidChoice { ruling: String, choice: String },
QuoteWithoutOperator { ruling: String },
/// A `desk_answer` reached a door other than the desk's (an admit, an import or a derivation): `id` is its key.
DeskAnswerOutsideDesk { id: String },
/// A desk request the Mind cannot read as an answer: `field` names the part (the fork, `answered_at`, an answer carrying neither choice nor note, a malformed answer id).
DeskRequestInvalid { field: String },
/// The desk's `request_id_conflict` (CultLib PR #72): this `(answerer, request_id)` already bound another body. Nothing is stored.
DeskRequestIdConflict { request_id: String },
/// `campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.
EmptyRepos { campaign: String },
RepoNotInCampaign { repo: String },
UnknownDependency { cut: String },
CutReportWithoutSpec { report: String },
SpecMismatch { field: String },
RangeOutsideCommits { head: String },
FalsifiedClaimWithoutConfirmedFinding { claim: String },
UnprovenClaimWithConfirmedFinding { claim: String },
PromiseWithoutVerdict { promise: String },
UnknownMutationLabel { label: String },
FindingWithoutRange,
FindingWithoutEvidence,
UnknownInvariant { label: String },
NotStewarded { repo: String },
/// `campaign` moved to mind `to`: nothing is written under its root here any more, whoever asks.
CampaignMoved { campaign: String, to: String },
/// A campaign moves to another mind, never to the one that gives it up.
MoveToSelf,
/// A Moved resolution is final; withdrawing it would leave two minds keeping the campaign.
MoveIsFinal,
/// An import names this mind as its source.
ImportFromSelf,
/// An import carries a document of `root`, which is no campaign this mind holds or the batch carries, or under which this mind holds a native write that is not a follow-up or idea admitted after the import began, or which cites or resolves one of its own native documents.
ImportOutsideCampaign { root: String },
/// A move names `root`, this mind's own instance: an instance's records are not a campaign's to freeze.
MoveOfInstanceRoot { root: String },
/// An import lists `id` as external, but this mind (or the batch) holds it.
ExternalPresent { id: String },
/// The persona document fails the published schema: `path` is the
/// document location (a key the schema does not declare is `*`) and
/// `message` the schema keyword that failed. Never the offending value.
PersonaInvalid { path: String, message: String },
/// The document's `personaId` is not this mind's instance, which is
/// `instance`.
PersonaForeign { instance: String },
/// The put's `expected_updated_at` is not the stored document's
/// `updatedAt`, which is `stored` (none when no persona is stored).
PersonaStale { stored: Option<String> },
/// The document's `provenance.authority` is not `canonical`: a projection
/// or an import is not the mind's persona.
PersonaNotCanonical,
/// A run claims work another run in force already holds: `item` is the
/// claimed document's id and `run` the id of the run that holds it.
AlreadyClaimed { item: String, run: RunId },
/// A run of hers opens, or is reinstated, while another in-force run of
/// hers of the same instance and turn is live: `run` is the holder's id
/// (ruling one-live-self-run).
AlreadyLive { run: RunId },
/// The one refusal a mind never raises: the answer exceeds the largest
/// body the organ will deliver by any plane, one send or a deferred body.
/// It lives here because a refusal rides the response schema and there is
/// one of those, so a client reads it the way it reads every other refusal
/// rather than parsing a second vocabulary. `bytes` is the encoded answer
/// (the payload, not its envelope) and `limit` is the largest body the
/// daemon defers, both so a caller can narrow its own request; nothing is
/// truncated or paginated on its behalf. The daemon owns it and its
/// deferral bound decides it; no rule of the mind is involved.
ResponseTooLarge { bytes: u64, limit: u64 },
/// A selection the substrate or the organ's value door refused: `field`
/// is the selection field the client sent, `value` the offending part.
SelectionInvalid { field: String, value: Option<String>, message: String },
/// A cursor that does not decode, does not belong to this selection or
/// was not minted by this process, or names a snapshot this mind has not
/// reached.
CursorInvalid { message: String },
Unavailable { detail: String },
}
impl std::fmt::Display for MindRefusal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "mind refusal: {self:?}")
}
}
impl std::error::Error for MindRefusal {}
impl From<PipelineRefusal> for MindRefusal {
fn from(refusal: PipelineRefusal) -> Self {
Self::Document(refusal)
}
}
/// The one total mapping from the substrate's refusals, with no wildcard arm.
/// `CursorStale` is unreachable: the organ answers at the cursor's own `asOf`,
/// so a stale refusal is an organ defect and says so. `ReferenceOutsideTarget`
/// is integrity: A7 admits only referents of the declared kind, so a stored
/// edge outside its target means the store is not what admission wrote.
impl From<SelectionRefusal> for MindRefusal {
fn from(refusal: SelectionRefusal) -> Self {
match refusal {
SelectionRefusal::Invalid(invalid) => {
Self::SelectionInvalid { field: invalid.field, value: invalid.value, message: invalid.message }
}
SelectionRefusal::CursorInvalid { message } => Self::CursorInvalid { message },
SelectionRefusal::CursorStale { as_of, current } => Self::Unavailable {
detail: format!("cursor_stale: the organ answers at the cursor's asOf ({as_of}), yet was asked at {current}"),
},
reference @ SelectionRefusal::ReferenceOutsideTarget { .. } => {
Self::Unavailable { detail: format!("stored reference outside its declared target: {reference}") }
}
}
}
}
/// The id of a run: a full pipeline id of kind Run. A refusal that names a
/// holder carries one, so a reader gets a run's id from the wire only if it
/// parses as one; a peer cannot fill it with any other string. The only ways
/// in are `TryFrom<String>`, which every deserialized value passes, and the
/// admission rules, which name a run the mind holds. The error is a fixed
/// message: it never echoes the offered value.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(try_from = "String", into = "String")]
pub struct RunId(String);
impl RunId {
/// The id as text.
pub fn as_str(&self) -> &str {
&self.0
}
/// The reference to the run this id names.
pub fn to_ref(&self) -> PipelineRef {
PipelineRef { kind: PipelineKind::Run, id: Short(self.0.clone()) }
}
/// The key of a run the mind holds, which admission already held to the
/// grammar when it admitted the run.
pub(crate) fn held(key: &str) -> Self {
Self(key.into())
}
}
impl TryFrom<String> for RunId {
type Error = &'static str;
fn try_from(id: String) -> Result<Self, Self::Error> {
let candidate = PipelineRef { kind: PipelineKind::Run, id: Short(id) };
candidate.validate_ref().map_err(|_| "not a run id")?;
Ok(Self(candidate.id.0))
}
}
impl From<RunId> for String {
fn from(id: RunId) -> Self {
id.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use cultnet_rs::SelectionInvalid;
/// The mapping is the organ's word on each substrate refusal: a bad
/// selection and a bad cursor cross as themselves, and the two the organ
/// is built never to raise cross as integrity faults, not as anything a
/// client could act on.
#[test]
fn each_substrate_refusal_maps_to_one_organ_refusal() {
let invalid = SelectionInvalid { field: "keys".into(), value: Some("x".into()), message: "no".into() };
assert_eq!(
MindRefusal::from(SelectionRefusal::Invalid(invalid)),
MindRefusal::SelectionInvalid { field: "keys".into(), value: Some("x".into()), message: "no".into() }
);
assert_eq!(
MindRefusal::from(SelectionRefusal::CursorInvalid { message: "bad".into() }),
MindRefusal::CursorInvalid { message: "bad".into() }
);
assert!(matches!(
MindRefusal::from(SelectionRefusal::CursorStale { as_of: 3, current: 4 }),
MindRefusal::Unavailable { detail } if detail.contains("cursor_stale")
));
let outside = SelectionRefusal::ReferenceOutsideTarget {
from_schema_id: "a".into(),
from_key: "k".into(),
role: "answers".into(),
to_schema_id: "b".into(),
to_key: "j".into(),
};
assert!(matches!(MindRefusal::from(outside), MindRefusal::Unavailable { .. }));
}
/// A run id from the wire is a run's id or nothing: other kinds' ids and
/// arbitrary strings are refused, and the refusal never repeats the text.
#[test]
fn a_run_id_is_a_run_key_and_never_any_other_string() {
let held = "eureka:run:mind-1";
let id: RunId = serde_json::from_str(&format!("\"{held}\"")).unwrap();
assert_eq!((id.as_str(), serde_json::to_string(&id).unwrap()), (held, format!("\"{held}\"")));
assert_eq!(id.to_ref(), PipelineRef { kind: PipelineKind::Run, id: Short(held.into()) });
for bad in ["not even a key", "eureka:cut_spec:cut-x.r1", "eureka:run:", "eureka:run:a:b", ""] {
let error = serde_json::from_str::<RunId>(&format!("\"{bad}\"")).unwrap_err().to_string();
assert!(error.contains("not a run id") && (bad.is_empty() || !error.contains(bad)), "{error}");
}
let wire = serde_json::from_str::<MindRefusal>(r#"{"AlreadyLive":{"run":"not even a key"}}"#);
assert!(wire.is_err());
}
}