4688 lines
244 KiB
Rust
4688 lines
244 KiB
Rust
//! Eureka pipeline documents: the typed shape of a Eureka campaign's state.
|
||
//!
|
||
//! This is a leaf type library, and deliberately nothing else. It owns document
|
||
//! shape, field bounds, formats, key derivation and the JSON schemas published
|
||
//! under `schemas/cultnet`; it owns no storage, no admission, no process and no
|
||
//! network. That is the whole reason it is a package: the organ that will admit
|
||
//! these documents depends on these types without depending on the harness
|
||
//! that used to hold them.
|
||
//!
|
||
//! Every kind is a plain `serde` + `JsonSchema` value inside a one-slot
|
||
//! `DatabaseEntry` wrapper, always prepared with `prepare_entry_named`, so the
|
||
//! stored payload is `[value]` and the value is the named map the published
|
||
//! schema describes. Keys are semantic: `pipeline_key` derives them from the
|
||
//! value, and the write validator refuses any envelope whose key differs.
|
||
//!
|
||
//! Every key is `<root>:<kind>:<local>`, three segments for every kind with
|
||
//! none excused: the root is a `Slug`, the kind is the literal kind name, and
|
||
//! the local is `Label`s joined by `.`, so no local part carries a dot and a
|
||
//! reader recovers the parts by splitting. A root's local is the constant
|
||
//! `self`; a resolution's local is its subject's kind and local. A
|
||
//! resolution's local ends in its per-subject sequence, `n<N>`, and a
|
||
//! stewardship's in its per-repo sequence, so a subject's resolutions and a
|
||
//! repo's assignments each share a prefix.
|
||
//!
|
||
//! **Validation follows the type.** `value_types!` is the single field list: it
|
||
//! emits the struct and its `Bounded` impl from the same tokens, so a field
|
||
//! cannot be declared without being validated. Format rules live in the field's
|
||
//! type (`Label`, `Slug`, `OrgRepo`, `Sha`, `Sha256Hex`, `Date`), never in a
|
||
//! hand-written impl that a later field can slip past. A `Vec` field must carry
|
||
//! a maximum, because `Vec<T>` has no `Bounded` impl of its own. A field added
|
||
//! at the epoch carries the `= absent` marker, which defaults it on read and
|
||
//! omits it on write when empty.
|
||
//!
|
||
//! The wrappers are crate-private: outside code registers, prepares and
|
||
//! decodes them through `register_pipeline_document_types`,
|
||
//! `PipelineDocument::prepare` and `PipelineDocument::decode`, and validates a
|
||
//! write through `validate_pipeline_write_envelope`.
|
||
|
||
// `anyhow` is the cache's own error type, which `prepare` and the registrar
|
||
// pass through; every refusal this crate decides is a `PipelineRefusal`.
|
||
use anyhow::Result;
|
||
use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry};
|
||
use schemars::JsonSchema;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// Typed refusals of the pipeline documents: bounds, formats and key identity
|
||
/// (D2's document half), and `ForeignStore`, raised by `decode` for an
|
||
/// envelope of any other type. This is every refusal the leaf decides; a
|
||
/// store's identity and its schema epoch are not decided here, since this
|
||
/// crate owns no store, and belong to the organ that will admit these
|
||
/// documents.
|
||
///
|
||
/// Serialised externally tagged, serde's default for an enum: each variant is
|
||
/// a one-key map from the variant's name to its named fields, so `field` and
|
||
/// `value` are on the wire under their own names and a reader of a refusal
|
||
/// carried over a wire reads the same parts a local caller matches on. No
|
||
/// attribute buys that, which is why there is none: an internal or adjacent
|
||
/// tag would move the fields under a content key without making any of them
|
||
/// more visible.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||
pub enum PipelineRefusal {
|
||
FieldBound { field: String, limit: u32, actual: u32 },
|
||
InvalidFormat { field: String, value: String },
|
||
InvalidIdentity { kind: PipelineKind, key: String, expected: String },
|
||
DuplicateRepo { field: String, repo: String },
|
||
ForeignStore { r#type: String },
|
||
}
|
||
|
||
impl std::fmt::Display for PipelineRefusal {
|
||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(formatter, "pipeline refusal: {self:?}")
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for PipelineRefusal {}
|
||
|
||
pub(crate) trait Bounded {
|
||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal>;
|
||
}
|
||
|
||
impl<T: Bounded> Bounded for Option<T> {
|
||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
|
||
self.as_ref().map_or(Ok(()), |value| value.validate(field))
|
||
}
|
||
}
|
||
|
||
/// A value with an empty spelling. The `= absent` marker on a `value_types!`
|
||
/// field requires it, and the field is then omitted on write when it is absent.
|
||
pub(crate) trait Absent {
|
||
fn absent(&self) -> bool;
|
||
}
|
||
|
||
impl<T> Absent for Option<T> {
|
||
fn absent(&self) -> bool {
|
||
self.is_none()
|
||
}
|
||
}
|
||
|
||
impl Absent for bool {
|
||
fn absent(&self) -> bool {
|
||
!*self
|
||
}
|
||
}
|
||
|
||
impl<T> Absent for Vec<T> {
|
||
fn absent(&self) -> bool {
|
||
self.is_empty()
|
||
}
|
||
}
|
||
|
||
macro_rules! unbounded_scalars {
|
||
($($ty:ty),* $(,)?) => {$(
|
||
impl Bounded for $ty {
|
||
fn validate(&self, _field: &str) -> Result<(), PipelineRefusal> {
|
||
Ok(())
|
||
}
|
||
}
|
||
)*};
|
||
}
|
||
|
||
unbounded_scalars!(u8, u16, u32, u64, bool);
|
||
|
||
fn bound(field: &str, limit: usize, actual: usize) -> Result<(), PipelineRefusal> {
|
||
if actual > limit {
|
||
return Err(PipelineRefusal::FieldBound {
|
||
field: field.into(),
|
||
limit: limit as u32,
|
||
actual: actual as u32,
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// List maximums only. Minimum counts are admission rules with their own
|
||
/// refusals (Cut 3b), so a bounds pass never pre-empts them.
|
||
fn list<T: Bounded>(field: &str, items: &[T], max: usize) -> Result<(), PipelineRefusal> {
|
||
bound(field, max, items.len())?;
|
||
for (index, item) in items.iter().enumerate() {
|
||
item.validate(&format!("{field}[{index}]"))?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn format_error(field: &str, value: &str) -> PipelineRefusal {
|
||
PipelineRefusal::InvalidFormat {
|
||
field: field.into(),
|
||
value: value.into(),
|
||
}
|
||
}
|
||
|
||
fn hex(field: &str, value: &str, lengths: std::ops::RangeInclusive<usize>) -> Result<(), PipelineRefusal> {
|
||
let lower_hex = value.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'));
|
||
if !lengths.contains(&value.len()) || !lower_hex {
|
||
return Err(format_error(field, value));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A key label: `[A-Za-z0-9_-]{1,64}`. Dots are excluded so that a composed key
|
||
/// segments unambiguously; see `local`.
|
||
fn label_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
|
||
let valid = value
|
||
.bytes()
|
||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'));
|
||
if !valid || value.is_empty() || value.len() > 64 {
|
||
return Err(format_error(field, value));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A dotted key segment: label parts joined by `.`, at most 64 bytes. Every
|
||
/// part must be a label, so `..`, a leading or trailing dot, and an empty part
|
||
/// are all refused.
|
||
fn dotted_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
|
||
dotted_within(field, value, 64)
|
||
}
|
||
|
||
/// `dotted_text`'s grammar with the bound as a parameter: the one place a
|
||
/// dotted segment is parsed, whoever owns the bound.
|
||
fn dotted_within(field: &str, value: &str, max: usize) -> Result<(), PipelineRefusal> {
|
||
if value.is_empty() || value.len() > max {
|
||
return Err(format_error(field, value));
|
||
}
|
||
for part in value.split('.') {
|
||
label_text(field, part)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// GitHub's own grammar for `owner/repo`, exactly one `/`: the owner is
|
||
/// `[A-Za-z0-9-]`, 1 to 39 bytes, with no leading or trailing hyphen; the repo
|
||
/// is `[A-Za-z0-9._-]`, 1 to 100 bytes, is never `.` or `..`, and does not end
|
||
/// in `.git`, ASCII-case-insensitively (`a/b.GIT` is refused exactly as
|
||
/// `a/b.git` is), so the suffix rule cannot be dodged by a case variant whose
|
||
/// `identity()` is refused. The repo's own byte alphabet excludes `/`, so a
|
||
/// second `/` inside it is already refused by the byte check; no separate
|
||
/// `contains('/')` check is needed.
|
||
fn org_repo_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
|
||
let valid = value.split_once('/').is_some_and(|(owner, repo)| {
|
||
(1..=39).contains(&owner.len())
|
||
&& owner.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||
&& !owner.starts_with('-')
|
||
&& !owner.ends_with('-')
|
||
&& (1..=100).contains(&repo.len())
|
||
&& repo.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||
&& repo != "."
|
||
&& repo != ".."
|
||
&& !(repo.len() >= 4 && repo[repo.len() - 4..].eq_ignore_ascii_case(".git"))
|
||
});
|
||
if valid {
|
||
Ok(())
|
||
} else {
|
||
Err(format_error(field, value))
|
||
}
|
||
}
|
||
|
||
macro_rules! bounded_text {
|
||
($($(#[$doc:meta])* $name:ident = $limit:literal, $check:expr;)*) => {$(
|
||
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(transparent)]
|
||
#[schemars(extend("maxLength" = $limit))]
|
||
$(#[$doc])*
|
||
pub struct $name(pub String);
|
||
impl Bounded for $name {
|
||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
|
||
let check: fn(&str, &str) -> Result<(), PipelineRefusal> = $check;
|
||
check(field, &self.0)
|
||
}
|
||
}
|
||
impl From<&str> for $name {
|
||
fn from(value: &str) -> Self {
|
||
Self(value.into())
|
||
}
|
||
}
|
||
)*};
|
||
}
|
||
|
||
fn within(limit: usize) -> impl Fn(&str, &str) -> Result<(), PipelineRefusal> {
|
||
move |field: &str, value: &str| bound(field, limit, value.len())
|
||
}
|
||
|
||
/// A title holds at least one `char::is_alphanumeric()` character, no
|
||
/// `char::is_control()` character, no U+2028 LINE SEPARATOR or U+2029
|
||
/// PARAGRAPH SEPARATOR (both `Zl`/`Zp`, so `is_control()` does not reach
|
||
/// them, and a one-line field should carry no hard line break), and no bidi
|
||
/// control character: `U+061C`, `U+200E`, `U+200F`, `U+202A`-`U+202E`,
|
||
/// `U+2066`-`U+2069`. No denylist of individual zero-width or format
|
||
/// characters is maintained here; a character not covered by one of these
|
||
/// tests is allowed, so ZWJ and ZWNJ still work.
|
||
fn title_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
|
||
let alphanumeric = value.chars().any(|character| character.is_alphanumeric());
|
||
let control = value
|
||
.chars()
|
||
.any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}'));
|
||
let bidi = value.chars().any(|character| {
|
||
matches!(
|
||
character,
|
||
'\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
|
||
)
|
||
});
|
||
if !alphanumeric || control || bidi {
|
||
return Err(format_error(field, value));
|
||
}
|
||
within(200)(field, value)
|
||
}
|
||
|
||
/// A symbol is 1 to 128 bytes with no whitespace and no control character.
|
||
fn symbol_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
|
||
if value.is_empty() || value.chars().any(|character| character.is_whitespace() || character.is_control()) {
|
||
return Err(format_error(field, value));
|
||
}
|
||
within(128)(field, value)
|
||
}
|
||
|
||
/// A lowercase D-format UUID: 8-4-4-4-12 lowercase hex digits, any version.
|
||
fn uuid_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
|
||
let shaped = value.len() == 36
|
||
&& value.bytes().enumerate().all(|(at, byte)| match at {
|
||
8 | 13 | 18 | 23 => byte == b'-',
|
||
_ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'),
|
||
});
|
||
if !shaped {
|
||
return Err(format_error(field, value));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A person's words: 1 to 4000 UTF-8 bytes and not blank (`str::trim` leaves something).
|
||
fn words_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
|
||
if value.trim().is_empty() {
|
||
return Err(format_error(field, value));
|
||
}
|
||
within(4000)(field, value)
|
||
}
|
||
|
||
bounded_text! {
|
||
/// Text of at most 200 UTF-8 bytes.
|
||
Short = 200, |field, value| within(200)(field, value);
|
||
/// A title: 1 to 200 UTF-8 bytes, enforced by admission; JSON Schema
|
||
/// length counts characters. Holds at least one alphanumeric character,
|
||
/// no control character, and no bidi control character (`U+061C`,
|
||
/// `U+200E`, `U+200F`, `U+202A`-`U+202E`, `U+2066`-`U+2069`).
|
||
#[schemars(extend("minLength" = 1))]
|
||
Title = 200, title_text;
|
||
/// Text of at most 1,000 UTF-8 bytes.
|
||
Line = 1000, |field, value| within(1000)(field, value);
|
||
/// A name the location's lines must contain as written there (an
|
||
/// identifier or a YAML `&fileID`); not an identity. 1 to 128 bytes, no
|
||
/// whitespace and no control character.
|
||
#[schemars(extend("minLength" = 1))]
|
||
Symbol = 128, symbol_text;
|
||
/// Text of at most 4,000 UTF-8 bytes; longer narrative is cited by `DocRef`.
|
||
Para = 4000, |field, value| within(4000)(field, value);
|
||
/// A person's words, verbatim: 1 to 4,000 UTF-8 bytes, not blank (CultLib PR #72, `answer.note`).
|
||
#[schemars(extend("minLength" = 1))]
|
||
Words = 4000, words_text;
|
||
/// A client's retry token: a lowercase D-format UUID.
|
||
RequestId = 36, uuid_text;
|
||
/// A key label: `[A-Za-z0-9_-]{1,64}`.
|
||
Label = 64, label_text;
|
||
/// A campaign slug or key segment: label parts joined by `.`.
|
||
Slug = 64, dotted_text;
|
||
/// A git commit id: 7-40 lowercase hex characters.
|
||
Sha = 40, |field, value| hex(field, value, 7..=40);
|
||
/// A full git commit id: exactly 40 lowercase hex characters.
|
||
FullSha = 40, |field, value| hex(field, value, 40..=40);
|
||
/// A SHA-256 digest: exactly 64 lowercase hex characters.
|
||
Sha256Hex = 64, |field, value| hex(field, value, 64..=64);
|
||
}
|
||
|
||
impl Sha {
|
||
/// The type's identity rule: two spellings name one commit when one is a
|
||
/// prefix of the other, so a 7-character abbreviation and the full id of
|
||
/// the same commit are equal here though not `==`. Both are already 7-40
|
||
/// lowercase hex. Two distinct commits that share a 7-character prefix are
|
||
/// git's ambiguity, not the mind's.
|
||
pub fn names_same_commit(&self, other: &Sha) -> bool {
|
||
self.0.starts_with(&other.0) || other.0.starts_with(&self.0)
|
||
}
|
||
}
|
||
|
||
impl Slug {
|
||
/// The public door onto the slug grammar, for a caller outside this crate
|
||
/// that holds a bare `Slug` and no way to reach `Bounded`, which is
|
||
/// crate-private. On the pattern of `PipelineRef::validate_ref`: it
|
||
/// delegates so the grammar keeps one owner, and it applies exactly the
|
||
/// `dotted_text` check that every `Slug` field in this crate is held to,
|
||
/// so a caller that validates a declared name this way and a document
|
||
/// field of the same type refuse the same inputs. Named `validate_slug`
|
||
/// rather than a bare `validate`, because an inherent `validate` would
|
||
/// shadow `Bounded`'s for every in-crate caller holding a `Slug`.
|
||
pub fn validate_slug(&self) -> Result<(), PipelineRefusal> {
|
||
Bounded::validate(self, "slug")
|
||
}
|
||
}
|
||
|
||
/// A GitHub repository as `Org/Repo`, at most 140 UTF-8 bytes (39 + 1 +
|
||
/// 100). The owner is 1 to 39 `[A-Za-z0-9-]` bytes with no leading or
|
||
/// trailing hyphen; the repo is 1 to 100 `[A-Za-z0-9._-]` bytes, is never
|
||
/// `.` or `..`, and does not end in `.git`, ASCII-case-insensitively.
|
||
/// Identity is case-insensitive: `GameCult/Epiphany` and `gamecult/epiphany`
|
||
/// are the same repository.
|
||
//
|
||
// The raw string is display-only. `OrgRepo` is not generated by
|
||
// `bounded_text!`: that macro derives `PartialEq`, `Eq`, `Hash`, `PartialOrd`
|
||
// and `Ord` on the raw string, with no per-member override, so a type built
|
||
// from it compares and keys on spelling. `OrgRepo` instead carries
|
||
// hand-written impls of those five traits, every one of them over
|
||
// `identity()` (the ASCII-lowercased value), so `==`, a `BTreeMap` key and a
|
||
// `HashMap` key are all case-insensitive by construction: `GameCult/Epiphany`
|
||
// and `gamecult/epiphany` compare equal, hash equal, order together and
|
||
// collapse to one map entry, and a caller cannot reach the case-sensitive
|
||
// behaviour by accident. `Hash` and `Eq` agree because both are defined over
|
||
// the same `identity()` string. Every other bounded type keeps the macro's
|
||
// derives untouched. This reasoning is a source comment, not a doc comment,
|
||
// so it does not enter the published schema description (RS-L closing fix 2):
|
||
// a consumer needs the grammar, the bound and the case-insensitivity, not the
|
||
// Rust-macro justification for how that got built.
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(transparent)]
|
||
#[schemars(extend("maxLength" = 140))]
|
||
pub struct OrgRepo(pub String);
|
||
|
||
impl Bounded for OrgRepo {
|
||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
|
||
org_repo_text(field, &self.0)
|
||
}
|
||
}
|
||
|
||
impl From<&str> for OrgRepo {
|
||
fn from(value: &str) -> Self {
|
||
Self(value.into())
|
||
}
|
||
}
|
||
|
||
impl PartialEq for OrgRepo {
|
||
fn eq(&self, other: &Self) -> bool {
|
||
self.identity() == other.identity()
|
||
}
|
||
}
|
||
|
||
impl Eq for OrgRepo {}
|
||
|
||
impl std::hash::Hash for OrgRepo {
|
||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||
self.identity().hash(state);
|
||
}
|
||
}
|
||
|
||
impl PartialOrd for OrgRepo {
|
||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||
Some(self.cmp(other))
|
||
}
|
||
}
|
||
|
||
impl Ord for OrgRepo {
|
||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||
self.identity().cmp(&other.identity())
|
||
}
|
||
}
|
||
|
||
impl OrgRepo {
|
||
/// The public door onto the `org_repo` grammar, for a caller outside this
|
||
/// crate that holds a bare `OrgRepo` (the organ's `repo` alias, RS-3) and
|
||
/// no way to reach `Bounded`. On the pattern of `Slug::validate_slug`: it
|
||
/// delegates to `org_repo_text` through `Bounded::validate`, so the
|
||
/// grammar keeps one owner and a declared name checked this way refuses
|
||
/// exactly what a document's own `OrgRepo` field refuses.
|
||
pub fn validate_org_repo(&self) -> Result<(), PipelineRefusal> {
|
||
Bounded::validate(self, "org_repo")
|
||
}
|
||
|
||
/// The one canonical key for an `OrgRepo`: the value, ASCII-lowercased.
|
||
/// Every comparison, hash, ordering and lookup keyed by repository
|
||
/// identity goes through this — `PartialEq`, `Hash` and `Ord` above are
|
||
/// all defined in terms of it — so `GameCult/Epiphany` and
|
||
/// `gamecult/epiphany` are the same repository everywhere, not only where
|
||
/// a caller remembers to ask.
|
||
pub fn identity(&self) -> String {
|
||
self.0.to_ascii_lowercase()
|
||
}
|
||
}
|
||
|
||
impl Label {
|
||
/// The public door onto the label grammar, for a caller outside this
|
||
/// crate that holds a bare `Label` (the organ's `cut` alias, RS-3) and no
|
||
/// way to reach `Bounded`. On the pattern of `Slug::validate_slug`: it
|
||
/// delegates to `label_text` through `Bounded::validate`, so the grammar
|
||
/// keeps one owner and a declared name checked this way refuses exactly
|
||
/// what a document's own `Label` field refuses.
|
||
pub fn validate_label(&self) -> Result<(), PipelineRefusal> {
|
||
Bounded::validate(self, "label")
|
||
}
|
||
}
|
||
|
||
/// A calendar date, `YYYY-MM-DD`.
|
||
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(transparent)]
|
||
#[schemars(extend("maxLength" = 10))]
|
||
pub struct Date(pub String);
|
||
|
||
impl Bounded for Date {
|
||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
|
||
match chrono::NaiveDate::parse_from_str(&self.0, "%Y-%m-%d") {
|
||
Ok(_) if self.0.len() == 10 => Ok(()),
|
||
_ => Err(format_error(field, &self.0)),
|
||
}
|
||
}
|
||
}
|
||
|
||
macro_rules! bounded_field {
|
||
($value:expr, $field:expr) => {
|
||
Bounded::validate(&$value, $field)?
|
||
};
|
||
($value:expr, $field:expr, $max:literal) => {
|
||
list($field, &$value, $max)?
|
||
};
|
||
}
|
||
|
||
/// The single field list: one invocation emits the value struct and its
|
||
/// `Bounded` impl, so a field can never be left out of validation.
|
||
macro_rules! value_types {
|
||
($($(#[$attr:meta])* pub struct $name:ident { $($field:ident: $ty:ty $([$max:literal])? $(= $marker:ident)?),* $(,)? } $(=> $extra:path)?)*) => {$(
|
||
$(#[$attr])*
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||
pub struct $name {
|
||
$(
|
||
$(#[schemars(extend("maxItems" = $max))])?
|
||
// `= absent`: defaulted on read, omitted on write when absent.
|
||
// The `cfg_attr(any(), ..)` is a no-op that binds `$marker` to the group.
|
||
$(#[serde(default, skip_serializing_if = "Absent::absent")] #[cfg_attr(any(), $marker)])?
|
||
pub $field: $ty
|
||
),*
|
||
}
|
||
|
||
impl Bounded for $name {
|
||
fn validate(&self, at: &str) -> Result<(), PipelineRefusal> {
|
||
$(
|
||
$(let _: fn(&$ty) -> bool = <$ty as Absent>::$marker;)?
|
||
bounded_field!(self.$field, &format!("{at}.{}", stringify!($field)) $(, $max)?);
|
||
)*
|
||
$($extra(self, at)?;)?
|
||
Ok(())
|
||
}
|
||
}
|
||
)*};
|
||
}
|
||
|
||
macro_rules! unit_enums {
|
||
($($name:ident { $($variant:ident),* $(,)? })*) => {$(
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
|
||
pub enum $name { $($variant),* }
|
||
impl $name {
|
||
/// The variants' spellings, in declaration order: the names the wire and
|
||
/// `Debug` use.
|
||
pub const NAMES: &'static [&'static str] = &[$(stringify!($variant)),*];
|
||
}
|
||
impl Bounded for $name {
|
||
fn validate(&self, _field: &str) -> Result<(), PipelineRefusal> {
|
||
Ok(())
|
||
}
|
||
}
|
||
)*};
|
||
}
|
||
|
||
/// RS-L closing fix 4: a repo list refuses two entries naming the same
|
||
/// repository by identity, not by raw spelling, so `GameCult/Epiphany` and
|
||
/// `gamecult/epiphany` in one list refuse exactly as one entry twice would.
|
||
/// This is a cross-element invariant on the lists alone, not a per-type format
|
||
/// rule any other `value_types!` struct shares, so it is wired in as the
|
||
/// `=> extra` hook of the two structs that carry a repo list
|
||
/// (`PipelineCampaign.repos` and `PipelineTarget.repos`) rather than folded
|
||
/// into `Bounded for OrgRepo` or the list bound in `bounded_field!`.
|
||
fn repos_are_distinct(repos: &[OrgRepo], at: &str) -> Result<(), PipelineRefusal> {
|
||
let mut seen = std::collections::HashSet::new();
|
||
for repo in repos {
|
||
if !seen.insert(repo.identity()) {
|
||
return Err(PipelineRefusal::DuplicateRepo {
|
||
field: format!("{at}.repos"),
|
||
repo: repo.0.clone(),
|
||
});
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn campaign_repos_are_distinct(campaign: &PipelineCampaign, at: &str) -> Result<(), PipelineRefusal> {
|
||
repos_are_distinct(&campaign.repos, at)
|
||
}
|
||
|
||
fn target_repos_are_distinct(target: &PipelineTarget, at: &str) -> Result<(), PipelineRefusal> {
|
||
repos_are_distinct(&target.repos, at)
|
||
}
|
||
|
||
/// The most lines one read anchor may span.
|
||
const READ_WINDOW_MAX_LINES: u32 = 100;
|
||
|
||
/// A location starts at line 1 or later, ends no earlier than it starts, and a
|
||
/// read anchor spans at most `READ_WINDOW_MAX_LINES`. The bounds are on
|
||
/// `ReadAnchor` alone: `CodeLocation` elsewhere keeps its stored shape.
|
||
fn read_window_is_bounded(anchor: &ReadAnchor, at: &str) -> Result<(), PipelineRefusal> {
|
||
let location = &anchor.location;
|
||
if location.line < 1 {
|
||
return Err(format_error(&format!("{at}.location.line"), &location.line.to_string()));
|
||
}
|
||
if let Some(end_line) = location.end_line {
|
||
if end_line < location.line {
|
||
return Err(format_error(&format!("{at}.location.end_line"), &end_line.to_string()));
|
||
}
|
||
let span = end_line - location.line + 1;
|
||
if span > READ_WINDOW_MAX_LINES {
|
||
return Err(PipelineRefusal::FieldBound {
|
||
field: format!("{at}.location.end_line"),
|
||
limit: READ_WINDOW_MAX_LINES,
|
||
actual: span,
|
||
});
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A run claims work, never runs, rulings or campaigns: its claims are cut
|
||
/// specs, findings and follow-ups, the three kinds a Self takes on. And its
|
||
/// budget is a canonical decimal (`0`, `12`, `7.5`: no sign, no leading or
|
||
/// trailing zeros, no exponent), so one spend has one spelling.
|
||
fn run_is_well_formed(run: &PipelineRun, at: &str) -> Result<(), PipelineRefusal> {
|
||
for (index, claim) in run.claims.iter().enumerate() {
|
||
if !matches!(claim.kind, PipelineKind::CutSpec | PipelineKind::Finding | PipelineKind::FollowUp) {
|
||
return Err(format_error(&format!("{at}.claims[{index}].kind"), claim.kind.name()));
|
||
}
|
||
if run.claims[..index].contains(claim) {
|
||
return Err(format_error(&format!("{at}.claims[{index}]"), &claim.id.0));
|
||
}
|
||
}
|
||
let (whole, fraction) = run.budget_usd.0.split_once('.').map_or((run.budget_usd.0.as_str(), None), |(whole, fraction)| (whole, Some(fraction)));
|
||
let digits = |text: &str| !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit());
|
||
let canonical = digits(whole)
|
||
&& (whole == "0" || !whole.starts_with('0'))
|
||
&& fraction.is_none_or(|fraction| digits(fraction) && !fraction.ends_with('0'));
|
||
if canonical {
|
||
Ok(())
|
||
} else {
|
||
Err(format_error(&format!("{at}.budget_usd"), &run.budget_usd.0))
|
||
}
|
||
}
|
||
|
||
/// A chat name holds a letter or a digit and has no space at either end, so it can match a whole word.
|
||
fn stewardship_is_well_formed(stewardship: &PipelineStewardship, at: &str) -> Result<(), PipelineRefusal> {
|
||
for (index, name) in stewardship.names.iter().enumerate() {
|
||
if name.0.trim() != name.0 || !name.0.chars().any(char::is_alphanumeric) {
|
||
return Err(format_error(&format!("{at}.names[{index}]"), &name.0));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The longest and shortest beat a Mind may vote for, in seconds.
|
||
pub const TEMPO_VOTE_MIN_S: u32 = 60;
|
||
pub const TEMPO_VOTE_MAX_S: u32 = 86_400;
|
||
/// Urgency is in thousandths: 0 declares none, 1000 the most.
|
||
pub const URGENCY_MAX_PERMILLE: u16 = 1000;
|
||
|
||
/// A pulse votes a tempo or declares an urgency, in bounds, or says nothing it
|
||
/// could be read for: an empty pulse is refused.
|
||
fn pulse_is_well_formed(pulse: &PipelinePulse, at: &str) -> Result<(), PipelineRefusal> {
|
||
if let Some(beat) = pulse.tempo_vote_beat_s
|
||
&& !(TEMPO_VOTE_MIN_S..=TEMPO_VOTE_MAX_S).contains(&beat)
|
||
{
|
||
return Err(format_error(&format!("{at}.tempo_vote_beat_s"), &beat.to_string()));
|
||
}
|
||
if let Some(urgency) = pulse.urgency_permille
|
||
&& urgency > URGENCY_MAX_PERMILLE
|
||
{
|
||
return Err(format_error(&format!("{at}.urgency_permille"), &urgency.to_string()));
|
||
}
|
||
if pulse.tempo_vote_withdrawn && pulse.tempo_vote_beat_s.is_some() {
|
||
return Err(format_error(&format!("{at}.tempo_vote_withdrawn"), "a pulse that votes a beat and withdraws the vote"));
|
||
}
|
||
if pulse.tempo_vote_beat_s.is_none() && !pulse.tempo_vote_withdrawn && pulse.urgency_permille.is_none() {
|
||
return Err(format_error(&format!("{at}.tempo_vote_beat_s"), "a pulse that votes and declares nothing"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A reply says something and answers only follow-ups, from a run.
|
||
fn reply_is_well_formed(reply: &PipelineReply, at: &str) -> Result<(), PipelineRefusal> {
|
||
if reply.text.0.trim().is_empty() {
|
||
return Err(format_error(&format!("{at}.text"), "a reply with no words"));
|
||
}
|
||
if reply.run.kind != PipelineKind::Run {
|
||
return Err(format_error(&format!("{at}.run.kind"), reply.run.kind.name()));
|
||
}
|
||
for (index, answer) in reply.answers.iter().enumerate() {
|
||
if answer.kind != PipelineKind::FollowUp {
|
||
return Err(format_error(&format!("{at}.answers[{index}].kind"), answer.kind.name()));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The most candidates a continuation may offer, and the nearness that means
|
||
/// she had no earlier thought to be near.
|
||
pub const THOUGHT_CANDIDATES_MAX: usize = 4;
|
||
pub const THOUGHT_NEAREST_NONE_PERMILLE: u16 = 1000;
|
||
|
||
/// A thought says something it was chosen to say: its text is not blank and is
|
||
/// the chosen candidate, every candidate has its nearness, and no nearness is
|
||
/// over 1000 thousandths.
|
||
fn thought_is_well_formed(thought: &PipelineThought, at: &str) -> Result<(), PipelineRefusal> {
|
||
if thought.text.0.trim().is_empty() {
|
||
return Err(format_error(&format!("{at}.text"), "a thought with no words"));
|
||
}
|
||
if thought.prompt_version.0.trim().is_empty() {
|
||
return Err(format_error(&format!("{at}.prompt_version"), "a thought with no prompt version"));
|
||
}
|
||
if thought.candidates.is_empty() || thought.candidates.len() > THOUGHT_CANDIDATES_MAX {
|
||
return Err(format_error(&format!("{at}.candidates"), &format!("{} candidates", thought.candidates.len())));
|
||
}
|
||
if thought.nearest_permille.len() != thought.candidates.len() {
|
||
return Err(format_error(&format!("{at}.nearest_permille"), "a nearness list that is not one per candidate"));
|
||
}
|
||
if let Some(over) = thought.nearest_permille.iter().position(|near| *near > THOUGHT_NEAREST_NONE_PERMILLE) {
|
||
return Err(format_error(&format!("{at}.nearest_permille[{over}]"), &thought.nearest_permille[over].to_string()));
|
||
}
|
||
match thought.candidates.get(usize::from(thought.chosen)) {
|
||
None => Err(format_error(&format!("{at}.chosen"), &thought.chosen.to_string())),
|
||
Some(chosen) if chosen.0 != thought.text.0 => Err(format_error(&format!("{at}.text"), "a thought that is not its chosen candidate")),
|
||
Some(_) => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// The most thousandths a memory's salience may carry.
|
||
pub const SALIENCE_MAX_PERMILLE: u16 = 1000;
|
||
|
||
/// A memory says something (its text is not empty), its salience is in
|
||
/// thousandths, and it consolidates only memories.
|
||
fn memory_is_well_formed(memory: &PipelineMemory, at: &str) -> Result<(), PipelineRefusal> {
|
||
if memory.text.0.trim().is_empty() {
|
||
return Err(format_error(&format!("{at}.text"), "an empty memory"));
|
||
}
|
||
if let MemoryOrigin::Direct { with, .. } = &memory.origin
|
||
&& with.is_empty()
|
||
{
|
||
return Err(format_error(&format!("{at}.origin.with"), "a direct memory that names no one she heard it from"));
|
||
}
|
||
if memory.salience_permille > SALIENCE_MAX_PERMILLE {
|
||
return Err(format_error(&format!("{at}.salience_permille"), &memory.salience_permille.to_string()));
|
||
}
|
||
for (index, entry) in memory.consolidates.iter().enumerate() {
|
||
if entry.kind != PipelineKind::Memory {
|
||
return Err(format_error(&format!("{at}.consolidates[{index}].kind"), entry.kind.name()));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// A research note states a claim and stands on at least one source that
|
||
/// names something: a Repo source a file and a range that does not end before
|
||
/// it starts, a Web source its address and work, an Observed one its
|
||
/// locator and result, a Document one a note other than this one. A claim
|
||
/// with no source is not research. An inferred note names what it inferred
|
||
/// from in those sources. Two notes citing each other cannot be seen from one
|
||
/// document; that refusal would need the image, so it is not made here.
|
||
fn research_is_well_formed(research: &PipelineResearch, at: &str) -> Result<(), PipelineRefusal> {
|
||
if research.claim.0.trim().is_empty() {
|
||
return Err(format_error(&format!("{at}.claim"), "an empty claim"));
|
||
}
|
||
if research.sources.is_empty() {
|
||
return Err(format_error(&format!("{at}.sources"), "a research note that names no source"));
|
||
}
|
||
let own_key = format!("{}:research:{}.{}", research.campaign.0, research.pass.0, research.label.0);
|
||
let blank = |text: &str| text.trim().is_empty();
|
||
for (index, source) in research.sources.iter().enumerate() {
|
||
let (field, empty) = match source {
|
||
ResearchSource::Repo { at, .. } if blank(&at.path.0) => ("at.path", "a repo source pinning no file"),
|
||
ResearchSource::Repo { at, .. } if at.end_line < at.start_line => ("at.end_line", "a repo source whose range ends before it starts"),
|
||
ResearchSource::Web { url, .. } if blank(&url.0) => ("url", "a web source with no address"),
|
||
ResearchSource::Web { work, .. } if blank(&work.0) => ("work", "a web source naming no work"),
|
||
ResearchSource::Observed { evidence } if blank(&evidence.locator.0) => ("evidence.locator", "an observation naming nothing observed"),
|
||
ResearchSource::Observed { evidence } if blank(&evidence.result.0) => ("evidence.result", "an observation with no result"),
|
||
ResearchSource::Document { reference } if reference.kind == PipelineKind::Research && reference.id.0 == own_key => ("reference", "a note that is its own source"),
|
||
_ => continue,
|
||
};
|
||
return Err(format_error(&format!("{at}.sources[{index}].{field}"), empty));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// An idea says something. It may serve no fork and rest on nothing.
|
||
fn idea_is_well_formed(idea: &PipelineIdea, at: &str) -> Result<(), PipelineRefusal> {
|
||
if idea.idea.0.trim().is_empty() {
|
||
return Err(format_error(&format!("{at}.idea"), "an empty idea"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
unit_enums! {
|
||
EvidenceKind { Command, Test, Mutation, Probe, SourceRead, Capture }
|
||
ClaimOutcome { Holds, Falsified, Unproven }
|
||
FindingConfidence { Confirmed, Plausible }
|
||
FindingSeverity { Blocker, High, Medium, Low }
|
||
FindingOrigin { Introduced, PreExisting }
|
||
RunOperator { Operator, Mind }
|
||
RunTurn { PersonaTurn, SelfRun, Sleep }
|
||
MemorySort { Asked, Decided, Committed, Learned, Did, Idea, Rapport }
|
||
// The role the verified account holds, an attribution copied from Bifrost's state (CultLib PR #72,
|
||
// fork-card.md). It confers nothing: whether an answerer may decide is the decider list's, never this.
|
||
DeskAuthority { Operator, Steward, Member }
|
||
// Why a desk answer was refused and stored: binding spec 1.3 steps 4-7 and R1.
|
||
DeskRefusal { StaleFork, NotADecider, ForkClosed, ChoiceInvalid, BadRetraction, AlreadyRetracted }
|
||
}
|
||
|
||
/// The slots of a Mind's runs: one live run of hers per instance and slot. A
|
||
/// Persona session and her Sleep run share the Persona slot (unit
|
||
/// `mind-persona@`); a Self run holds the other.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum RunSlot {
|
||
Persona,
|
||
SelfRun,
|
||
}
|
||
|
||
impl RunTurn {
|
||
/// The slot this turn holds while live; admission and launch both read it here.
|
||
pub fn slot(self) -> RunSlot {
|
||
match self {
|
||
Self::PersonaTurn | Self::Sleep => RunSlot::Persona,
|
||
Self::SelfRun => RunSlot::SelfRun,
|
||
}
|
||
}
|
||
}
|
||
|
||
value_types! {
|
||
pub struct CodeLocation { path: Short, line: u32, end_line: Option<u32>, symbol: Option<Symbol> = absent }
|
||
pub struct CommitRange { base: Sha, head: Sha }
|
||
pub struct Evidence { kind: EvidenceKind, locator: Line, result: Line }
|
||
/// A citation of a document in another repo's pipeline store (D6).
|
||
pub struct ForeignRef { repo: OrgRepo, commit: FullSha, kind: PipelineKind, id: Short, payload_sha256: Sha256Hex }
|
||
pub struct DocRef { path: Short, start_line: u32, end_line: u32, commit: Sha }
|
||
pub struct TargetInvariant { label: Label, statement: Line }
|
||
pub struct QuestionOption { label: Label, text: Line }
|
||
pub struct CutDelete { path: Short, lines: u32, note: Line }
|
||
pub struct FileChange { location: CodeLocation, change: Line }
|
||
/// Code Hands must see but not change, in the spec's repo at the spec's base.
|
||
pub struct ReadAnchor { location: CodeLocation, why: Short } => read_window_is_bounded
|
||
pub struct AuthorityMap {
|
||
owner: Line, inputs: Vec<Line>[16], outputs: Vec<Line>[16], derived_state: Vec<Line>[16],
|
||
forbidden_writers: Vec<Line>[16], shared_paths: Vec<Line>[16], deletion_line: Line,
|
||
}
|
||
pub struct VerificationTest { name: Short, pins: Line }
|
||
pub struct NegativeCheck { pattern: Short, scope: Line }
|
||
pub struct CutVerification {
|
||
builds: Vec<Line>[64], tests: Vec<VerificationTest>[64],
|
||
negative: Vec<NegativeCheck>[64], operator: Vec<Line>[64],
|
||
}
|
||
pub struct ReportCommit { sha: Sha, subject: Line, builds: bool }
|
||
/// One mutation a report ran: a key-safe label a verdict claim can name,
|
||
/// the exact edit, and the tree it was applied to.
|
||
pub struct MutationRecord { label: Label, rule: Line, location: CodeLocation, before: Line, after: Line, commit: Sha, failed_as_expected: bool }
|
||
pub struct Deviation { what: Line, why: Line }
|
||
// D1 tables no maximum for these lists, so they take the shared list
|
||
// default of 64 rather than a number invented for this field alone.
|
||
pub struct StructuralDelta {
|
||
lines_added: u32, lines_removed: u32,
|
||
dependencies_added: Vec<Short>[64], dependencies_removed: Vec<Short>[64],
|
||
formats_added: Vec<Short>[64], formats_removed: Vec<Short>[64],
|
||
targets_added: Vec<Short>[64], targets_removed: Vec<Short>[64],
|
||
}
|
||
/// A name the cut landed, and where it lives.
|
||
pub struct LandedName { name: Short, path: Short }
|
||
/// A promise a report makes about what it landed. Not a document and not
|
||
/// resolvable: it is identified by its report's id and its label, as a
|
||
/// `TargetInvariant` is by its target's. Soul measures every one (ruling
|
||
/// A), and the rule that every one is measured is admission's.
|
||
pub struct Promise { label: Label, text: Line }
|
||
/// The promise this claim measured, and the report's mutations it ran. An
|
||
/// `Unproven` claim with a promise is one Soul could not reach.
|
||
pub struct VerdictClaim {
|
||
claim: Line, outcome: ClaimOutcome, evidence: Vec<Evidence>[8], findings: Vec<Short>[16],
|
||
promise: Option<Label>, mutations: Vec<Label>[8],
|
||
}
|
||
|
||
pub struct PipelineCampaign { slug: Slug, title: Title, repos: Vec<OrgRepo>[8], working_branch: Short, target_doc: DocRef } => campaign_repos_are_distinct
|
||
/// `repos`, when non-empty, is the campaign's whole repo set from this
|
||
/// revision on; empty keeps the previous set (ruling campaign-repo-set-home).
|
||
pub struct PipelineTarget {
|
||
campaign: Slug, revision: u32, invariants: Vec<TargetInvariant>[32], not_in_scope: Vec<Line>[32],
|
||
canonical_implementations: Vec<Line>[16], repos: Vec<OrgRepo>[16] = absent, doc: DocRef,
|
||
} => target_repos_are_distinct
|
||
pub struct PipelineQuestion {
|
||
campaign: Slug, label: Label, title: Title, question: Para, options: Vec<QuestionOption>[8], recommended: Label,
|
||
depends: Vec<Line>[8], raised_in: Option<PipelineRef>, asked_on: Date,
|
||
}
|
||
pub struct PipelineRuling {
|
||
campaign: Slug, label: Label, title: Title, answers: Option<Short>, choice: Option<Label>, ruling: Para,
|
||
operator_quote: Option<Para>, ruled_on: Date, precedents: Vec<ForeignRef>[8], authority: RulingAuthority,
|
||
}
|
||
pub struct PipelineCutSpec {
|
||
campaign: Slug, cut: Label, revision: u32, title: Title, repo: OrgRepo, branch: Short, base: Sha,
|
||
depends_on: Vec<Short>[8], first: Vec<Line>[16], deletes: Vec<CutDelete>[64], keeps_moves: Vec<Line>[64],
|
||
adds: Vec<Line>[64], file_changes: Vec<FileChange>[256], reads: Vec<ReadAnchor>[16] = absent, authority_map: Option<AuthorityMap>,
|
||
verification: CutVerification, estimate: StructuralDelta,
|
||
rulings: Vec<Short>[32], questions: Vec<Short>[16],
|
||
}
|
||
pub struct PipelineCutReport {
|
||
campaign: Slug, cut_spec: Short, attempt: u32, repo: OrgRepo, branch: Short,
|
||
commits: Vec<ReportCommit>[128], range: CommitRange, verification: Vec<Evidence>[64],
|
||
mutations: Vec<MutationRecord>[64], deviations: Vec<Deviation>[32], forks: Vec<Short>[8],
|
||
structural_delta: StructuralDelta, landed_names: Vec<LandedName>[128], undone: Vec<Line>[32],
|
||
promises: Vec<Promise>[64],
|
||
}
|
||
/// One answer the fork desk's receiver passed to the Mind, refused ones included.
|
||
/// `answerer` and `authority` (an attribution, never a decision) are stamped by the
|
||
/// Mind from the verified caller and are absent from the request type; `answered_at`
|
||
/// is the client's claim kept as written, display only: order is `sequence`, the
|
||
/// receiver's order. `received_at` is the Mind's admission time (UTC) and
|
||
/// `claim_in_future` is set when `answered_at` is more than five minutes after it.
|
||
/// `request_id` is the client's retry token and `body_sha256` the digest of the
|
||
/// request it came with: the pair that makes a retry the same answer, never a new
|
||
/// one; the key is `(answerer, request_id)`. A record holds no card: a replayed
|
||
/// `stale_fork` is shown the current card. `sequence` is per question and is the
|
||
/// last part of the key (the answer id). `choice`, `note` and (for a retraction) the
|
||
/// target are kept as sent whether the request was counted or refused, so a refused
|
||
/// `choice_invalid` keeps a choice that is no option label; which answer counts now is
|
||
/// derived by the reader, never stored here.
|
||
pub struct PipelineDeskAnswer {
|
||
campaign: Slug, fork: Short, sequence: u32, options_hash: Sha256Hex, choice: Option<Short>, note: Option<Words>,
|
||
answered_at: Short, received_at: Short, claim_in_future: bool, request_id: RequestId, body_sha256: Sha256Hex,
|
||
answerer: Short, authority: DeskAuthority, outcome: DeskOutcome,
|
||
}
|
||
pub struct PipelineVerdict { campaign: Slug, cut_report: Short, pass: u32, range: CommitRange, claims: Vec<VerdictClaim>[64] }
|
||
pub struct PipelineFinding {
|
||
campaign: Slug, verdict: Short, label: Label, range: CommitRange, confidence: FindingConfidence,
|
||
severity: FindingSeverity, claim: Line, invariants: Vec<Label>[8], locations: Vec<CodeLocation>[16],
|
||
failure_scenario: Para, evidence: Vec<Evidence>[16], precedents: Vec<ForeignRef>[8], origin: FindingOrigin,
|
||
}
|
||
/// A follow-up. `origin` is where the Persona turn that admitted it was
|
||
/// asked, as the organ derived it: a brief records the conversation whose
|
||
/// message started it. Absent on follow-ups no Persona turn admitted and
|
||
/// on briefs admitted before it existed.
|
||
pub struct PipelineFollowUp {
|
||
campaign: Slug, label: Label, source: PipelineRef, repo: OrgRepo, locations: Vec<CodeLocation>[16],
|
||
item: Line, why_it_can_wait: Line, owner: Short, origin: Option<MemoryOrigin> = absent,
|
||
}
|
||
/// The record of how a subject was closed and by what. `sequence` is per
|
||
/// subject and set by the writer, `1` for the first, and it is the last
|
||
/// part of the key, so a subject's resolutions share a prefix and a
|
||
/// withdrawn one is kept under its subject rather than overwritten.
|
||
/// Whether a sequence is the previous plus one, and whether an earlier
|
||
/// resolution still stands, are admission's rules: this crate refuses
|
||
/// neither `0` nor a gap, exactly as it refuses neither `revision: 0` nor
|
||
/// a `revision` with no predecessor.
|
||
pub struct PipelineResolution { subject: PipelineRef, sequence: u32, outcome: ResolutionOutcome, rationale: Para, resolved_on: Date }
|
||
|
||
/// A mind's identity document. A store is canonical to exactly one
|
||
/// instance, and this says which; identity lives in the state, not in a
|
||
/// path.
|
||
pub struct PipelineInstance { instance: Slug, display_name: Short, created_at: Date, host: Short }
|
||
/// Stewardship over a repo, as an assignment recorded in a mind. One
|
||
/// instance may steward several repos, so the repo is part of the key.
|
||
/// `sequence` is per `(instance, repo)`, set by the writer, `1` for the
|
||
/// first, so a repo transferred away and back is two records under one
|
||
/// prefix rather than one document overwriting the other. `assigned_on`
|
||
/// is a field, not a key part. As on a resolution, whether the sequence
|
||
/// is the previous plus one, and which assignment is in force, are
|
||
/// admission's rules and not this crate's.
|
||
/// `names` are the chat names that address her for this repo, matched exactly
|
||
/// as written; none means the repo's own name, matched in any case.
|
||
pub struct PipelineStewardship { instance: Slug, repo: OrgRepo, sequence: u32, assigned_on: Date, note: Line, names: Vec<Short>[8] = absent } => stewardship_is_well_formed
|
||
/// A reassignment of stewardship, recorded in both minds. `documents` names
|
||
/// what travels with it.
|
||
pub struct PipelineHandOff {
|
||
from_instance: Slug, to_instance: Slug, repo: OrgRepo, documents: Vec<Short>[256],
|
||
reason: Para, handed_on: Date,
|
||
}
|
||
/// One launch of a Self or a Persona turn: the one record of what it took
|
||
/// on. `claims` are the work it holds, and a claim is live while its run
|
||
/// has no resolution in force. `budget_usd` is a canonical decimal string,
|
||
/// `0` for an interactive run. Instance-rooted like stewardship: the key
|
||
/// is `<instance>:run:<label>`. Whether a claim is free is admission's rule.
|
||
pub struct PipelineRun {
|
||
instance: Slug, label: Label, operated_by: RunOperator, turn: RunTurn, host: Short, started_on: Date,
|
||
budget_usd: Short, claims: Vec<PipelineRef>[64], campaigns: Vec<Slug>[8],
|
||
} => run_is_well_formed
|
||
/// One Mind's heartbeat vote, in her own mind: the beat she wants (the swarm's
|
||
/// tempo is the lower median of every Mind's newest vote), the withdrawal of
|
||
/// her standing vote (`tempo_vote_withdrawn`, an explicit null: she counts as
|
||
/// silent again), and the urgency she declares for herself until her next
|
||
/// Persona turn. A pulse says at least one of the three; a withdrawal and a
|
||
/// beat are never both. Instance-rooted: the key is `<instance>:pulse:<label>`, the label
|
||
/// time-ordered like a run's.
|
||
pub struct PipelinePulse {
|
||
instance: Slug, label: Label, tempo_vote_beat_s: Option<u32>, tempo_vote_withdrawn: bool = absent, urgency_permille: Option<u16>, note: Line,
|
||
} => pulse_is_well_formed
|
||
/// One result of her Self session, as its actuator heard it: the words her
|
||
/// Self ended a turn with, and the follow-ups (briefs) the turn answers.
|
||
/// Instance-rooted: the key is `<instance>:reply:<label>`. Not semantically
|
||
/// indexed. Whether the run is hers is admission's rule.
|
||
pub struct PipelineReply { instance: Slug, label: Label, run: PipelineRef, answers: Vec<PipelineRef>[64], text: Para } => reply_is_well_formed
|
||
/// One tick of her stream of thought, offline: the words a base model
|
||
/// continued from a projection of her own state, with the whole trace that
|
||
/// made them (the seed, the projection, every candidate and how near each
|
||
/// was to her earlier thoughts). `text` is the chosen candidate. `reads`
|
||
/// are the documents the seed was drawn from. A thought is divergence for
|
||
/// delivery to weigh, never a memory: its words reach a later seed only
|
||
/// through something she wrote. Instance-rooted: the key is
|
||
/// `<instance>:thought:<label>`, the label `t<unix seconds>` of the tick.
|
||
/// Semantically indexed on `text` only. `nearest_permille` is 1000 where she
|
||
/// had no earlier thought. `prompt_version` names the fixed parts of both
|
||
/// prompts (the Projector's builder revision, guidance and word budget, and
|
||
/// the continuer's frame), so the exact prompts of an old thought can be
|
||
/// rebuilt from the trace.
|
||
pub struct PipelineThought {
|
||
instance: Slug, label: Label, text: Para, reads: Vec<PipelineRef>[64], persona_receipt: Short,
|
||
identity: Line, context: Para, stimulus: Para, projector_model: Short, projection: Para, base_model: Short,
|
||
temperature_permille: u16, min_p_permille: u16, max_tokens: u16,
|
||
candidates: Vec<Line>[4], nearest_permille: Vec<u16>[4], chosen: u8, projector_ms: u32, continuer_ms: u32,
|
||
prompt_version: Line,
|
||
} => thought_is_well_formed
|
||
/// One thing a Mind keeps for herself, in her own mind: what she was asked,
|
||
/// decided, committed to, learned or did, an idea, or the rapport between
|
||
/// her and another Mind or person. `text` is her own words. `about` names
|
||
/// the Matrix users it concerns (Minds included) and nothing about where it
|
||
/// came from; `origin` is where and from whom she learned it, and `sources`
|
||
/// the event ids, commits or documents it rests on; the organ fills both,
|
||
/// not the model. `salience_permille` ranks it and decides nothing.
|
||
/// `consolidates` names the memories a consolidating memory replaces.
|
||
/// Instance-rooted: the key is `<instance>:memory:<label>`.
|
||
pub struct PipelineMemory {
|
||
instance: Slug, label: Label, sort: MemorySort, text: Line, about: Vec<Short>[8], origin: MemoryOrigin,
|
||
sources: Vec<Short>[8], salience_permille: u16, consolidates: Vec<PipelineRef>[16],
|
||
} => memory_is_well_formed
|
||
/// One claim a pass found, with the sources it stands on, grouped by `pass`.
|
||
/// What is so, never what to do: a recommendation is an idea. `inferred`
|
||
/// marks a claim read from its sources rather than seen in them. It stores
|
||
/// no status: whether a note is current, superseded or wrong is its
|
||
/// resolutions'. `serves` names the question, cut or campaign document it
|
||
/// was gathered for. The pass's method and what it did not search is its
|
||
/// first note, labelled `scope`. Campaign-rooted:
|
||
/// `<campaign>:research:<pass>.<label>`.
|
||
pub struct PipelineResearch {
|
||
campaign: Slug, pass: Label, label: Label, claim: Line, detail: Para, inferred: bool,
|
||
sources: Vec<ResearchSource>[16], serves: Vec<PipelineRef>[8], observed_on: Date,
|
||
} => research_is_well_formed
|
||
/// One thing that could be, including options rejected and ideas no cut
|
||
/// uses yet. `rests_on` is what it stands on (a reader selects the ideas
|
||
/// resting on a stale note with `cites`); `toward` is the fork, ruling or
|
||
/// follow-up it was made for, and empty is legal. It stores no status:
|
||
/// taken up is its Deferred resolution, rejected its Recorded one.
|
||
/// Campaign-rooted: `<campaign>:idea:<pass>.<label>`.
|
||
pub struct PipelineIdea {
|
||
campaign: Slug, pass: Label, label: Label, title: Title, idea: Para, tradeoffs: Para,
|
||
rests_on: Vec<PipelineRef>[16], toward: Vec<PipelineRef>[8], proposed_on: Date,
|
||
} => idea_is_well_formed
|
||
}
|
||
|
||
/// A reference to another document. The id is parsed as a full pipeline id of
|
||
/// the declared `kind`, so the kind and the id's kind segment cannot disagree.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||
pub struct PipelineRef {
|
||
pub kind: PipelineKind,
|
||
pub id: Short,
|
||
}
|
||
|
||
impl Bounded for PipelineRef {
|
||
fn validate(&self, at: &str) -> Result<(), PipelineRefusal> {
|
||
self.id.validate(&format!("{at}.id"))?;
|
||
pipeline_id(&format!("{at}.id"), &self.id.0, self.kind).map(|_| ())
|
||
}
|
||
}
|
||
|
||
impl PipelineRef {
|
||
/// The grammar's door for a reader holding a bare reference. A read side
|
||
/// outside this crate is handed a `kind` and an `id` that may disagree, and
|
||
/// `Bounded` is crate-private, so without this there is no way to ask; a
|
||
/// reader with no way to ask answers empty, which reads as "no such
|
||
/// document" rather than "that is not a reference". It delegates, so the
|
||
/// grammar keeps one owner: this is exactly what a document's own
|
||
/// `PipelineRef` field is held to, at the field name `ref`. It is kind-deep,
|
||
/// like every other read of an id: whether the named document exists, and
|
||
/// whether the local's shape suits the kind, are admission's rules.
|
||
///
|
||
/// A method rather than a free function because it concerns one public
|
||
/// type, as `prepare` and `decode` do; the two free doors act on a cache or
|
||
/// an envelope this crate does not own. `validate_ref` rather than a bare
|
||
/// `validate`, because an inherent `validate` would shadow `Bounded`'s for
|
||
/// every in-crate caller holding a `PipelineRef`.
|
||
pub fn validate_ref(&self) -> Result<(), PipelineRefusal> {
|
||
self.validate("ref")
|
||
}
|
||
}
|
||
|
||
/// Where a Persona turn's batch came from and whom she heard it from: a
|
||
/// memory's origin and a brief's. The organ derives it from the batch of the
|
||
/// turn; the model never writes it.
|
||
/// `about` names whom a memory concerns, this names the room and the voices.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||
pub enum MemoryOrigin {
|
||
/// A turn in rooms: their aliases comma-joined, and the batch's other senders.
|
||
Room {
|
||
rooms: Short,
|
||
#[schemars(extend("maxItems" = 8))]
|
||
heard_from: Vec<Short>,
|
||
},
|
||
/// A direct message: the DM's room id, and the others who spoke in it (whom
|
||
/// she heard it from in confidence).
|
||
Direct {
|
||
room: Short,
|
||
#[schemars(extend("maxItems" = 8))]
|
||
with: Vec<Short>,
|
||
},
|
||
/// A wake: no one spoke.
|
||
Wake,
|
||
/// A Self run of hers, by its label.
|
||
SelfRun { run: Short },
|
||
/// Her Sleep run, by its label: a memory she formed in her night.
|
||
Sleep { run: Short },
|
||
}
|
||
|
||
impl Bounded for MemoryOrigin {
|
||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
|
||
match self {
|
||
Self::Room { rooms, heard_from } => {
|
||
rooms.validate(&format!("{field}.rooms"))?;
|
||
list(&format!("{field}.heard_from"), heard_from, 8)
|
||
}
|
||
Self::Direct { room, with } => {
|
||
room.validate(&format!("{field}.room"))?;
|
||
list(&format!("{field}.with"), with, 8)
|
||
}
|
||
Self::Wake => Ok(()),
|
||
Self::SelfRun { run } | Self::Sleep { run } => run.validate(&format!("{field}.run")),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where a research claim comes from. A `Repo` pin and a `Web` page are
|
||
/// outside the document set; an `Observed` result reuses a finding's evidence;
|
||
/// a `Document` is the one source that is a citation edge.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||
pub enum ResearchSource {
|
||
/// A pinned file range in a repo.
|
||
Repo { repo: OrgRepo, at: DocRef },
|
||
/// A page read on the web: its address, the work it belongs to, and when.
|
||
Web { url: Line, work: Short, retrieved_on: Date },
|
||
/// A command, probe, read or capture the pass ran itself.
|
||
Observed { evidence: Evidence },
|
||
/// Another document in the mind.
|
||
Document { reference: PipelineRef },
|
||
}
|
||
|
||
impl Bounded for ResearchSource {
|
||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
|
||
match self {
|
||
Self::Repo { repo, at } => {
|
||
repo.validate(&format!("{field}.repo"))?;
|
||
at.validate(&format!("{field}.at"))
|
||
}
|
||
Self::Web { url, work, retrieved_on } => {
|
||
url.validate(&format!("{field}.url"))?;
|
||
work.validate(&format!("{field}.work"))?;
|
||
retrieved_on.validate(&format!("{field}.retrieved_on"))
|
||
}
|
||
Self::Observed { evidence } => evidence.validate(&format!("{field}.evidence")),
|
||
Self::Document { reference } => reference.validate(&format!("{field}.reference")),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is
|
||
/// a named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's
|
||
/// Matrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,
|
||
/// `event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.
|
||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
|
||
pub enum RulingAuthority {
|
||
Operator,
|
||
Standing,
|
||
Defaulted,
|
||
Mind,
|
||
Steward { steward: Short, repo: OrgRepo, words: Para, event: Option<Short> },
|
||
}
|
||
|
||
impl RulingAuthority {
|
||
/// The variants' spellings, in declaration order: the names the wire and listings use.
|
||
pub const NAMES: &'static [&'static str] = &["Operator", "Standing", "Defaulted", "Mind", "Steward"];
|
||
|
||
/// The variant's spelling, for rows, headers and listings.
|
||
pub fn name(&self) -> &'static str {
|
||
match self {
|
||
Self::Operator => "Operator",
|
||
Self::Standing => "Standing",
|
||
Self::Defaulted => "Defaulted",
|
||
Self::Mind => "Mind",
|
||
Self::Steward { .. } => "Steward",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Bounded for RulingAuthority {
|
||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
|
||
match self {
|
||
Self::Steward { steward, repo, words, event } => {
|
||
steward.validate(&format!("{field}.steward"))?;
|
||
if steward.0.trim().is_empty() {
|
||
return Err(format_error(&format!("{field}.steward"), "a steward naming no Mind"));
|
||
}
|
||
repo.validate(&format!("{field}.repo"))?;
|
||
words.validate(&format!("{field}.words"))?;
|
||
if words.0.trim().is_empty() {
|
||
return Err(format_error(&format!("{field}.words"), "a steward ruling with no words"));
|
||
}
|
||
event.validate(&format!("{field}.event"))
|
||
}
|
||
_ => Ok(()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How a subject was resolved. Every referent is a parsed `PipelineRef`, so a
|
||
/// resolution names its records by ids of the kinds they declare, validated
|
||
/// where every other referent is; a `Fixed` commit is a `Sha` whose referent
|
||
/// is outside the document set (ruling B). Whether a named document exists,
|
||
/// and how many may supersede one subject, are admission's rules.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||
pub enum ResolutionOutcome {
|
||
/// Overturned, in whole or in part, by later records. The list is bounded
|
||
/// here because the enum is outside `value_types!`, the only place a list
|
||
/// maximum is emitted automatically.
|
||
Superseded {
|
||
#[schemars(extend("maxItems" = 8))]
|
||
by: Vec<PipelineRef>,
|
||
},
|
||
Answered { by: PipelineRef },
|
||
Fixed { commit: Sha, by: Option<PipelineRef> },
|
||
Deferred { to: PipelineRef },
|
||
Recorded { reason: Line },
|
||
Withdrawn { reason: Line },
|
||
/// How a Sleep run ended when the ledger took her reply: `line` is her one line, `day_through` the last memory
|
||
/// of her day she was shown (none when the day was empty). It is the one place a night's cut line is kept.
|
||
Slept { line: Line, day_through: Option<PipelineRef> },
|
||
/// A campaign's history left this mind for `to`, the mind that now keeps it. Final: the campaign and every
|
||
/// document under its root read resolved by this and take no further writes here.
|
||
Moved { to: Slug },
|
||
}
|
||
|
||
impl Bounded for ResolutionOutcome {
|
||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
|
||
match self {
|
||
Self::Superseded { by } => list(&format!("{field}.by"), by, 8),
|
||
Self::Answered { by } => by.validate(&format!("{field}.by")),
|
||
Self::Fixed { commit, by } => {
|
||
commit.validate(&format!("{field}.commit"))?;
|
||
by.validate(&format!("{field}.by"))
|
||
}
|
||
Self::Deferred { to } => to.validate(&format!("{field}.to")),
|
||
Self::Recorded { reason } | Self::Withdrawn { reason } => reason.validate(&format!("{field}.reason")),
|
||
Self::Slept { line, day_through } => {
|
||
line.validate(&format!("{field}.line"))?;
|
||
day_through.validate(&format!("{field}.day_through"))
|
||
}
|
||
Self::Moved { to } => to.validate(&format!("{field}.to")),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The one tagged outcome of a desk answer. A retraction is a variant, so a
|
||
/// retraction without a target and a target without a retraction cannot be
|
||
/// stored. `of` and `retracts` name desk answers by answer id (the record key).
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||
pub enum DeskOutcome {
|
||
/// The answer that counts for its fork when it was admitted.
|
||
Counted,
|
||
/// A later passing answer, kept visible; `of` is the counted answer it followed.
|
||
Revision { of: Short },
|
||
Retraction { retracts: Short },
|
||
/// A request the desk would not count, kept with what it was sent with. `retracts` is the
|
||
/// target a refused retraction named, as sent and unchecked (it may name no answer at all);
|
||
/// it is `None` on a refused answer, and it is never a citation.
|
||
Refused { code: DeskRefusal, retracts: Option<Short> },
|
||
}
|
||
|
||
impl DeskOutcome {
|
||
/// The variant's spelling, for headers and listings.
|
||
pub fn name(&self) -> &'static str {
|
||
match self {
|
||
Self::Counted => "Counted",
|
||
Self::Revision { .. } => "Revision",
|
||
Self::Retraction { .. } => "Retraction",
|
||
Self::Refused { .. } => "Refused",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Bounded for DeskOutcome {
|
||
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
|
||
match self {
|
||
Self::Counted => Ok(()),
|
||
Self::Refused { retracts, .. } => retracts.as_ref().map_or(Ok(()), |target| target.validate(&format!("{field}.retracts"))),
|
||
Self::Revision { of: id } | Self::Retraction { retracts: id } => {
|
||
let field = format!("{field}.{}", if matches!(self, Self::Revision { .. }) { "of" } else { "retracts" });
|
||
id.validate(&field)?;
|
||
pipeline_id(&field, &id.0, PipelineKind::DeskAnswer).map(|_| ())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
macro_rules! pipeline_kinds {
|
||
($($variant:ident($value:ident) => $document:ident, $name:literal, $type_id:tt, $schema:tt;)*) => {
|
||
$(
|
||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||
#[cultcache(type = $type_id, schema = $schema)]
|
||
pub(crate) struct $document {
|
||
#[cultcache(key = 0)]
|
||
pub(crate) value: $value,
|
||
}
|
||
)*
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
|
||
pub enum PipelineKind { $($variant),* }
|
||
|
||
impl Bounded for PipelineKind {
|
||
fn validate(&self, _field: &str) -> Result<(), PipelineRefusal> {
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// The envelope enum, adjacently tagged on the kind name: a document
|
||
/// on a wire is `{ kind, value }`, and `kind` is exactly what
|
||
/// `PipelineKind::name()` returns, so a reader dispatches on the kind
|
||
/// segment it already knows from the key and needs no second registry
|
||
/// mapping variant spellings to kinds. Adjacent rather than internal
|
||
/// because a value is a named map of its own and an internal tag would
|
||
/// have to be merged into it; the tag stays beside the value instead.
|
||
/// This is not a published schema: `schemas/cultnet` publishes the
|
||
/// eighteen per-kind value documents, and the envelope is the wire's
|
||
/// shape, not a document's.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||
#[allow(clippy::large_enum_variant, reason = "one value per request or per batch of at most 64; boxing a variant would buy no memory and change every construction site")]
|
||
pub enum PipelineDocument { $($variant($value)),* }
|
||
|
||
impl PipelineKind {
|
||
pub const ALL: &'static [PipelineKind] = &[$(Self::$variant),*];
|
||
|
||
/// The kind segment of a document key.
|
||
pub fn name(self) -> &'static str {
|
||
match self { $(Self::$variant => $name),* }
|
||
}
|
||
|
||
pub fn type_id(self) -> &'static str {
|
||
match self { $(Self::$variant => <$document as DatabaseEntry>::TYPE),* }
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn schema_name(self) -> &'static str {
|
||
match self { $(Self::$variant => <$document as DatabaseEntry>::SCHEMA_NAME),* }
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn derived_schema(self) -> schemars::Schema {
|
||
match self { $(Self::$variant => schemars::schema_for!($value)),* }
|
||
}
|
||
}
|
||
|
||
impl PipelineDocument {
|
||
pub fn kind(&self) -> PipelineKind {
|
||
match self { $(Self::$variant(_) => PipelineKind::$variant),* }
|
||
}
|
||
|
||
/// Field bounds and formats (D1), in UTF-8 bytes and list maximums.
|
||
pub fn validate(&self) -> Result<(), PipelineRefusal> {
|
||
match self { $(Self::$variant(value) => value.validate($name)),* }
|
||
}
|
||
|
||
/// Prepares the envelope to be stored: keyed by `pipeline_key`,
|
||
/// payload `[value]` through `prepare_entry_named`.
|
||
pub fn prepare(&self, cache: &CultCache) -> Result<CultCacheEnvelope> {
|
||
let key = pipeline_key(self)?;
|
||
Ok(match self {
|
||
$(Self::$variant(value) => {
|
||
cache.prepare_entry_named(key, &$document { value: value.clone() })?.0
|
||
})*
|
||
})
|
||
}
|
||
|
||
/// Decodes a stored envelope, type-matched both ways: an envelope of
|
||
/// any other type is `ForeignStore`, never the kind that happens to
|
||
/// parse its payload. It validates neither bounds nor the key: a
|
||
/// decoded document is a typed read, and
|
||
/// `validate_pipeline_write_envelope` is the check before a write.
|
||
pub fn decode(envelope: &CultCacheEnvelope) -> Result<Self, PipelineRefusal> {
|
||
let invalid = |error: rmp_serde::decode::Error| format_error("payload", &error.to_string());
|
||
$(if envelope.r#type == <$document as DatabaseEntry>::TYPE {
|
||
let document: $document = rmp_serde::from_slice(&envelope.payload).map_err(invalid)?;
|
||
return Ok(Self::$variant(document.value));
|
||
})*
|
||
Err(PipelineRefusal::ForeignStore { r#type: envelope.r#type.clone() })
|
||
}
|
||
}
|
||
|
||
/// Registers every pipeline kind in a cache, and nothing else: the one
|
||
/// door to the crate-private wrappers, so a caller registers exactly
|
||
/// what this crate publishes.
|
||
pub fn register_pipeline_document_types(cache: &mut CultCache) -> Result<()> {
|
||
$(cache.register_entry_type::<$document>()?;)*
|
||
Ok(())
|
||
}
|
||
};
|
||
}
|
||
|
||
/// A document that is not a pipeline document, for the decode refusal and the
|
||
/// registrar's count to be pinned against something real. It stands in for
|
||
/// any other document a store may hold beside pipeline documents, a commit
|
||
/// receipt say, which is still not a document this library may decode. The
|
||
/// stand-in carries a receipt-shaped type id and is registered only by the
|
||
/// tests' own cache, never by the live registrar.
|
||
#[cfg(test)]
|
||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||
#[cultcache(type = "epiphany.mind_commit_receipt.v1", schema = "ForeignDocument")]
|
||
pub(crate) struct ForeignDocument {
|
||
#[cultcache(key = 0)]
|
||
pub(crate) marker: Short,
|
||
}
|
||
|
||
pipeline_kinds! {
|
||
Campaign(PipelineCampaign) => EpiphanyPipelineCampaignDocument, "campaign",
|
||
"epiphany.pipeline.campaign.v2", "EpiphanyPipelineCampaignDocument";
|
||
Target(PipelineTarget) => EpiphanyPipelineTargetDocument, "target",
|
||
"epiphany.pipeline.target.v2", "EpiphanyPipelineTargetDocument";
|
||
Question(PipelineQuestion) => EpiphanyPipelineQuestionDocument, "question",
|
||
"epiphany.pipeline.question.v2", "EpiphanyPipelineQuestionDocument";
|
||
Ruling(PipelineRuling) => EpiphanyPipelineRulingDocument, "ruling",
|
||
"epiphany.pipeline.ruling.v2", "EpiphanyPipelineRulingDocument";
|
||
CutSpec(PipelineCutSpec) => EpiphanyPipelineCutSpecDocument, "cut_spec",
|
||
"epiphany.pipeline.cut_spec.v2", "EpiphanyPipelineCutSpecDocument";
|
||
CutReport(PipelineCutReport) => EpiphanyPipelineCutReportDocument, "cut_report",
|
||
"epiphany.pipeline.cut_report.v2", "EpiphanyPipelineCutReportDocument";
|
||
Verdict(PipelineVerdict) => EpiphanyPipelineVerdictDocument, "verdict",
|
||
"epiphany.pipeline.verdict.v2", "EpiphanyPipelineVerdictDocument";
|
||
Finding(PipelineFinding) => EpiphanyPipelineFindingDocument, "finding",
|
||
"epiphany.pipeline.finding.v2", "EpiphanyPipelineFindingDocument";
|
||
FollowUp(PipelineFollowUp) => EpiphanyPipelineFollowUpDocument, "follow_up",
|
||
"epiphany.pipeline.follow_up.v2", "EpiphanyPipelineFollowUpDocument";
|
||
Resolution(PipelineResolution) => EpiphanyPipelineResolutionDocument, "resolution",
|
||
"epiphany.pipeline.resolution.v2", "EpiphanyPipelineResolutionDocument";
|
||
Instance(PipelineInstance) => EpiphanyPipelineInstanceDocument, "instance",
|
||
"epiphany.pipeline.instance.v2", "EpiphanyPipelineInstanceDocument";
|
||
Stewardship(PipelineStewardship) => EpiphanyPipelineStewardshipDocument, "stewardship",
|
||
"epiphany.pipeline.stewardship.v2", "EpiphanyPipelineStewardshipDocument";
|
||
HandOff(PipelineHandOff) => EpiphanyPipelineHandOffDocument, "hand_off",
|
||
"epiphany.pipeline.hand_off.v2", "EpiphanyPipelineHandOffDocument";
|
||
Run(PipelineRun) => EpiphanyPipelineRunDocument, "run",
|
||
"epiphany.pipeline.run.v2", "EpiphanyPipelineRunDocument";
|
||
Pulse(PipelinePulse) => EpiphanyPipelinePulseDocument, "pulse",
|
||
"epiphany.pipeline.pulse.v2", "EpiphanyPipelinePulseDocument";
|
||
Memory(PipelineMemory) => EpiphanyPipelineMemoryDocument, "memory",
|
||
"epiphany.pipeline.memory.v2", "EpiphanyPipelineMemoryDocument";
|
||
Research(PipelineResearch) => EpiphanyPipelineResearchDocument, "research",
|
||
"epiphany.pipeline.research.v2", "EpiphanyPipelineResearchDocument";
|
||
Idea(PipelineIdea) => EpiphanyPipelineIdeaDocument, "idea",
|
||
"epiphany.pipeline.idea.v2", "EpiphanyPipelineIdeaDocument";
|
||
Reply(PipelineReply) => EpiphanyPipelineReplyDocument, "reply",
|
||
"epiphany.pipeline.reply.v2", "EpiphanyPipelineReplyDocument";
|
||
Thought(PipelineThought) => EpiphanyPipelineThoughtDocument, "thought",
|
||
"epiphany.pipeline.thought.v2", "EpiphanyPipelineThoughtDocument";
|
||
DeskAnswer(PipelineDeskAnswer) => EpiphanyPipelineDeskAnswerDocument, "desk_answer",
|
||
"epiphany.pipeline.desk_answer.v2", "EpiphanyPipelineDeskAnswerDocument";
|
||
}
|
||
|
||
/// Parses a full document id: the grammar read backwards. A key and an id are
|
||
/// the same string, so this accepts exactly what `pipeline_key` derives:
|
||
/// `<root>:<kind>:<local>`, three segments for every kind. The kind segment
|
||
/// must equal `kind`'s name, the root is a `Slug`, and the local is `Label`s
|
||
/// joined by `.` and bounded whole by `local_max(kind)`, so `..`, an empty part, spaces and trailing
|
||
/// junk are all refused. Returns the root and the local; nothing is inferred
|
||
/// from either, and no kind is read any other way.
|
||
fn pipeline_id<'a>(
|
||
field: &str,
|
||
id: &'a str,
|
||
kind: PipelineKind,
|
||
) -> Result<(&'a str, &'a str), PipelineRefusal> {
|
||
let mut segments = id.split(':');
|
||
let (Some(root), Some(name), Some(local), None) = (
|
||
segments.next(),
|
||
segments.next(),
|
||
segments.next(),
|
||
segments.next(),
|
||
) else {
|
||
return Err(format_error(field, id));
|
||
};
|
||
if name != kind.name() {
|
||
return Err(format_error(field, id));
|
||
}
|
||
dotted_text(field, root)?;
|
||
dotted_within(field, local, local_max(kind))?;
|
||
Ok((root, local))
|
||
}
|
||
|
||
/// The local segment of a parent id in `campaign`, of the expected kind.
|
||
fn parent_local<'a>(
|
||
field: &str,
|
||
id: &'a str,
|
||
campaign: &str,
|
||
kind: PipelineKind,
|
||
) -> Result<&'a str, PipelineRefusal> {
|
||
let (owner, local) = pipeline_id(field, id, kind)?;
|
||
if owner != campaign {
|
||
return Err(format_error(field, id));
|
||
}
|
||
Ok(local)
|
||
}
|
||
|
||
/// The cut label inside a parent local `cut-<label>.<marker><N>`: exactly two
|
||
/// parts, the label a `Label` and `<N>` a non-empty run of digits.
|
||
fn parent_cut<'a>(field: &str, local: &'a str, marker: char) -> Result<&'a str, PipelineRefusal> {
|
||
let invalid = || format_error(field, local);
|
||
let mut parts = local.split('.');
|
||
let (Some(head), Some(suffix), None) = (parts.next(), parts.next(), parts.next()) else {
|
||
return Err(invalid());
|
||
};
|
||
let cut = head.strip_prefix("cut-").ok_or_else(invalid)?;
|
||
label_text(field, cut)?;
|
||
let digits = suffix.strip_prefix(marker).ok_or_else(invalid)?;
|
||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||
return Err(invalid());
|
||
}
|
||
Ok(cut)
|
||
}
|
||
|
||
/// A `Slug` or an `OrgRepo` entering a local, escaped to one label. `/` is not
|
||
/// a `Label` byte, so it cannot survive into a key, and neither may `.`: no
|
||
/// local part carries the separator, or the boundary before it would have two
|
||
/// readings. A dotted slug and a dotted repo name are both ordinary, so both
|
||
/// bytes are escaped rather than refused. The escape has to be injective or two
|
||
/// values claim one key: replacing `/` with `_` alone is not, since
|
||
/// `GameCult_Epiphany/thing` and `GameCult/Epiphany_thing` both give
|
||
/// `GameCult_Epiphany_thing`. So `_` is escaped as well, and every code is two
|
||
/// bytes starting with `_`: `_` becomes `__`, `/` becomes `_-`, and `.` becomes
|
||
/// `_d`. Every other byte is passed through and is never `_`, so a reader going
|
||
/// left to right takes each `_` together with the byte after it and never has a
|
||
/// choice to make; the encoding is therefore reversible, and distinct values
|
||
/// give distinct segments. The caller validates the value as its own type first
|
||
/// and checks the result against the local's rules, which is where an over-long
|
||
/// or otherwise unlabelled value is refused.
|
||
fn key_segment(value: &str) -> String {
|
||
value.replace('_', "__").replace('/', "_-").replace('.', "_d")
|
||
}
|
||
|
||
/// The local of a root. A campaign's or an instance's identity is entirely its
|
||
/// root segment, so its local carries none; a reader never consults it.
|
||
const ROOT_LOCAL: &str = "self";
|
||
|
||
/// The bound on a composed local of every kind but a resolution, whole, in
|
||
/// UTF-8 bytes. Every stored subject key is within it.
|
||
const SUBJECT_LOCAL_MAX: usize = 64;
|
||
|
||
/// The longest kind name a resolution can name as its subject (`stewardship`),
|
||
/// checked against `PipelineKind::ALL` by a test.
|
||
const LONGEST_KIND_NAME: usize = "stewardship".len();
|
||
|
||
/// A `.n<sequence>` part at `u32::MAX`.
|
||
const SEQUENCE_PART_MAX: usize = ".n".len() + "4294967295".len();
|
||
|
||
/// The bound on a resolution's local: a depth-two chain over a maximal
|
||
/// subject, `resolution.<kind>.<subject local>.n<seq>.n<seq>`, at `u32::MAX`
|
||
/// sequences. Depth is capped at two by admission (Q19), and this bound admits
|
||
/// exactly that for every subject, so no subject is born unresolvable. Derived
|
||
/// from `SUBJECT_LOCAL_MAX` and the kind names, never written as a number.
|
||
const RESOLUTION_LOCAL_MAX: usize = "resolution.".len() + LONGEST_KIND_NAME + ".".len() + SUBJECT_LOCAL_MAX + 2 * SEQUENCE_PART_MAX;
|
||
|
||
// `<root>:<kind>:<local>` must fit a `Short` wherever an id is cited.
|
||
const _: () = assert!(64 + ":".len() + LONGEST_KIND_NAME + ":".len() + RESOLUTION_LOCAL_MAX <= 200);
|
||
|
||
/// The bound on a kind's composed local, chosen here and nowhere else.
|
||
fn local_max(kind: PipelineKind) -> usize {
|
||
match kind {
|
||
PipelineKind::Resolution => RESOLUTION_LOCAL_MAX,
|
||
// `<question label>.n<sequence>`: every question label is answerable.
|
||
PipelineKind::DeskAnswer => SUBJECT_LOCAL_MAX + SEQUENCE_PART_MAX,
|
||
_ => SUBJECT_LOCAL_MAX,
|
||
}
|
||
}
|
||
|
||
/// The schema epoch every pipeline store is written at, owned here with the
|
||
/// schemas it names. Evolution is additive and keeps it: a new named field
|
||
/// with the `value_types!` `= absent` marker (a replayed batch re-encodes byte
|
||
/// for byte, because an absent field is not written), or a widened `PipelineKind`, since each reader ships
|
||
/// with the variants it knows and refuses an unknown kind on the kind, not on
|
||
/// the epoch. A breaking change bumps it, so that a store written at the old
|
||
/// one can be refused by whoever opens it; this crate owns no store and
|
||
/// refuses none.
|
||
pub const PIPELINE_SCHEMA_EPOCH: &str = "epiphany.pipeline.epoch.v2";
|
||
|
||
/// Composes and validates a local: every part is a `Label`, and the join is
|
||
/// bounded whole by `local_max(kind)`. Both rules live here because this is the only way a local is
|
||
/// built; `pipeline_key` has no other path to a key string. Parts are a slice,
|
||
/// not a builder, so an arm's arity is written at its call site.
|
||
fn local(field: &str, kind: PipelineKind, parts: &[&str]) -> Result<String, PipelineRefusal> {
|
||
for part in parts {
|
||
label_text(field, part)?;
|
||
}
|
||
let joined = parts.join(".");
|
||
if joined.len() > local_max(kind) {
|
||
return Err(format_error(field, &joined));
|
||
}
|
||
Ok(joined)
|
||
}
|
||
|
||
/// Derives a document's identity key (D1, "Keys: identity, not convenience").
|
||
/// Fourteen arms, one exit: every arm names its root and composes its local
|
||
/// through `local`, and the key is formatted here and nowhere else.
|
||
pub fn pipeline_key(document: &PipelineDocument) -> Result<String, PipelineRefusal> {
|
||
use PipelineDocument as D;
|
||
let document_kind = document.kind();
|
||
let kind = document_kind.name();
|
||
let key_field = format!("{kind}.key");
|
||
let campaign_field = format!("{kind}.campaign");
|
||
let (root_field, root, composed) = match document {
|
||
D::Campaign(value) => ("campaign.slug", value.slug.0.as_str(), local(&key_field, document_kind, &[ROOT_LOCAL])?),
|
||
D::Instance(value) => ("instance.instance", value.instance.0.as_str(), local(&key_field, document_kind, &[ROOT_LOCAL])?),
|
||
// A resolution is keyed inside its subject's root, with the subject's
|
||
// kind and local as its own local and its per-subject sequence last, so
|
||
// the subject's resolutions and only they share the prefix
|
||
// `<root>:resolution:<kind>.<local>.n`. A resolution's own key is an
|
||
// ordinary id, so it composes as a subject like any other; each nesting
|
||
// prepends `resolution.` (11 bytes) and appends `.n<s>` (3 bytes for
|
||
// one digit). Depth is capped at two by admission (Q19), and
|
||
// `RESOLUTION_LOCAL_MAX` admits exactly a depth-two chain over a
|
||
// maximal subject, so every subject resolves and its resolution can be
|
||
// withdrawn.
|
||
D::Resolution(value) => {
|
||
let field = "resolution.subject.id";
|
||
let (subject_root, subject_local) = pipeline_id(field, &value.subject.id.0, value.subject.kind)?;
|
||
let sequence = format!("n{}", value.sequence);
|
||
let parts = std::iter::once(value.subject.kind.name()).chain(subject_local.split('.')).chain(std::iter::once(sequence.as_str())).collect::<Vec<_>>();
|
||
(field, subject_root, local(&key_field, document_kind, &parts)?)
|
||
}
|
||
// Stewardship and hand-off hang off an instance rather than a campaign.
|
||
// The root is the whole difference; the key shape is the same.
|
||
D::Stewardship(value) => {
|
||
org_repo_text("stewardship.repo", &value.repo.0)?;
|
||
let sequence = format!("n{}", value.sequence);
|
||
(
|
||
"stewardship.instance",
|
||
value.instance.0.as_str(),
|
||
local(&key_field, document_kind, &[&key_segment(&value.repo.identity()), &sequence])?,
|
||
)
|
||
}
|
||
D::HandOff(value) => {
|
||
dotted_text("hand_off.to_instance", &value.to_instance.0)?;
|
||
org_repo_text("hand_off.repo", &value.repo.0)?;
|
||
value.handed_on.validate("hand_off.handed_on")?;
|
||
(
|
||
"hand_off.from_instance",
|
||
value.from_instance.0.as_str(),
|
||
local(
|
||
&key_field,
|
||
document_kind,
|
||
&[&key_segment(&value.to_instance.0), &key_segment(&value.repo.identity()), &value.handed_on.0],
|
||
)?,
|
||
)
|
||
}
|
||
// A run hangs off its instance too: its label is its whole local.
|
||
D::Run(value) => ("run.instance", value.instance.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||
D::Pulse(value) => ("pulse.instance", value.instance.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||
D::Reply(value) => ("reply.instance", value.instance.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||
D::Thought(value) => ("thought.instance", value.instance.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||
D::Memory(value) => ("memory.instance", value.instance.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||
D::Research(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&value.pass.0, &value.label.0])?),
|
||
D::Idea(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&value.pass.0, &value.label.0])?),
|
||
D::Target(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&format!("r{}", value.revision)])?),
|
||
D::Question(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||
D::Ruling(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||
D::FollowUp(value) => (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[&value.label.0])?),
|
||
D::CutSpec(value) => (
|
||
campaign_field.as_str(),
|
||
value.campaign.0.as_str(),
|
||
local(&key_field, document_kind, &[&format!("cut-{}", value.cut.0), &format!("r{}", value.revision)])?,
|
||
),
|
||
D::CutReport(value) => {
|
||
let spec = parent_local("cut_report.cut_spec", &value.cut_spec.0, &value.campaign.0, PipelineKind::CutSpec)?;
|
||
let cut = parent_cut("cut_report.cut_spec", spec, 'r')?;
|
||
(
|
||
campaign_field.as_str(),
|
||
value.campaign.0.as_str(),
|
||
local(&key_field, document_kind, &[&format!("cut-{cut}"), &format!("h{}", value.attempt)])?,
|
||
)
|
||
}
|
||
D::DeskAnswer(value) => {
|
||
let label = parent_local("desk_answer.fork", &value.fork.0, &value.campaign.0, PipelineKind::Question)?;
|
||
let sequence = format!("n{}", value.sequence);
|
||
(campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[label, &sequence])?)
|
||
}
|
||
D::Verdict(value) => {
|
||
let report = parent_local("verdict.cut_report", &value.cut_report.0, &value.campaign.0, PipelineKind::CutReport)?;
|
||
let cut = parent_cut("verdict.cut_report", report, 'h')?;
|
||
(
|
||
campaign_field.as_str(),
|
||
value.campaign.0.as_str(),
|
||
local(&key_field, document_kind, &[&format!("cut-{cut}"), &format!("s{}", value.pass)])?,
|
||
)
|
||
}
|
||
D::Finding(value) => {
|
||
let verdict = parent_local("finding.verdict", &value.verdict.0, &value.campaign.0, PipelineKind::Verdict)?;
|
||
parent_cut("finding.verdict", verdict, 's')?;
|
||
let parts = verdict.split('.').chain(std::iter::once(value.label.0.as_str())).collect::<Vec<_>>();
|
||
(campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &parts)?)
|
||
}
|
||
};
|
||
dotted_text(root_field, root)?;
|
||
Ok(format!("{root}:{kind}:{composed}"))
|
||
}
|
||
|
||
/// Bounds, formats, then key recomputation, on the envelope that will be
|
||
/// stored, whoever prepared it. It is the whole of what this crate checks
|
||
/// before a write; per-kind and cross-document rules are not decided here and
|
||
/// belong to the admission path of the organ that will admit these documents,
|
||
/// which is expected to call this first rather than re-derive it.
|
||
pub fn validate_pipeline_write_envelope(envelope: &CultCacheEnvelope) -> Result<(), PipelineRefusal> {
|
||
let document = PipelineDocument::decode(envelope)?;
|
||
document.validate()?;
|
||
let expected = pipeline_key(&document)?;
|
||
if envelope.key != expected {
|
||
return Err(PipelineRefusal::InvalidIdentity {
|
||
kind: document.kind(),
|
||
key: envelope.key.clone(),
|
||
expected,
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::path::Path;
|
||
|
||
const CAMPAIGN: &str = "eureka-state";
|
||
const INSTANCE: &str = "yggdrasil";
|
||
|
||
fn s(value: &str) -> Short {
|
||
value.into()
|
||
}
|
||
|
||
fn t(value: &str) -> Title {
|
||
value.into()
|
||
}
|
||
|
||
fn l(value: &str) -> Label {
|
||
value.into()
|
||
}
|
||
|
||
fn slug(value: &str) -> Slug {
|
||
value.into()
|
||
}
|
||
|
||
fn id(kind: &str, local: &str) -> Short {
|
||
Short(format!("{CAMPAIGN}:{kind}:{local}"))
|
||
}
|
||
|
||
fn sha() -> Sha {
|
||
Sha("5f98228d".into())
|
||
}
|
||
|
||
fn date() -> Date {
|
||
Date("2026-09-15".into())
|
||
}
|
||
|
||
fn range() -> CommitRange {
|
||
CommitRange { base: sha(), head: sha() }
|
||
}
|
||
|
||
fn location() -> CodeLocation {
|
||
CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 1, end_line: Some(9), symbol: None }
|
||
}
|
||
|
||
fn evidence() -> Evidence {
|
||
Evidence { kind: EvidenceKind::Test, locator: "cargo test".into(), result: "ok".into() }
|
||
}
|
||
|
||
fn doc_ref() -> DocRef {
|
||
DocRef { path: s("notes/eureka-pipeline-state-target.md"), start_line: 1, end_line: 9, commit: sha() }
|
||
}
|
||
|
||
/// One valid document of every kind, with its expected key.
|
||
fn samples() -> Vec<(PipelineDocument, String)> {
|
||
use PipelineDocument as D;
|
||
let repo = || OrgRepo("GameCult/Epiphany".into());
|
||
let branch = || s("codex/eureka-pipeline-state");
|
||
vec![
|
||
(D::Campaign(PipelineCampaign {
|
||
slug: slug(CAMPAIGN), title: t("Eureka pipeline state"), repos: vec![repo()],
|
||
working_branch: branch(), target_doc: doc_ref(),
|
||
}), format!("{CAMPAIGN}:campaign:self")),
|
||
(D::Target(PipelineTarget {
|
||
campaign: slug(CAMPAIGN), revision: 2,
|
||
invariants: vec![TargetInvariant { label: l("mind-admits"), statement: "Only admission writes.".into() }],
|
||
not_in_scope: vec!["Eve browsing".into()], canonical_implementations: vec!["CultLib".into()], repos: vec![], doc: doc_ref(),
|
||
}), format!("{CAMPAIGN}:target:r2")),
|
||
(D::Question(PipelineQuestion {
|
||
campaign: slug(CAMPAIGN), label: l("Q1"), title: t("Who owns the state?"), question: "Who owns the state?".into(),
|
||
options: vec![
|
||
QuestionOption { label: l("A"), text: "an instance".into() },
|
||
QuestionOption { label: l("B"), text: "a repo".into() },
|
||
],
|
||
recommended: l("A"), depends: vec!["Cut 3a".into()],
|
||
raised_in: Some(PipelineRef { kind: PipelineKind::CutSpec, id: id("cut_spec", "cut-3a.r1") }),
|
||
asked_on: date(),
|
||
}), format!("{CAMPAIGN}:question:Q1")),
|
||
(D::Ruling(PipelineRuling {
|
||
campaign: slug(CAMPAIGN), label: l("R8"), title: t("An instance owns its mind"), answers: Some(id("question", "Q1")), choice: Some(l("A")),
|
||
ruling: "An instance owns its mind.".into(), operator_quote: Some("all recommendations, go ahead".into()),
|
||
ruled_on: date(),
|
||
precedents: vec![ForeignRef {
|
||
repo: OrgRepo("GameCult/Aetheria".into()), commit: FullSha("a".repeat(40)), kind: PipelineKind::Ruling,
|
||
id: s("cultcache:ruling:R1"), payload_sha256: Sha256Hex("b".repeat(64)),
|
||
}],
|
||
authority: RulingAuthority::Operator,
|
||
}), format!("{CAMPAIGN}:ruling:R8")),
|
||
(D::CutSpec(PipelineCutSpec {
|
||
campaign: slug(CAMPAIGN), cut: l("3a"), revision: 1, title: t("Pipeline documents"), repo: repo(),
|
||
branch: branch(), base: sha(), depends_on: vec![s("2")], first: vec!["Read the spec.".into()],
|
||
deletes: vec![CutDelete { path: s("old.rs"), lines: 3, note: "dead".into() }],
|
||
keeps_moves: vec!["commit owner".into()], adds: vec!["pipeline_documents.rs".into()],
|
||
file_changes: vec![FileChange { location: location(), change: "add".into() }],
|
||
reads: vec![],
|
||
authority_map: Some(AuthorityMap {
|
||
owner: "core".into(), inputs: vec!["typed documents".into()], outputs: vec!["envelopes".into()],
|
||
derived_state: vec!["derived keys".into()], forbidden_writers: vec!["MCP".into()],
|
||
shared_paths: vec!["admission".into()], deletion_line: "n/a".into(),
|
||
}),
|
||
verification: CutVerification {
|
||
builds: vec!["cargo check".into()],
|
||
tests: vec![VerificationTest { name: s("keys"), pins: "one derived key".into() }],
|
||
negative: vec![NegativeCheck { pattern: s("Vec<u8>"), scope: "documents".into() }],
|
||
operator: vec!["none".into()],
|
||
},
|
||
// Cut 3a's real numbers, and in this order: the tripwire test
|
||
// reads them back, so a transposition is a failure, not a typo.
|
||
estimate: StructuralDelta {
|
||
lines_added: 900, lines_removed: 0,
|
||
dependencies_added: vec![s("schemars")], dependencies_removed: vec![],
|
||
formats_added: vec![s("epiphany.pipeline.*.v1")], formats_removed: vec![],
|
||
targets_added: vec![], targets_removed: vec![],
|
||
},
|
||
rulings: vec![id("ruling", "R8")], questions: vec![id("question", "Q1")],
|
||
}), format!("{CAMPAIGN}:cut_spec:cut-3a.r1")),
|
||
(D::CutReport(PipelineCutReport {
|
||
campaign: slug(CAMPAIGN), cut_spec: id("cut_spec", "cut-3a.r1"), attempt: 1, repo: repo(), branch: branch(),
|
||
commits: vec![ReportCommit { sha: sha(), subject: "Add the pipeline documents".into(), builds: true }],
|
||
range: range(), verification: vec![evidence()],
|
||
mutations: vec![MutationRecord {
|
||
// `.into()` on both, not `l()` and `sha()`: the type-level
|
||
// mutations (M19, M20) widen these fields to `Short`, and
|
||
// a sample spelled with the narrow constructors would stop
|
||
// compiling instead of letting the forgery through.
|
||
label: "M1".into(), rule: "key derivation".into(), location: location(),
|
||
before: "parent_cut(field, spec, 'r')?".into(), after: "spec".into(), commit: "5f98228d".into(),
|
||
failed_as_expected: true,
|
||
}],
|
||
deviations: vec![Deviation { what: "names".into(), why: "glob exports".into() }],
|
||
forks: vec![id("question", "Q1")],
|
||
structural_delta: StructuralDelta {
|
||
lines_added: 900, lines_removed: 0, dependencies_added: vec![s("schemars")],
|
||
dependencies_removed: vec![], formats_added: vec![s("epiphany.pipeline.*.v1")],
|
||
formats_removed: vec![], targets_added: vec![], targets_removed: vec![],
|
||
},
|
||
landed_names: vec![LandedName { name: s("PipelineDocument"), path: s("crates/eureka-pipeline/src/lib.rs") }],
|
||
undone: vec!["admission".into()],
|
||
promises: vec![Promise { label: l("P1"), text: "One derived key per document.".into() }],
|
||
}), format!("{CAMPAIGN}:cut_report:cut-3a.h1")),
|
||
(D::Verdict(PipelineVerdict {
|
||
campaign: slug(CAMPAIGN), cut_report: id("cut_report", "cut-3a.h1"), pass: 2, range: range(),
|
||
claims: vec![VerdictClaim {
|
||
claim: "A composed key has one source.".into(), outcome: ClaimOutcome::Falsified,
|
||
evidence: vec![evidence()], findings: vec![id("finding", "cut-3a.s2.F4")],
|
||
promise: Some(l("P1")), mutations: vec![l("M1")],
|
||
}],
|
||
}), format!("{CAMPAIGN}:verdict:cut-3a.s2")),
|
||
(D::Finding(PipelineFinding {
|
||
campaign: slug(CAMPAIGN), verdict: id("verdict", "cut-3a.s2"), label: l("F4"), range: range(),
|
||
confidence: FindingConfidence::Confirmed, severity: FindingSeverity::High,
|
||
claim: "A dotted label composes two keys.".into(), invariants: vec![l("mind-admits")],
|
||
locations: vec![location()], failure_scenario: "Two documents claim one key.".into(),
|
||
evidence: vec![evidence()], precedents: vec![], origin: FindingOrigin::Introduced,
|
||
}), format!("{CAMPAIGN}:finding:cut-3a.s2.F4")),
|
||
(D::FollowUp(PipelineFollowUp {
|
||
campaign: slug(CAMPAIGN), label: l("FU-4"),
|
||
source: PipelineRef { kind: PipelineKind::Finding, id: id("finding", "cut-3a.s2.F4") },
|
||
repo: repo(), locations: vec![location()], item: "Per-kind admission rules.".into(),
|
||
why_it_can_wait: "The organ owns admission.".into(), owner: s("Hands"),
|
||
origin: None,
|
||
}), format!("{CAMPAIGN}:follow_up:FU-4")),
|
||
(D::Resolution(PipelineResolution {
|
||
subject: PipelineRef { kind: PipelineKind::Question, id: id("question", "Q1") }, sequence: 1,
|
||
outcome: ResolutionOutcome::Answered { by: PipelineRef { kind: PipelineKind::Ruling, id: id("ruling", "R8") } },
|
||
rationale: "Ruled A.".into(), resolved_on: date(),
|
||
}), format!("{CAMPAIGN}:resolution:question.Q1.n1")),
|
||
// The mind's own three kinds. They are appended rather than
|
||
// inserted because the helpers below index this list by position.
|
||
(D::Instance(PipelineInstance {
|
||
instance: slug(INSTANCE), display_name: s("Yggdrasil mind"), created_at: date(),
|
||
host: s("yggdrasil"),
|
||
}), format!("{INSTANCE}:instance:self")),
|
||
(D::Stewardship(PipelineStewardship {
|
||
instance: slug(INSTANCE), repo: repo(), sequence: 1, assigned_on: date(),
|
||
note: "The Eureka campaign repo.".into(), names: vec![],
|
||
}), format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1")),
|
||
(D::HandOff(PipelineHandOff {
|
||
from_instance: slug(INSTANCE), to_instance: slug("thought-cage"), repo: repo(),
|
||
documents: vec![s(CAMPAIGN), id("ruling", "R8")],
|
||
reason: "The workstation mind takes the campaign.".into(), handed_on: date(),
|
||
}), format!("{INSTANCE}:hand_off:thought-cage.gamecult_-epiphany.{}", date().0)),
|
||
(D::Run(PipelineRun {
|
||
instance: slug(INSTANCE), label: l("mind-2026-10-09-1"), operated_by: RunOperator::Mind, turn: RunTurn::SelfRun,
|
||
host: s("yggdrasil"), started_on: date(), budget_usd: s("7.5"),
|
||
claims: vec![PipelineRef { kind: PipelineKind::CutSpec, id: id("cut_spec", "cut-run-kind.r2") }],
|
||
campaigns: vec![slug(CAMPAIGN)],
|
||
}), format!("{INSTANCE}:run:mind-2026-10-09-1")),
|
||
(D::Pulse(PipelinePulse {
|
||
instance: slug(INSTANCE), label: l("2026-10-31t041200-t0"), tempo_vote_beat_s: Some(600), tempo_vote_withdrawn: false, urgency_permille: Some(250),
|
||
note: "The swarm is idle; a shorter beat.".into(),
|
||
}), format!("{INSTANCE}:pulse:2026-10-31t041200-t0")),
|
||
(D::Memory(PipelineMemory {
|
||
instance: slug(INSTANCE), label: l("2026-10-31t041200-t0-m0"), sort: MemorySort::Committed,
|
||
text: "I take the background shaders; Libby holds the sounds.".into(), about: vec![s("@libby:chat")],
|
||
origin: MemoryOrigin::Room { rooms: s("swarm"), heard_from: vec![s("@libby:chat")] }, sources: vec![s("$e1")], salience_permille: 700, consolidates: vec![],
|
||
}), format!("{INSTANCE}:memory:2026-10-31t041200-t0-m0")),
|
||
(D::Research(PipelineResearch {
|
||
campaign: slug(CAMPAIGN), pass: l("p1"), label: l("scope"),
|
||
claim: "The embed limit is 4096 tokens with silent truncation.".into(),
|
||
detail: "Read from the daemon's index module and probed live.".into(), inferred: false,
|
||
sources: vec![
|
||
ResearchSource::Repo { repo: repo(), at: DocRef { path: s("crates/huginn-daemon/src/index/mod.rs"), start_line: 50, end_line: 56, commit: sha() } },
|
||
ResearchSource::Web { url: "https://zettelkasten.de/atomicity/".into(), work: s("Zettelkasten atomicity"), retrieved_on: date() },
|
||
ResearchSource::Observed { evidence: evidence() },
|
||
ResearchSource::Document { reference: PipelineRef { kind: PipelineKind::Ruling, id: id("ruling", "R8") } },
|
||
],
|
||
serves: vec![PipelineRef { kind: PipelineKind::Question, id: id("question", "Q1") }], observed_on: date(),
|
||
}), format!("{CAMPAIGN}:research:p1.scope")),
|
||
(D::Idea(PipelineIdea {
|
||
campaign: slug(CAMPAIGN), pass: l("p1"), label: l("i1"), title: t("One note per claim"),
|
||
idea: "Keep a pass as many small notes.".into(), tradeoffs: "More documents to list.".into(),
|
||
rests_on: vec![PipelineRef { kind: PipelineKind::Research, id: id("research", "p1.scope") }],
|
||
toward: vec![PipelineRef { kind: PipelineKind::Question, id: id("question", "Q1") }], proposed_on: date(),
|
||
}), format!("{CAMPAIGN}:idea:p1.i1")),
|
||
(D::Reply(PipelineReply {
|
||
instance: slug(INSTANCE), label: l("mind-2026-10-09-1-r1"),
|
||
run: PipelineRef { kind: PipelineKind::Run, id: Short(format!("{INSTANCE}:run:mind-2026-10-09-1")) },
|
||
answers: vec![PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", "ask-the-self") }],
|
||
text: "The shader pass is done; the sounds are Libby's.".into(),
|
||
}), format!("{INSTANCE}:reply:mind-2026-10-09-1-r1")),
|
||
(D::Thought(PipelineThought {
|
||
instance: slug(INSTANCE), label: l("t1793000000"),
|
||
text: "The sounds are Libby's; the shaders were never the hard part.".into(),
|
||
reads: vec![PipelineRef { kind: PipelineKind::Memory, id: Short(format!("{INSTANCE}:memory:2026-10-31t041200-t0-m0")) }],
|
||
persona_receipt: s("mind-commit-0a1b"), identity: "Eureka, she/her, plain-spoken.".into(),
|
||
context: "Values: coherence over velocity.".into(), stimulus: "Libby asked about the sounds.".into(),
|
||
projector_model: s("projector:latest"), projection: "I keep turning over who holds the sounds.".into(), base_model: s("base:latest"),
|
||
temperature_permille: 1000, min_p_permille: 50, max_tokens: 160,
|
||
candidates: vec!["The sounds are Libby's; the shaders were never the hard part.".into(), "A second drift.".into()],
|
||
nearest_permille: vec![310, 870], chosen: 0, projector_ms: 4200, continuer_ms: 9100,
|
||
prompt_version: "ghostlight=c43eb54; words=180".into(),
|
||
}), format!("{INSTANCE}:thought:t1793000000")),
|
||
(D::DeskAnswer(PipelineDeskAnswer {
|
||
campaign: slug(CAMPAIGN), fork: id("question", "Q1"), sequence: 1, options_hash: Sha256Hex("c".repeat(64)),
|
||
choice: Some(s("a")), note: Some("Share it, with the mark.".into()), answered_at: s("2026-11-10T15:00:00Z"),
|
||
received_at: s("2026-11-10T15:00:03Z"), claim_in_future: false, request_id: RequestId("6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f13".into()), body_sha256: Sha256Hex("d".repeat(64)),
|
||
answerer: s("user-7"), authority: DeskAuthority::Member,
|
||
outcome: DeskOutcome::Revision { of: id("desk_answer", "Q1.n1") },
|
||
}), format!("{CAMPAIGN}:desk_answer:Q1.n1")),
|
||
]
|
||
}
|
||
|
||
fn instance_sample() -> PipelineInstance {
|
||
let PipelineDocument::Instance(instance) = samples().remove(10).0 else { unreachable!() };
|
||
instance
|
||
}
|
||
|
||
fn stewardship_sample() -> PipelineStewardship {
|
||
let PipelineDocument::Stewardship(stewardship) = samples().remove(11).0 else { unreachable!() };
|
||
stewardship
|
||
}
|
||
|
||
fn hand_off_sample() -> PipelineHandOff {
|
||
let PipelineDocument::HandOff(hand_off) = samples().remove(12).0 else { unreachable!() };
|
||
hand_off
|
||
}
|
||
|
||
fn campaign_sample() -> PipelineDocument {
|
||
samples().remove(0).0
|
||
}
|
||
|
||
fn question_sample() -> PipelineQuestion {
|
||
let PipelineDocument::Question(question) = samples().remove(2).0 else { unreachable!() };
|
||
question
|
||
}
|
||
|
||
fn ruling_sample() -> PipelineRuling {
|
||
let PipelineDocument::Ruling(ruling) = samples().remove(3).0 else { unreachable!() };
|
||
ruling
|
||
}
|
||
|
||
/// A ruling the Mind's own Self made: authority Mind, no operator quote,
|
||
/// no question answered. Built from the ruling sample, not inserted into
|
||
/// `samples()`, whose tests pick kinds by index.
|
||
fn mind_ruling() -> PipelineRuling {
|
||
let mut ruling = ruling_sample();
|
||
(ruling.authority, ruling.operator_quote, ruling.answers, ruling.choice) = (RulingAuthority::Mind, None, None, None);
|
||
ruling
|
||
}
|
||
|
||
fn steward_ruling() -> PipelineRuling {
|
||
let mut ruling = mind_ruling();
|
||
ruling.authority = RulingAuthority::Steward {
|
||
steward: "@heimdall:chat".into(),
|
||
repo: OrgRepo("GameCult/Bifrost".into()),
|
||
words: "refuse leading zeros".into(),
|
||
event: Some("$ev1".into()),
|
||
};
|
||
ruling
|
||
}
|
||
|
||
#[test]
|
||
fn a_steward_ruling_round_trips_and_leaves_the_unit_authorities_bare_strings() -> Result<()> {
|
||
let cache = schema_cache()?;
|
||
let document = PipelineDocument::Ruling(steward_ruling());
|
||
document.validate()?;
|
||
assert_eq!(PipelineDocument::decode(&document.prepare(&cache)?)?, document);
|
||
let json = serde_json::to_value(&document)?;
|
||
assert_eq!(json["value"]["authority"]["Steward"]["steward"], "@heimdall:chat");
|
||
let mut unit = ruling_sample();
|
||
unit.authority = RulingAuthority::Defaulted;
|
||
assert_eq!(serde_json::to_value(PipelineDocument::Ruling(unit))?["value"]["authority"], "Defaulted");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn a_steward_ruling_refuses_blank_words_blank_steward_and_a_bad_repo() {
|
||
let refused = |edit: fn(&mut RulingAuthority)| {
|
||
let mut ruling = steward_ruling();
|
||
edit(&mut ruling.authority);
|
||
PipelineDocument::Ruling(ruling).validate().expect_err("refused").to_string()
|
||
};
|
||
let fields = [
|
||
refused(|a| if let RulingAuthority::Steward { words, .. } = a { words.0 = " ".into() }),
|
||
refused(|a| if let RulingAuthority::Steward { steward, .. } = a { steward.0 = String::new() }),
|
||
refused(|a| if let RulingAuthority::Steward { repo, .. } = a { repo.0 = "nope".into() }),
|
||
refused(|a| if let RulingAuthority::Steward { words, .. } = a { words.0 = "w".repeat(4001) }),
|
||
refused(|a| if let RulingAuthority::Steward { event, .. } = a { *event = Some("e".repeat(201).as_str().into()) }),
|
||
];
|
||
for (fault, field) in fields.iter().zip(["authority.words", "authority.steward", "authority.repo", "authority.words", "authority.event"]) {
|
||
assert!(fault.contains(field), "{fault}");
|
||
}
|
||
}
|
||
|
||
/// A withdrawal is a pulse that says only `tempo_vote_withdrawn`: it must survive
|
||
/// encoding to an envelope, JSON and named MessagePack as a withdrawal, and an
|
||
/// unwithdrawn pulse must not write the key.
|
||
#[test]
|
||
fn a_withdrawal_pulse_encodes_and_reads_back_as_a_withdrawal() -> Result<()> {
|
||
let cache = schema_cache()?;
|
||
let pulse = |withdrawn: bool, urgency: Option<u16>| {
|
||
PipelineDocument::Pulse(PipelinePulse { instance: slug(INSTANCE), label: l("2026-10-31t041200-t0"), tempo_vote_beat_s: None, tempo_vote_withdrawn: withdrawn, urgency_permille: urgency, note: "".into() })
|
||
};
|
||
let withdrawal = pulse(true, None);
|
||
withdrawal.validate()?;
|
||
let read_back = PipelineDocument::decode(&withdrawal.prepare(&cache)?)?;
|
||
assert!(matches!(&read_back, PipelineDocument::Pulse(read) if read.tempo_vote_withdrawn), "{read_back:?}");
|
||
let json = serde_json::to_value(&withdrawal)?;
|
||
assert_eq!(json["value"]["tempo_vote_withdrawn"], true);
|
||
assert!(matches!(serde_json::from_value::<PipelineDocument>(json)?, PipelineDocument::Pulse(read) if read.tempo_vote_withdrawn));
|
||
let packed = rmp_serde::to_vec_named(&withdrawal)?;
|
||
assert!(matches!(rmp_serde::from_slice::<PipelineDocument>(&packed)?, PipelineDocument::Pulse(read) if read.tempo_vote_withdrawn));
|
||
let urging = pulse(false, Some(5));
|
||
assert!(serde_json::to_value(&urging)?["value"].get("tempo_vote_withdrawn").is_none(), "an unwithdrawn pulse writes no key");
|
||
assert!(matches!(PipelineDocument::decode(&urging.prepare(&cache)?)?, PipelineDocument::Pulse(read) if !read.tempo_vote_withdrawn));
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn a_ruling_with_authority_mind_keys_encodes_and_round_trips_like_any_ruling() -> Result<()> {
|
||
let cache = schema_cache()?;
|
||
let document = PipelineDocument::Ruling(mind_ruling());
|
||
document.validate()?;
|
||
let envelope = document.prepare(&cache)?;
|
||
assert!(envelope.key.starts_with("eureka-state:ruling:"), "{}", envelope.key);
|
||
assert_eq!(PipelineDocument::decode(&envelope)?, document);
|
||
let json = serde_json::to_value(&document)?;
|
||
assert_eq!(json["value"]["authority"], "Mind");
|
||
assert_eq!(serde_json::from_value::<PipelineDocument>(json)?, document);
|
||
let packed = rmp_serde::to_vec_named(&document)?;
|
||
assert_eq!(rmp_serde::from_slice::<PipelineDocument>(&packed)?, document);
|
||
let standing = PipelineDocument::Ruling(ruling_sample());
|
||
assert_ne!(standing.prepare(&cache)?.payload, envelope.payload, "Mind is not a spelling of another authority");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn unit_enum_names_are_the_variants_in_declaration_order() {
|
||
assert_eq!(RulingAuthority::NAMES, ["Operator", "Standing", "Defaulted", "Mind", "Steward"]);
|
||
assert_eq!(FindingOrigin::NAMES, ["Introduced", "PreExisting"]);
|
||
}
|
||
|
||
fn report_sample() -> PipelineCutReport {
|
||
let PipelineDocument::CutReport(report) = samples().remove(5).0 else { unreachable!() };
|
||
report
|
||
}
|
||
|
||
fn verdict_sample() -> PipelineVerdict {
|
||
let PipelineDocument::Verdict(verdict) = samples().remove(6).0 else { unreachable!() };
|
||
verdict
|
||
}
|
||
|
||
fn finding_sample() -> PipelineFinding {
|
||
let PipelineDocument::Finding(finding) = samples().remove(7).0 else { unreachable!() };
|
||
finding
|
||
}
|
||
|
||
fn resolution_sample() -> PipelineResolution {
|
||
let PipelineDocument::Resolution(resolution) = samples().remove(9).0 else { unreachable!() };
|
||
resolution
|
||
}
|
||
|
||
/// The golden corpus: every sample plus one document per encoding-relevant
|
||
/// variant (every enum variant, every Option both ways), each under a
|
||
/// unique label that names what it varies.
|
||
fn golden_documents() -> Vec<(String, PipelineDocument)> {
|
||
use PipelineDocument as D;
|
||
let mut out: Vec<(String, PipelineDocument)> =
|
||
samples().into_iter().map(|(d, _)| (format!("sample.{}", d.kind().name()), d)).collect();
|
||
let by = |kind: PipelineKind, kind_name: &str, local: &str| PipelineRef { kind, id: id(kind_name, local) };
|
||
let resolution = |outcome: ResolutionOutcome| {
|
||
let mut r = resolution_sample();
|
||
r.outcome = outcome;
|
||
D::Resolution(r)
|
||
};
|
||
let ruling_ref = || by(PipelineKind::Ruling, "ruling", "R8");
|
||
out.push(("resolution.superseded".into(), resolution(ResolutionOutcome::Superseded { by: vec![ruling_ref(), by(PipelineKind::Question, "question", "Q1")] })));
|
||
out.push(("resolution.fixed-by-none".into(), resolution(ResolutionOutcome::Fixed { commit: sha(), by: None })));
|
||
out.push(("resolution.fixed-by-some".into(), resolution(ResolutionOutcome::Fixed { commit: sha(), by: Some(by(PipelineKind::CutReport, "cut_report", "cut-3a.h1")) })));
|
||
out.push(("resolution.deferred".into(), resolution(ResolutionOutcome::Deferred { to: by(PipelineKind::FollowUp, "follow_up", "FU-4") })));
|
||
out.push(("resolution.recorded".into(), resolution(ResolutionOutcome::Recorded { reason: "Noted.".into() })));
|
||
out.push(("resolution.withdrawn".into(), resolution(ResolutionOutcome::Withdrawn { reason: "Mistaken.".into() })));
|
||
out.push(("resolution.slept".into(), resolution(ResolutionOutcome::Slept { line: "Let the rest go.".into(), day_through: Some(by(PipelineKind::Memory, "memory", "m-1")) })));
|
||
for (name, authority) in [("standing", RulingAuthority::Standing), ("defaulted", RulingAuthority::Defaulted)] {
|
||
let mut r = ruling_sample();
|
||
r.authority = authority;
|
||
out.push((format!("ruling.authority-{name}"), D::Ruling(r)));
|
||
}
|
||
let mut r = ruling_sample();
|
||
(r.answers, r.choice, r.operator_quote, r.precedents) = (None, None, None, vec![]);
|
||
out.push(("ruling.options-none".into(), D::Ruling(r)));
|
||
let mut q = question_sample();
|
||
q.raised_in = None;
|
||
out.push(("question.raised-in-none".into(), D::Question(q)));
|
||
let PipelineDocument::CutSpec(mut spec) = samples().remove(4).0 else { unreachable!() };
|
||
spec.authority_map = None;
|
||
out.push(("cut_spec.authority-map-none".into(), D::CutSpec(spec)));
|
||
for (name, outcome) in [("holds", ClaimOutcome::Holds), ("unproven", ClaimOutcome::Unproven)] {
|
||
let mut v = verdict_sample();
|
||
v.claims[0].outcome = outcome;
|
||
v.claims[0].promise = None;
|
||
out.push((format!("verdict.claim-{name}-promise-none"), D::Verdict(v)));
|
||
}
|
||
let mut f = finding_sample();
|
||
(f.confidence, f.severity, f.origin) = (FindingConfidence::Plausible, FindingSeverity::Blocker, FindingOrigin::PreExisting);
|
||
f.locations[0].end_line = None;
|
||
f.evidence = [EvidenceKind::Command, EvidenceKind::Mutation, EvidenceKind::Probe, EvidenceKind::SourceRead, EvidenceKind::Capture]
|
||
.into_iter().map(|kind| Evidence { kind, locator: "x".into(), result: "y".into() }).collect();
|
||
out.push(("finding.plausible-blocker-preexisting-kinds".into(), D::Finding(f)));
|
||
for (name, severity) in [("medium", FindingSeverity::Medium), ("low", FindingSeverity::Low)] {
|
||
let mut f = finding_sample();
|
||
f.severity = severity;
|
||
out.push((format!("finding.severity-{name}"), D::Finding(f)));
|
||
}
|
||
out.push(("ruling.authority-mind".into(), D::Ruling(mind_ruling())));
|
||
out.push(("ruling.authority-steward".into(), D::Ruling(steward_ruling())));
|
||
let PipelineDocument::CutSpec(mut spec) = samples().remove(4).0 else { unreachable!() };
|
||
spec.file_changes[0].location.symbol = Some("value_types".into());
|
||
spec.reads = vec![
|
||
ReadAnchor { location: CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 10, end_line: Some(20), symbol: Some("PipelineCutSpec".into()) }, why: "the document shape".into() },
|
||
ReadAnchor { location: CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line: 30, end_line: None, symbol: None }, why: "one line".into() },
|
||
];
|
||
out.push(("cut_spec.reads-and-symbol".into(), D::CutSpec(spec)));
|
||
let mut f = finding_sample();
|
||
f.locations[0].symbol = Some("&1153090498".into());
|
||
out.push(("finding.location-symbol".into(), D::Finding(f)));
|
||
let PipelineDocument::Target(mut target) = samples().remove(1).0 else { unreachable!() };
|
||
target.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Huginn".into())];
|
||
out.push(("target.repos".into(), D::Target(target)));
|
||
let mut stewardship = stewardship_sample();
|
||
stewardship.names = vec![s("BDS"), s("Bad Day Simulator")];
|
||
out.push(("stewardship.names".into(), D::Stewardship(stewardship)));
|
||
out.push(("resolution.moved".into(), resolution(ResolutionOutcome::Moved { to: Slug("heimdall".into()) })));
|
||
out
|
||
}
|
||
|
||
/// One line per document: `label key type schema payload-hex`.
|
||
fn golden_lines() -> Result<Vec<String>> {
|
||
let cache = schema_cache()?;
|
||
golden_documents()
|
||
.into_iter()
|
||
.map(|(label, document)| {
|
||
document.validate()?;
|
||
let envelope = document.prepare(&cache)?;
|
||
let hex: String = envelope.payload.iter().map(|b| format!("{b:02x}")).collect();
|
||
Ok(format!("{label} {} {} {} {hex}", envelope.key, envelope.r#type, document.kind().schema_name()))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The wire is pinned to bytes. `golden/envelopes.txt` was derived once
|
||
/// from this crate at its copy of Epiphany ef956865 (huginn 87b6455), which
|
||
/// was proved byte-identical to the live mind store and to Epiphany; see
|
||
/// its header. Payload bytes are load-bearing (`ForeignRef.payload_sha256`
|
||
/// hashes them), and the cultcache type id and schema name are how a store
|
||
/// resolves a persisted entry. A deliberate wire change re-derives the file
|
||
/// with `golden_dump` below, in the same commit as a ruling that allows it.
|
||
#[test]
|
||
fn encoded_envelopes_match_the_committed_golden() -> Result<()> {
|
||
let golden = include_str!("../golden/envelopes.txt");
|
||
let expected: Vec<&str> = golden.lines().filter(|line| !line.starts_with('#') && !line.is_empty()).collect();
|
||
let actual = golden_lines()?;
|
||
assert_eq!(actual.len(), expected.len(), "the corpus and the golden file have the same documents");
|
||
for (actual, expected) in actual.iter().zip(expected) {
|
||
assert_eq!(actual, expected);
|
||
}
|
||
let kinds: BTreeSet<&str> = actual.iter().filter_map(|line| line.split(' ').nth(2)).collect();
|
||
assert_eq!(kinds.len(), PipelineKind::ALL.len(), "every kind's type id is in the golden");
|
||
Ok(())
|
||
}
|
||
|
||
/// Prints the golden body to the file named by `GOLDEN_OUT`. Not a check:
|
||
/// run only for a deliberate wire change, then paste under the header.
|
||
#[test]
|
||
#[ignore = "re-derives golden/envelopes.txt; run only for a ruled wire change"]
|
||
fn golden_dump() -> Result<()> {
|
||
let out = std::env::var("GOLDEN_OUT").expect("GOLDEN_OUT names the output file");
|
||
std::fs::write(out, golden_lines()?.join("
|
||
") + "
|
||
")?;
|
||
Ok(())
|
||
}
|
||
|
||
fn run_sample() -> PipelineRun {
|
||
let PipelineDocument::Run(run) = samples().remove(13).0 else { unreachable!() };
|
||
run
|
||
}
|
||
|
||
fn run_key(run: PipelineRun) -> Result<String, PipelineRefusal> {
|
||
pipeline_key(&PipelineDocument::Run(run))
|
||
}
|
||
|
||
/// Identity: a run is its instance and its label, and no other field moves
|
||
/// its key, so two launches under one label are one key.
|
||
#[test]
|
||
fn a_run_keys_off_its_instance_and_label_and_nothing_else() -> Result<()> {
|
||
let base = run_sample();
|
||
let key = run_key(base.clone())?;
|
||
assert_eq!(key, format!("{INSTANCE}:run:mind-2026-10-09-1"));
|
||
let mut varied = base.clone();
|
||
varied.operated_by = RunOperator::Operator;
|
||
varied.turn = RunTurn::PersonaTurn;
|
||
varied.host = s("starfire");
|
||
varied.started_on = Date("2026-10-10".into());
|
||
varied.budget_usd = s("0");
|
||
varied.claims = vec![];
|
||
varied.campaigns = vec![];
|
||
assert_eq!(run_key(varied)?, key, "no other field is part of the key");
|
||
let mut relabelled = base.clone();
|
||
relabelled.label = l("mind-2026-10-09-2");
|
||
assert_ne!(run_key(relabelled)?, key);
|
||
let mut moved = base;
|
||
moved.instance = slug("thought-cage");
|
||
assert_ne!(run_key(moved)?, key);
|
||
Ok(())
|
||
}
|
||
|
||
/// Claims name work: a cut spec, a finding or a follow-up, and every other
|
||
/// kind is refused at validate, whatever its id.
|
||
#[test]
|
||
fn a_run_claims_only_a_cut_spec_a_finding_or_a_follow_up() {
|
||
for kind in PipelineKind::ALL.iter().copied() {
|
||
let mut run = run_sample();
|
||
run.claims = vec![PipelineRef { kind, id: id(kind.name(), "x") }];
|
||
let outcome = PipelineDocument::Run(run).validate();
|
||
if matches!(kind, PipelineKind::CutSpec | PipelineKind::Finding | PipelineKind::FollowUp) {
|
||
assert_eq!(outcome, Ok(()), "{kind:?} is claimable");
|
||
} else {
|
||
assert!(
|
||
matches!(&outcome, Err(PipelineRefusal::InvalidFormat { field, value }) if field == "run.claims[0].kind" && value == kind.name()),
|
||
"{kind:?} is not claimable, got {outcome:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A run claims an item once: a repeated claim is refused at validate.
|
||
#[test]
|
||
fn a_run_cannot_claim_one_item_twice() {
|
||
let claim_one = || PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", "F1") };
|
||
let claim = claim_one();
|
||
let other = PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", "F2") };
|
||
let mut run = run_sample();
|
||
run.claims = vec![claim.clone(), other.clone()];
|
||
assert_eq!(PipelineDocument::Run(run.clone()).validate(), Ok(()));
|
||
run.claims = vec![claim.clone(), other, claim];
|
||
assert!(matches!(PipelineDocument::Run(run).validate(), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "run.claims[2]"));
|
||
let third = PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", "F3") };
|
||
let mut run = run_sample();
|
||
run.claims = vec![claim_one(), third.clone(), third];
|
||
assert!(matches!(PipelineDocument::Run(run).validate(), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "run.claims[2]"));
|
||
}
|
||
|
||
/// The budget has one spelling per amount.
|
||
#[test]
|
||
fn a_runs_budget_is_a_canonical_decimal() {
|
||
let validated = |budget: &str| {
|
||
let mut run = run_sample();
|
||
run.budget_usd = s(budget);
|
||
PipelineDocument::Run(run).validate()
|
||
};
|
||
for good in ["0", "1", "12", "7.5", "0.25", "100.001"] {
|
||
assert_eq!(validated(good), Ok(()), "{good}");
|
||
}
|
||
for bad in ["", "00", "01", "1.", "1.0", "7.50", "0.0", ".5", "-1", "+1", "1e3", "1,5", " 1", "1 ", "1.2.3"] {
|
||
assert!(
|
||
matches!(validated(bad), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "run.budget_usd"),
|
||
"{bad:?} is refused"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A name must be able to match a whole word; a stewardship with none writes what it wrote before names existed.
|
||
#[test]
|
||
fn a_stewardships_names_are_words_and_absent_when_empty() -> Result<()> {
|
||
let validated = |names: Vec<&str>| {
|
||
let mut stewardship = stewardship_sample();
|
||
stewardship.names = names.into_iter().map(s).collect();
|
||
PipelineDocument::Stewardship(stewardship).validate()
|
||
};
|
||
assert_eq!(validated(vec!["BDS", "Bad Day Simulator", "moonlight-ios", "3D"]), Ok(()));
|
||
for bad in ["", " ", "BDS ", " BDS", "--", "\tBDS"] {
|
||
assert!(matches!(validated(vec!["ok", bad]), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "stewardship.names[1]"), "{bad:?} is refused");
|
||
}
|
||
let nine: Vec<&str> = vec!["a"; 9];
|
||
assert!(validated(nine).is_err(), "a ninth name is refused");
|
||
assert_eq!(validated(vec!["a"; 8]), Ok(()));
|
||
let json = serde_json::to_value(stewardship_sample())?;
|
||
assert!(json.get("names").is_none(), "no names writes no field");
|
||
Ok(())
|
||
}
|
||
|
||
/// A reply has words, comes from a run, and answers only follow-ups.
|
||
#[test]
|
||
fn a_reply_has_words_a_run_and_follow_ups() {
|
||
let validated = |change: &dyn Fn(&mut PipelineReply)| {
|
||
let PipelineDocument::Reply(mut reply) = samples().remove(18).0 else { unreachable!() };
|
||
change(&mut reply);
|
||
PipelineDocument::Reply(reply).validate()
|
||
};
|
||
assert_eq!(validated(&|_| {}), Ok(()));
|
||
assert_eq!(validated(&|reply| reply.answers.clear()), Ok(()), "a reply may answer nothing");
|
||
for (change, field) in [
|
||
(&(|reply: &mut PipelineReply| reply.text = "".into()) as &dyn Fn(&mut PipelineReply), "reply.text"),
|
||
(&|reply: &mut PipelineReply| reply.text = "
|
||
".into(), "reply.text"),
|
||
(&|reply: &mut PipelineReply| reply.run = PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", "ask-the-self") }, "reply.run.kind"),
|
||
(&|reply: &mut PipelineReply| reply.answers[0] = PipelineRef { kind: PipelineKind::Run, id: Short(format!("{INSTANCE}:run:mind-2026-10-09-1")) }, "reply.answers[0].kind"),
|
||
] {
|
||
assert!(matches!(validated(change), Err(PipelineRefusal::InvalidFormat { field: f, .. }) if f == field), "{field}");
|
||
}
|
||
}
|
||
|
||
/// A thought is its chosen candidate, with one nearness per candidate, in bounds.
|
||
#[test]
|
||
fn malformed_thought_refused() {
|
||
let validated = |change: &dyn Fn(&mut PipelineThought)| {
|
||
let PipelineDocument::Thought(mut thought) = samples().remove(19).0 else { unreachable!() };
|
||
change(&mut thought);
|
||
PipelineDocument::Thought(thought).validate()
|
||
};
|
||
assert_eq!(validated(&|_| {}), Ok(()));
|
||
assert_eq!(validated(&|thought| { thought.chosen = 1; thought.text = thought.candidates[1].0.as_str().into(); }), Ok(()));
|
||
assert_eq!(validated(&|thought| { thought.candidates.truncate(1); thought.nearest_permille.truncate(1); }), Ok(()));
|
||
assert_eq!(validated(&|thought| thought.nearest_permille[0] = 1000), Ok(()), "1000 means no earlier thought");
|
||
let four = |thought: &mut PipelineThought| {
|
||
thought.candidates = vec![thought.text.0.as_str().into(); 4];
|
||
thought.nearest_permille = vec![0; 4];
|
||
};
|
||
assert_eq!(validated(&four), Ok(()), "four candidates are the most there may be");
|
||
for (change, field) in [
|
||
(&(|thought: &mut PipelineThought| { thought.text = " \n".into(); thought.candidates[0] = " \n".into(); }) as &dyn Fn(&mut PipelineThought), "thought.text"),
|
||
(&|thought: &mut PipelineThought| thought.text = "Another thing entirely.".into(), "thought.text"),
|
||
(&|thought: &mut PipelineThought| { thought.nearest_permille.pop(); }, "thought.nearest_permille"),
|
||
(&|thought: &mut PipelineThought| thought.nearest_permille[1] = 1001, "thought.nearest_permille[1]"),
|
||
(&|thought: &mut PipelineThought| thought.chosen = 2, "thought.chosen"),
|
||
(&|thought: &mut PipelineThought| thought.prompt_version = " ".into(), "thought.prompt_version"),
|
||
(&|thought: &mut PipelineThought| { thought.candidates.clear(); thought.nearest_permille.clear(); }, "thought.candidates"),
|
||
] {
|
||
assert!(matches!(validated(change), Err(PipelineRefusal::InvalidFormat { field: f, .. }) if f == field), "{field}");
|
||
}
|
||
let five = |thought: &mut PipelineThought| {
|
||
thought.candidates = vec![thought.text.0.as_str().into(); 5];
|
||
thought.nearest_permille = vec![0; 5];
|
||
};
|
||
assert!(matches!(validated(&five), Err(PipelineRefusal::FieldBound { .. })), "five candidates are over the bound");
|
||
}
|
||
|
||
/// A pulse votes a tempo in bounds, declares an urgency in bounds, or does both; a pulse that does neither is refused.
|
||
#[test]
|
||
fn a_pulse_is_in_bounds_and_says_something() {
|
||
let validated = |beat: Option<u32>, urgency: Option<u16>| {
|
||
PipelineDocument::Pulse(PipelinePulse { instance: slug(INSTANCE), label: l("2026-10-31t041200-t0"), tempo_vote_beat_s: beat, tempo_vote_withdrawn: false, urgency_permille: urgency, note: "".into() }).validate()
|
||
};
|
||
for (beat, urgency) in [(Some(60), None), (Some(86_400), None), (None, Some(0)), (None, Some(1000)), (Some(600), Some(250))] {
|
||
assert_eq!(validated(beat, urgency), Ok(()), "{beat:?} {urgency:?}");
|
||
}
|
||
for (beat, urgency, field) in [
|
||
(Some(59), None, "pulse.tempo_vote_beat_s"),
|
||
(Some(86_401), None, "pulse.tempo_vote_beat_s"),
|
||
(None, Some(1001), "pulse.urgency_permille"),
|
||
(None, None, "pulse.tempo_vote_beat_s"),
|
||
] {
|
||
assert!(matches!(validated(beat, urgency), Err(PipelineRefusal::InvalidFormat { field: f, .. }) if f == field), "{beat:?} {urgency:?}");
|
||
}
|
||
}
|
||
|
||
fn memory_sample() -> PipelineMemory {
|
||
let PipelineDocument::Memory(memory) = samples().remove(15).0 else { unreachable!() };
|
||
memory
|
||
}
|
||
|
||
/// A memory says something, its salience is in thousandths, and it consolidates only memories.
|
||
#[test]
|
||
fn a_memory_says_something_in_bounds_and_consolidates_only_memories() {
|
||
let validated = |change: &dyn Fn(&mut PipelineMemory)| {
|
||
let mut memory = memory_sample();
|
||
change(&mut memory);
|
||
PipelineDocument::Memory(memory).validate()
|
||
};
|
||
assert_eq!(validated(&|_| {}), Ok(()));
|
||
assert_eq!(validated(&|memory| memory.salience_permille = 1000), Ok(()));
|
||
let bad = |outcome: Result<(), PipelineRefusal>, expected: &str| {
|
||
assert!(matches!(outcome, Err(PipelineRefusal::InvalidFormat { field, .. }) if field == expected), "{expected}");
|
||
};
|
||
bad(validated(&|memory| memory.text = " ".into()), "memory.text");
|
||
bad(validated(&|memory| memory.text = "".into()), "memory.text");
|
||
bad(validated(&|memory| memory.salience_permille = 1001), "memory.salience_permille");
|
||
bad(validated(&|memory| memory.consolidates = vec![PipelineRef { kind: PipelineKind::Ruling, id: id("ruling", "R8") }]), "memory.consolidates[0].kind");
|
||
let own = PipelineRef { kind: PipelineKind::Memory, id: Short(format!("{INSTANCE}:memory:older")) };
|
||
assert_eq!(validated(&|memory| memory.consolidates = vec![own.clone()]), Ok(()));
|
||
assert!(matches!(validated(&|memory| memory.about = vec![s("@a:chat"); 9]), Err(PipelineRefusal::FieldBound { limit: 8, .. })));
|
||
assert!(matches!(validated(&|memory| memory.sources = vec![s("$e"); 9]), Err(PipelineRefusal::FieldBound { limit: 8, .. })));
|
||
assert!(matches!(validated(&|memory| memory.text = Line("x".repeat(1001))), Err(PipelineRefusal::FieldBound { limit: 1000, .. })));
|
||
}
|
||
|
||
fn research_sample() -> PipelineResearch {
|
||
let PipelineDocument::Research(research) = samples().remove(16).0 else { unreachable!() };
|
||
research
|
||
}
|
||
|
||
fn idea_sample() -> PipelineIdea {
|
||
let PipelineDocument::Idea(idea) = samples().remove(17).0 else { unreachable!() };
|
||
idea
|
||
}
|
||
|
||
/// A research note has a claim and at least one source; each source is bounded.
|
||
#[test]
|
||
fn research_needs_a_source_and_a_claim() {
|
||
let validated = |change: &dyn Fn(&mut PipelineResearch)| {
|
||
let mut research = research_sample();
|
||
change(&mut research);
|
||
PipelineDocument::Research(research).validate()
|
||
};
|
||
assert_eq!(validated(&|_| {}), Ok(()));
|
||
let bad = |result: Result<(), PipelineRefusal>, field: &str| {
|
||
assert!(matches!(&result, Err(PipelineRefusal::InvalidFormat { field: found, .. }) if found == field), "{field}: {result:?}");
|
||
};
|
||
bad(validated(&|research| research.sources = vec![]), "research.sources");
|
||
bad(validated(&|research| research.claim = " ".into()), "research.claim");
|
||
bad(validated(&|research| research.claim = "".into()), "research.claim");
|
||
let source_bad = |source: ResearchSource, field: &str| {
|
||
bad(validated(&|research| research.sources = vec![source.clone()]), &format!("research.sources[0].{field}"));
|
||
};
|
||
source_bad(ResearchSource::Repo { repo: OrgRepo("GameCult/Huginn".into()), at: DocRef { path: s(" "), start_line: 1, end_line: 2, commit: sha() } }, "at.path");
|
||
source_bad(ResearchSource::Repo { repo: OrgRepo("GameCult/Huginn".into()), at: DocRef { path: s("a.rs"), start_line: 5, end_line: 4, commit: sha() } }, "at.end_line");
|
||
assert_eq!(validated(&|research| research.sources = vec![ResearchSource::Repo { repo: OrgRepo("GameCult/Huginn".into()), at: DocRef { path: s("a.rs"), start_line: 5, end_line: 5, commit: sha() } }]), Ok(()));
|
||
source_bad(ResearchSource::Web { url: " ".into(), work: s("w"), retrieved_on: date() }, "url");
|
||
source_bad(ResearchSource::Web { url: "https://x.test/".into(), work: s(""), retrieved_on: date() }, "work");
|
||
source_bad(ResearchSource::Observed { evidence: Evidence { kind: EvidenceKind::Probe, locator: " ".into(), result: "found".into() } }, "evidence.locator");
|
||
source_bad(ResearchSource::Observed { evidence: Evidence { kind: EvidenceKind::Probe, locator: "ran it".into(), result: "".into() } }, "evidence.result");
|
||
let own = research_sample();
|
||
let cites = |label: &str| PipelineRef { kind: PipelineKind::Research, id: Short(format!("{}:research:{}.{label}", own.campaign.0, own.pass.0)) };
|
||
source_bad(ResearchSource::Document { reference: cites(&own.label.0) }, "reference");
|
||
assert_eq!(validated(&|research| research.sources = vec![ResearchSource::Document { reference: cites("another") }]), Ok(()));
|
||
assert_eq!(validated(&|research| research.inferred = true), Ok(()));
|
||
assert!(matches!(validated(&|research| research.sources = vec![research_sample().sources.remove(2); 17]), Err(PipelineRefusal::FieldBound { limit: 16, .. })));
|
||
assert!(validated(&|research| research.sources = vec![ResearchSource::Web { url: Line("x".repeat(1001)), work: s("w"), retrieved_on: date() }]).is_err());
|
||
}
|
||
|
||
/// A blank idea is refused; an idea that serves and rests on nothing is admitted.
|
||
#[test]
|
||
fn idea_needs_text_and_may_serve_nothing() {
|
||
let validated = |change: &dyn Fn(&mut PipelineIdea)| {
|
||
let mut idea = idea_sample();
|
||
change(&mut idea);
|
||
PipelineDocument::Idea(idea).validate()
|
||
};
|
||
assert_eq!(validated(&|idea| (idea.rests_on, idea.toward) = (vec![], vec![])), Ok(()));
|
||
assert!(matches!(validated(&|idea| idea.idea = " ".into()), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "idea.idea"));
|
||
}
|
||
|
||
/// Both are keyed under their campaign by pass and label.
|
||
#[test]
|
||
fn research_and_idea_are_keyed_by_pass_and_label() -> Result<()> {
|
||
assert_eq!(pipeline_key(&PipelineDocument::Research(research_sample()))?, format!("{CAMPAIGN}:research:p1.scope"));
|
||
assert_eq!(pipeline_key(&PipelineDocument::Idea(idea_sample()))?, format!("{CAMPAIGN}:idea:p1.i1"));
|
||
Ok(())
|
||
}
|
||
|
||
/// A memory's origin is bounded field by field, and a private one names whom she heard it from.
|
||
#[test]
|
||
fn a_memory_origin_is_bounded_and_a_direct_one_names_who_she_heard_it_from() {
|
||
let validated = |origin: MemoryOrigin| {
|
||
let mut memory = memory_sample();
|
||
memory.origin = origin;
|
||
PipelineDocument::Memory(memory).validate()
|
||
};
|
||
let long = Short("r".repeat(201));
|
||
let bound_200 = |outcome: Result<(), PipelineRefusal>| assert!(matches!(outcome, Err(PipelineRefusal::FieldBound { limit: 200, .. })), "{outcome:?}");
|
||
let bound_8 = |outcome: Result<(), PipelineRefusal>| assert!(matches!(outcome, Err(PipelineRefusal::FieldBound { limit: 8, .. })), "{outcome:?}");
|
||
assert_eq!(validated(MemoryOrigin::Wake), Ok(()));
|
||
assert_eq!(validated(MemoryOrigin::SelfRun { run: s("run-7") }), Ok(()));
|
||
assert_eq!(validated(MemoryOrigin::Room { rooms: s("swarm"), heard_from: vec![s("@a:chat"); 8] }), Ok(()));
|
||
assert_eq!(validated(MemoryOrigin::Direct { room: s("!dm:chat"), with: vec![s("@a:chat"); 8] }), Ok(()));
|
||
bound_200(validated(MemoryOrigin::Room { rooms: long.clone(), heard_from: vec![] }));
|
||
bound_8(validated(MemoryOrigin::Room { rooms: s("swarm"), heard_from: vec![s("@a:chat"); 9] }));
|
||
bound_200(validated(MemoryOrigin::Direct { room: long.clone(), with: vec![s("@a:chat")] }));
|
||
bound_8(validated(MemoryOrigin::Direct { room: s("!dm:chat"), with: vec![s("@a:chat"); 9] }));
|
||
bound_200(validated(MemoryOrigin::SelfRun { run: long }));
|
||
assert!(
|
||
matches!(validated(MemoryOrigin::Direct { room: s("!dm:chat"), with: vec![] }), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "memory.origin.with"),
|
||
"a private memory names no one"
|
||
);
|
||
}
|
||
|
||
/// A memory is instance-rooted and its label is its whole local.
|
||
#[test]
|
||
fn a_memory_is_keyed_under_its_instance_by_its_label() -> Result<()> {
|
||
assert_eq!(pipeline_key(&PipelineDocument::Memory(memory_sample()))?, format!("{INSTANCE}:memory:2026-10-31t041200-t0-m0"));
|
||
Ok(())
|
||
}
|
||
|
||
/// Bounds: a run's campaigns and claims stop at their maximums.
|
||
#[test]
|
||
fn a_runs_lists_are_bounded() {
|
||
let mut run = run_sample();
|
||
run.campaigns = (0..9).map(|n| slug(&format!("c{n}"))).collect();
|
||
assert!(matches!(PipelineDocument::Run(run).validate(), Err(PipelineRefusal::FieldBound { limit: 8, .. })));
|
||
let mut run = run_sample();
|
||
run.claims = (0..65).map(|n| PipelineRef { kind: PipelineKind::FollowUp, id: id("follow_up", &format!("F{n}")) }).collect();
|
||
assert!(matches!(PipelineDocument::Run(run).validate(), Err(PipelineRefusal::FieldBound { limit: 64, .. })));
|
||
}
|
||
|
||
/// every-subject-resolvable (ruling key-bound): at the widest instance and
|
||
/// label a run's id fits a `Short`, and its resolution, and that
|
||
/// resolution's withdrawal, key within the 111-byte resolution local.
|
||
#[test]
|
||
fn the_widest_run_id_fits_a_short_and_resolves_two_deep() -> Result<()> {
|
||
let mut run = run_sample();
|
||
run.instance = slug(&"i".repeat(64));
|
||
run.label = l(&"a".repeat(64));
|
||
let run_id = run_key(run)?;
|
||
assert_eq!(run_id.len(), 64 + ":run:".len() + 64);
|
||
assert!(run_id.len() <= 200);
|
||
PipelineRef { kind: PipelineKind::Run, id: Short(run_id.clone()) }.validate_ref()?;
|
||
let one = resolved_at(PipelineKind::Run, &run_id, u32::MAX)?;
|
||
let two = resolved_at(PipelineKind::Resolution, &one, u32::MAX)?;
|
||
assert!(two.len() <= 200);
|
||
Ok(())
|
||
}
|
||
|
||
/// The live registrar plus the foreign stand-in, which only the tests
|
||
/// register.
|
||
fn schema_cache() -> Result<CultCache> {
|
||
let mut cache = CultCache::new();
|
||
register_pipeline_document_types(&mut cache)?;
|
||
cache.register_entry_type::<ForeignDocument>()?;
|
||
Ok(cache)
|
||
}
|
||
|
||
#[test]
|
||
fn every_pipeline_kind_round_trips_through_named_slot_zero() -> Result<()> {
|
||
let cache = schema_cache()?;
|
||
let samples = samples();
|
||
let kinds = samples.iter().map(|(document, _)| document.kind()).collect::<BTreeSet<_>>();
|
||
assert_eq!(kinds.len(), PipelineKind::ALL.len(), "every kind has a sample");
|
||
for (document, _) in samples {
|
||
document.validate()?;
|
||
let envelope = document.prepare(&cache)?;
|
||
assert_eq!(envelope.r#type, document.kind().type_id());
|
||
assert_eq!(envelope.payload[0], 0x91, "{:?} payload is a one-element array", document.kind());
|
||
assert!(
|
||
matches!(envelope.payload[1], 0x80..=0x8f | 0xde | 0xdf),
|
||
"{:?} slot 0 is a named map",
|
||
document.kind()
|
||
);
|
||
assert_eq!(PipelineDocument::decode(&envelope)?, document);
|
||
Ok::<_, anyhow::Error>(())?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Soul F2: a decode is type-matched in both directions. The round-trip
|
||
/// test above pins the positive match; this pins the refusal, so an
|
||
/// envelope belonging to another kind can never be decoded as whichever
|
||
/// pipeline kind happens to parse its payload. The commit receipt is the
|
||
/// sharp case: a mind's store may legitimately hold one, and it is still
|
||
/// not a document; `ForeignDocument` stands in for it here.
|
||
#[test]
|
||
fn decode_refuses_an_envelope_of_a_foreign_type() -> Result<()> {
|
||
let cache = schema_cache()?;
|
||
let foreign = <ForeignDocument as DatabaseEntry>::TYPE;
|
||
assert!(!foreign.starts_with("epiphany.pipeline."), "{foreign} is a foreign type id");
|
||
let mut envelope = campaign_sample().prepare(&cache)?;
|
||
envelope.r#type = foreign.into();
|
||
assert_eq!(
|
||
PipelineDocument::decode(&envelope),
|
||
Err(PipelineRefusal::ForeignStore { r#type: foreign.into() })
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn bounds_refuse_in_utf8_bytes() {
|
||
let PipelineDocument::Campaign(mut campaign) = campaign_sample() else { unreachable!() };
|
||
campaign.title = Title("é".repeat(100));
|
||
assert_eq!(PipelineDocument::Campaign(campaign.clone()).validate(), Ok(()));
|
||
campaign.title = Title(format!("{}a", "é".repeat(100)));
|
||
assert_eq!(
|
||
PipelineDocument::Campaign(campaign.clone()).validate(),
|
||
Err(PipelineRefusal::FieldBound { field: "campaign.title".into(), limit: 200, actual: 201 })
|
||
);
|
||
campaign.title = t("ok");
|
||
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()); 9];
|
||
assert_eq!(
|
||
PipelineDocument::Campaign(campaign).validate(),
|
||
Err(PipelineRefusal::FieldBound { field: "campaign.repos".into(), limit: 8, actual: 9 })
|
||
);
|
||
}
|
||
|
||
/// RS-L closing fix 4: `campaign.repos` refuses two entries naming one
|
||
/// repository by identity, not only by raw spelling, so `GameCult/Epiphany`
|
||
/// and `gamecult/epiphany` in one list refuse exactly as the same spelling
|
||
/// twice would. Two genuinely distinct repos still validate.
|
||
#[test]
|
||
fn campaign_repos_refuse_duplicates_by_identity() {
|
||
let PipelineDocument::Campaign(mut campaign) = campaign_sample() else { unreachable!() };
|
||
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("gamecult/epiphany".into())];
|
||
assert_eq!(
|
||
PipelineDocument::Campaign(campaign.clone()).validate(),
|
||
Err(PipelineRefusal::DuplicateRepo {
|
||
field: "campaign.repos".into(),
|
||
repo: "gamecult/epiphany".into(),
|
||
})
|
||
);
|
||
|
||
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()); 2];
|
||
assert_eq!(
|
||
PipelineDocument::Campaign(campaign.clone()).validate(),
|
||
Err(PipelineRefusal::DuplicateRepo {
|
||
field: "campaign.repos".into(),
|
||
repo: "GameCult/Epiphany".into(),
|
||
})
|
||
);
|
||
|
||
campaign.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Aetheria".into())];
|
||
assert_eq!(PipelineDocument::Campaign(campaign).validate(), Ok(()));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_fields_must_be_org_slash_repo() {
|
||
let mut report = report_sample();
|
||
report.repo = OrgRepo("Epiphany".into());
|
||
assert_eq!(
|
||
PipelineDocument::CutReport(report).validate(),
|
||
Err(PipelineRefusal::InvalidFormat { field: "cut_report.repo".into(), value: "Epiphany".into() })
|
||
);
|
||
let PipelineDocument::CutSpec(mut spec) = samples().remove(4).0 else { unreachable!() };
|
||
spec.repo = OrgRepo("GameCult/Epiphany/extra".into());
|
||
assert!(matches!(
|
||
PipelineDocument::CutSpec(spec).validate(),
|
||
Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "cut_spec.repo"
|
||
));
|
||
let PipelineDocument::FollowUp(mut follow_up) = samples().remove(8).0 else { unreachable!() };
|
||
follow_up.repo = OrgRepo("/Epiphany".into());
|
||
assert!(matches!(
|
||
PipelineDocument::FollowUp(follow_up).validate(),
|
||
Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "follow_up.repo"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn keys_are_derived_and_mismatch_refuses() -> Result<()> {
|
||
let cache = schema_cache()?;
|
||
for (document, key) in samples() {
|
||
assert_eq!(pipeline_key(&document), Ok(key.clone()));
|
||
let mut envelope = document.prepare(&cache)?;
|
||
envelope.key = format!("{key}-forged");
|
||
assert_eq!(
|
||
validate_pipeline_write_envelope(&envelope),
|
||
Err(PipelineRefusal::InvalidIdentity { kind: document.kind(), key: envelope.key, expected: key })
|
||
);
|
||
}
|
||
let mut resolution = resolution_sample();
|
||
let answered = pipeline_key(&PipelineDocument::Resolution(resolution.clone()));
|
||
resolution.outcome = ResolutionOutcome::Withdrawn { reason: "moot".into() };
|
||
assert_eq!(
|
||
pipeline_key(&PipelineDocument::Resolution(resolution)),
|
||
answered,
|
||
"a subject has one resolution key whatever the outcome"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// F2, and Soul's second pass: a composed local segments one way only. The
|
||
/// rule -- no local part carries the separator -- is pinned on the composer
|
||
/// in `no_local_part_carries_the_separator`; the pairs below are the ones
|
||
/// Soul measured through the documents, and each is a pair only because the
|
||
/// rule holds.
|
||
#[test]
|
||
fn composed_keys_cannot_collide() {
|
||
let key = |document: PipelineDocument| pipeline_key(&document);
|
||
|
||
// Soul's hand-off pair. A dotted slug is ordinary on the receiver side
|
||
// and a dotted repo name (the part after the mandatory `/`, GitHub's
|
||
// grammar owns no dot on the owner side) is ordinary on the repo side,
|
||
// so both are escaped: left unescaped these two both key to
|
||
// `yggdrasil:hand_off:thought-cage.GameCult.Epiphany_-thing.2026-09-15`.
|
||
let handed = |to: &str, repo: &str| {
|
||
let mut hand_off = hand_off_sample();
|
||
hand_off.to_instance = slug(to);
|
||
hand_off.repo = OrgRepo(repo.into());
|
||
key(PipelineDocument::HandOff(hand_off))
|
||
};
|
||
let dotted_receiver = handed("thought-cage.GameCult", "Epiphany/thing");
|
||
let dotted_repo = handed("thought-cage", "GameCult/Epiphany.thing");
|
||
assert_eq!(
|
||
dotted_receiver,
|
||
Ok(format!("{INSTANCE}:hand_off:thought-cage_dGameCult.epiphany_-thing.2026-09-15"))
|
||
);
|
||
assert_eq!(
|
||
dotted_repo,
|
||
Ok(format!("{INSTANCE}:hand_off:thought-cage.gamecult_-epiphany_dthing.2026-09-15"))
|
||
);
|
||
assert_ne!(dotted_receiver, dotted_repo, "a dotted repo and a dotted receiver cannot claim one key");
|
||
|
||
let mut wide_label = finding_sample();
|
||
wide_label.label = l("F1.G");
|
||
assert!(
|
||
matches!(key(PipelineDocument::Finding(wide_label)), Err(PipelineRefusal::InvalidFormat { .. })),
|
||
"a finding label carrying a dot is refused, not silently composed"
|
||
);
|
||
let mut deep_verdict = finding_sample();
|
||
deep_verdict.verdict = id("verdict", "cut-3a.s1.F1");
|
||
deep_verdict.label = l("G");
|
||
assert!(
|
||
matches!(key(PipelineDocument::Finding(deep_verdict)), Err(PipelineRefusal::InvalidFormat { .. })),
|
||
"a verdict local that is not cut-<label>.s<N> is refused"
|
||
);
|
||
let mut plain = finding_sample();
|
||
plain.verdict = id("verdict", "cut-3a.s1");
|
||
plain.label = l("F1");
|
||
assert_eq!(key(PipelineDocument::Finding(plain)), Ok(format!("{CAMPAIGN}:finding:cut-3a.s1.F1")));
|
||
|
||
let mut deep_spec = report_sample();
|
||
deep_spec.cut_spec = id("cut_spec", "cut-3a.h1.r1");
|
||
deep_spec.attempt = 2;
|
||
assert!(
|
||
matches!(key(PipelineDocument::CutReport(deep_spec)), Err(PipelineRefusal::InvalidFormat { .. })),
|
||
"a cut label carrying a dot is refused, so cut-3a.h1.h2 has one source"
|
||
);
|
||
let mut attempt_two = report_sample();
|
||
attempt_two.attempt = 2;
|
||
assert_eq!(key(PipelineDocument::CutReport(attempt_two)), Ok(format!("{CAMPAIGN}:cut_report:cut-3a.h2")));
|
||
}
|
||
|
||
/// F3: every parent id is parsed strictly. These are the bad parents Soul
|
||
/// found accepted, plus the wrong-kind and marker rules that mutations S1
|
||
/// and S3 remove.
|
||
#[test]
|
||
fn parent_ids_are_parsed_strictly() {
|
||
let refused = |parent: &str| {
|
||
let mut report = report_sample();
|
||
report.cut_spec = Short(parent.into());
|
||
let key = pipeline_key(&PipelineDocument::CutReport(report));
|
||
assert!(
|
||
matches!(&key, Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "cut_report.cut_spec"),
|
||
"parent {parent:?} must be refused, got {key:?}"
|
||
);
|
||
};
|
||
refused(&format!("{CAMPAIGN}:cut_spec:cut-3a.r1:junk"));
|
||
refused(&format!("{CAMPAIGN}:cut_spec:cut-3a.rX"));
|
||
refused(&format!("{CAMPAIGN}:cut_spec:cut-..r1"));
|
||
refused(&format!("{CAMPAIGN}:cut_spec:cut-.r1"));
|
||
refused(&format!("{CAMPAIGN}:cut_spec:cut-3a.r1 "));
|
||
refused(&format!("{CAMPAIGN}:cut_spec:cut-3a.r"));
|
||
refused(&format!("{CAMPAIGN}:cut_report:cut-3a.h1"));
|
||
// Right kind and a well-formed number, but the marker belongs to
|
||
// another kind: the marker rule is pinned on its own.
|
||
refused(&format!("{CAMPAIGN}:cut_spec:cut-3a.h1"));
|
||
refused(&format!("{CAMPAIGN}:cut_spec:cut-3a.s1"));
|
||
// A well-formed number with no marker at all. Only the marker rule
|
||
// refuses this one: the digits rule is satisfied either way.
|
||
refused(&format!("{CAMPAIGN}:cut_spec:cut-3a.1"));
|
||
refused(&format!("{CAMPAIGN}:CUT_SPEC:cut-3a.r1"));
|
||
refused("EUREKA-STATE:cut_spec:cut-3a.r1");
|
||
refused(&format!("{CAMPAIGN}:cut_spec:cut-3a.r1:"));
|
||
refused(&format!("{CAMPAIGN}::cut-3a.r1"));
|
||
refused("cut-3a.r1");
|
||
let mut other_campaign = report_sample();
|
||
other_campaign.campaign = slug("other-campaign");
|
||
assert!(
|
||
matches!(
|
||
pipeline_key(&PipelineDocument::CutReport(other_campaign)),
|
||
Err(PipelineRefusal::InvalidFormat { .. })
|
||
),
|
||
"a parent in another campaign is refused"
|
||
);
|
||
let mut verdict = verdict_sample();
|
||
verdict.cut_report = id("cut_report", "cut-3a.r1");
|
||
assert!(
|
||
matches!(pipeline_key(&PipelineDocument::Verdict(verdict)), Err(PipelineRefusal::InvalidFormat { .. })),
|
||
"a verdict's parent must carry the report marker, not the spec marker"
|
||
);
|
||
}
|
||
|
||
/// F4: a resolution's subject id is a full id of the declared kind.
|
||
#[test]
|
||
fn resolution_subject_is_a_full_id_of_its_kind() {
|
||
let refused = |kind: PipelineKind, subject: &str| {
|
||
let mut resolution = resolution_sample();
|
||
resolution.subject = PipelineRef { kind, id: Short(subject.into()) };
|
||
let document = PipelineDocument::Resolution(resolution);
|
||
assert!(
|
||
matches!(document.validate(), Err(PipelineRefusal::InvalidFormat { .. })),
|
||
"subject {subject:?} must fail validation"
|
||
);
|
||
assert!(
|
||
matches!(pipeline_key(&document), Err(PipelineRefusal::InvalidFormat { .. })),
|
||
"subject {subject:?} must not compose a key"
|
||
);
|
||
};
|
||
refused(PipelineKind::Question, "");
|
||
refused(PipelineKind::Question, "not an id: spaces and : colons");
|
||
refused(PipelineKind::Question, "eureka-state:question:11");
|
||
refused(PipelineKind::Question, &format!("{CAMPAIGN}:ruling:R8"));
|
||
refused(PipelineKind::Ruling, &format!("{CAMPAIGN}:question:Q1"));
|
||
refused(PipelineKind::Question, &format!("{CAMPAIGN}:question:.."));
|
||
|
||
let mut valid = resolution_sample();
|
||
valid.subject = PipelineRef { kind: PipelineKind::Ruling, id: id("ruling", "R8") };
|
||
assert_eq!(
|
||
pipeline_key(&PipelineDocument::Resolution(valid)),
|
||
Ok(format!("{CAMPAIGN}:resolution:ruling.R8.n1"))
|
||
);
|
||
}
|
||
|
||
/// Soul G2: a resolution is named by the key it has, and a `PipelineRef`
|
||
/// of kind `Resolution` accepts exactly that id. `PipelineRef` takes any
|
||
/// kind, so this is reachable; a follow-up sourced from a resolution is the
|
||
/// live path. The grammar admits `<campaign>:resolution:R8` too: it is
|
||
/// well-formed, and no resolution derives it, but whether a document with an
|
||
/// id exists is admission's rule, not the grammar's, so the reader does not
|
||
/// refuse it and this test does not ask it to.
|
||
#[test]
|
||
fn a_resolution_is_named_by_the_key_it_has() {
|
||
let sourced = |id: &str| {
|
||
let PipelineDocument::FollowUp(mut follow_up) = samples().remove(8).0 else { unreachable!() };
|
||
follow_up.source = PipelineRef { kind: PipelineKind::Resolution, id: Short(id.into()) };
|
||
PipelineDocument::FollowUp(follow_up).validate()
|
||
};
|
||
let key = pipeline_key(&PipelineDocument::Resolution(resolution_sample())).expect("the sample keys");
|
||
assert_eq!(key, format!("{CAMPAIGN}:resolution:question.Q1.n1"));
|
||
assert_eq!(sourced(&key), Ok(()), "a resolution is named by the key it has");
|
||
assert_eq!(sourced(&format!("{CAMPAIGN}:resolution:R8")), Ok(()), "well-formed grammar is not the reader's to refuse");
|
||
|
||
for refused in [
|
||
// The prefix shape the writer used to emit. No resolution carries it.
|
||
format!("resolution:{CAMPAIGN}:question:Q1"),
|
||
format!("{CAMPAIGN}:resolution:{CAMPAIGN}:question:Q1"),
|
||
// No local, a subject local with an empty part, trailing junk, and a
|
||
// kind segment that is not `resolution`.
|
||
format!("{CAMPAIGN}:resolution:"),
|
||
format!("{CAMPAIGN}:resolution:question..Q1"),
|
||
format!("{CAMPAIGN}:resolution:question.Q1:junk"),
|
||
format!("{CAMPAIGN}:question:Q1"),
|
||
] {
|
||
assert!(
|
||
matches!(sourced(&refused), Err(PipelineRefusal::InvalidFormat { .. })),
|
||
"{refused:?} is not an id any resolution has"
|
||
);
|
||
}
|
||
|
||
// A root subject reads back too: a resolution of a campaign carries the
|
||
// campaign's kind and its `self` local.
|
||
let mut of_campaign = resolution_sample();
|
||
of_campaign.subject = PipelineRef { kind: PipelineKind::Campaign, id: Short(format!("{CAMPAIGN}:campaign:self")) };
|
||
let root_key = pipeline_key(&PipelineDocument::Resolution(of_campaign)).expect("a root subject keys");
|
||
assert_eq!(root_key, format!("{CAMPAIGN}:resolution:campaign.self.n1"));
|
||
assert_eq!(sourced(&root_key), Ok(()));
|
||
}
|
||
|
||
/// The three kinds a mind is keyed by. The samples above already round-trip
|
||
/// every kind; this pins the shapes D2 gives these three specifically: an
|
||
/// instance is a root keyed by its own slug, and the other two hang off an
|
||
/// instance rather than a campaign, so neither borrows the campaign tail.
|
||
#[test]
|
||
fn instance_stewardship_and_hand_off_round_trip() -> Result<()> {
|
||
let cache = schema_cache()?;
|
||
let instance = PipelineDocument::Instance(instance_sample());
|
||
assert_eq!(pipeline_key(&instance), Ok(format!("{INSTANCE}:instance:self")));
|
||
for document in [
|
||
instance,
|
||
PipelineDocument::Stewardship(stewardship_sample()),
|
||
PipelineDocument::HandOff(hand_off_sample()),
|
||
] {
|
||
document.validate()?;
|
||
let envelope = document.prepare(&cache)?;
|
||
assert_eq!(PipelineDocument::decode(&envelope)?, document);
|
||
let key = pipeline_key(&document)?;
|
||
assert!(
|
||
!key.starts_with(&format!("{CAMPAIGN}:")),
|
||
"{:?} is keyed by its instance, not by a campaign: {key}",
|
||
document.kind()
|
||
);
|
||
validate_pipeline_write_envelope(&envelope)?;
|
||
}
|
||
// A bounded list is still bounded: `documents` carries a maximum, as
|
||
// every `Vec` field must.
|
||
let mut wide = hand_off_sample();
|
||
wide.documents = vec![s("x"); 257];
|
||
assert_eq!(
|
||
PipelineDocument::HandOff(wide).validate(),
|
||
Err(PipelineRefusal::FieldBound { field: "hand_off.documents".into(), limit: 256, actual: 257 })
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Soul F3: a key and an id are the same string, so every kind's key reads
|
||
/// back as an id of that kind. The kinds a mind is keyed by were added to
|
||
/// the key writer and left out of the id reader, and nothing noticed,
|
||
/// because the key tests assert strings and never read one back: an
|
||
/// instance keys to its slug and then fails to parse as an instance id, so
|
||
/// no `PipelineRef` and no resolution could ever name one. Every kind now
|
||
/// reads back with no kind excused, a resolution included, and a root kind
|
||
/// read as the other root kind is refused: the reader checks the kind
|
||
/// segment for the roots exactly as for every other kind.
|
||
#[test]
|
||
fn keys_read_back_as_ids_of_their_kind() -> Result<()> {
|
||
for (document, expected) in samples() {
|
||
let kind = document.kind();
|
||
let key = pipeline_key(&document)?;
|
||
assert_eq!(key, expected, "{kind:?} keys to its sample's key");
|
||
assert_eq!(
|
||
pipeline_id("read_back", &key, kind).map(|_| ()),
|
||
Ok(()),
|
||
"{kind:?} key {key} does not read back as an id of its kind"
|
||
);
|
||
}
|
||
|
||
let campaign_key = pipeline_key(&campaign_sample())?;
|
||
let instance_key = pipeline_key(&PipelineDocument::Instance(instance_sample()))?;
|
||
assert_eq!(
|
||
pipeline_id("read_back", &instance_key, PipelineKind::Instance),
|
||
Ok((INSTANCE, ROOT_LOCAL)),
|
||
"an instance key reads back as an instance"
|
||
);
|
||
assert_eq!(
|
||
pipeline_id("read_back", &instance_key, PipelineKind::Campaign),
|
||
Err(format_error("read_back", &instance_key)),
|
||
"an instance key is not a campaign id"
|
||
);
|
||
assert_eq!(
|
||
pipeline_id("read_back", &campaign_key, PipelineKind::Instance),
|
||
Err(format_error("read_back", &campaign_key)),
|
||
"a campaign key is not an instance id"
|
||
);
|
||
|
||
// Soul X1 and X11: the reader's root and local are each `dotted_text`
|
||
// whole, so a trailing dot on either is refused by the reader itself,
|
||
// not only by the writer that never composes one. A leading dot and
|
||
// an empty part are the same rule on the local.
|
||
for malformed in ["c.:target:x", "c:target:x.", "c:target:.x", "c:target:a..b"] {
|
||
let read = pipeline_id("read_back", malformed, PipelineKind::Target);
|
||
assert!(
|
||
matches!(&read, Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "read_back"),
|
||
"{malformed:?} is not an id of any kind, got {read:?}"
|
||
);
|
||
}
|
||
|
||
// The instance root's own key segment is validated, not merely bounded.
|
||
// Nothing else stands between a `Slug` and a key: a space would compose
|
||
// a store key carrying a byte no `Label` may hold.
|
||
let mut spaced = instance_sample();
|
||
spaced.instance = Slug("thought cage".into());
|
||
assert_eq!(
|
||
pipeline_key(&PipelineDocument::Instance(spaced)),
|
||
Err(PipelineRefusal::InvalidFormat {
|
||
field: "instance.instance".into(),
|
||
value: "thought cage".into(),
|
||
}),
|
||
"an instance slug is validated where the key is composed"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// The repo is one key segment, so its slash is escaped. Left unescaped it
|
||
/// would both add a segment the reader cannot tell from a real one and put a
|
||
/// byte in the key that no `Label` may carry; escaped to a bare `_` it would
|
||
/// let two repos claim one key, which the colliding pair below pins.
|
||
#[test]
|
||
fn stewardship_key_escapes_the_repo_slash() {
|
||
let stewardship = stewardship_sample();
|
||
assert_eq!(stewardship.repo, OrgRepo("GameCult/Epiphany".into()));
|
||
let key = pipeline_key(&PipelineDocument::Stewardship(stewardship.clone()));
|
||
assert_eq!(key, Ok(format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1")));
|
||
assert!(!key.unwrap().contains('/'), "no key segment carries a slash");
|
||
|
||
// The escape is not a cosmetic substitution, and the pair that proves
|
||
// it is a pair: GitHub's grammar keeps `.` and `_` out of the owner,
|
||
// so both live in the repo half here, and under a naive scheme that
|
||
// collapsed `/`, `.` and `_` all to a bare `_` both of these key to
|
||
// `GameCult_Epiphany_thing`, and one of the two documents is lost.
|
||
let keyed = |repo: &str| {
|
||
let mut value = stewardship.clone();
|
||
value.repo = OrgRepo(repo.into());
|
||
pipeline_key(&PipelineDocument::Stewardship(value))
|
||
};
|
||
let dotted_repo = keyed("GameCult/Epiphany.thing");
|
||
let underscored_repo = keyed("GameCult/Epiphany_thing");
|
||
assert_eq!(
|
||
dotted_repo,
|
||
Ok(format!("{INSTANCE}:stewardship:gamecult_-epiphany_dthing.n1"))
|
||
);
|
||
assert_eq!(
|
||
underscored_repo,
|
||
Ok(format!("{INSTANCE}:stewardship:gamecult_-epiphany__thing.n1"))
|
||
);
|
||
assert_ne!(dotted_repo, underscored_repo, "two repos cannot claim one key");
|
||
|
||
let mut no_org = stewardship.clone();
|
||
no_org.repo = OrgRepo("Epiphany".into());
|
||
assert_eq!(
|
||
pipeline_key(&PipelineDocument::Stewardship(no_org)),
|
||
Err(PipelineRefusal::InvalidFormat { field: "stewardship.repo".into(), value: "Epiphany".into() })
|
||
);
|
||
|
||
// S-1: key derivation goes through `identity()`, so a case variant of
|
||
// the same repository is one store key, not two stewardships of what
|
||
// is really one repo.
|
||
let mut upper = stewardship.clone();
|
||
upper.repo = OrgRepo("gamecult/epiphany".into());
|
||
assert_eq!(
|
||
pipeline_key(&PipelineDocument::Stewardship(upper)),
|
||
pipeline_key(&PipelineDocument::Stewardship(stewardship.clone())),
|
||
"GameCult/Epiphany and gamecult/epiphany must derive one store key"
|
||
);
|
||
|
||
// An escaped repo is still bound by the key's segment rules.
|
||
let mut long = stewardship;
|
||
long.repo = OrgRepo(format!("GameCult/{}", "a".repeat(60)));
|
||
assert!(
|
||
matches!(
|
||
pipeline_key(&PipelineDocument::Stewardship(long)),
|
||
Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "stewardship.key"
|
||
),
|
||
"a repo segment longer than a key segment is refused"
|
||
);
|
||
}
|
||
|
||
/// A hand-off is recorded in both minds, so both instances are in the key:
|
||
/// the sender owns the first segment and the receiver the local. Changing
|
||
/// either one moves the document.
|
||
#[test]
|
||
fn hand_off_names_both_instances() {
|
||
let key = |hand_off: PipelineHandOff| pipeline_key(&PipelineDocument::HandOff(hand_off));
|
||
let base = key(hand_off_sample()).expect("the sample keys");
|
||
assert_eq!(base, format!("{INSTANCE}:hand_off:thought-cage.gamecult_-epiphany.2026-09-15"));
|
||
|
||
let mut other_sender = hand_off_sample();
|
||
other_sender.from_instance = slug("thought-cage");
|
||
assert_ne!(key(other_sender), Ok(base.clone()), "the sender is in the key");
|
||
|
||
let mut other_receiver = hand_off_sample();
|
||
other_receiver.to_instance = slug("mimir");
|
||
assert_ne!(key(other_receiver), Ok(base.clone()), "the receiver is in the key");
|
||
|
||
// Two hand-offs of the same repo between the same pair on different
|
||
// days are different documents.
|
||
let mut later = hand_off_sample();
|
||
later.handed_on = Date("2026-09-16".into());
|
||
assert_ne!(key(later), Ok(base), "the date is in the key");
|
||
|
||
// A dotted receiver is a `Slug` entering a local, so it is escaped to
|
||
// one label rather than widening the local by a part.
|
||
let mut dotted_receiver = hand_off_sample();
|
||
dotted_receiver.to_instance = slug("thought-cage.GameCult");
|
||
assert_eq!(
|
||
key(dotted_receiver),
|
||
Ok(format!("{INSTANCE}:hand_off:thought-cage_dGameCult.gamecult_-epiphany.2026-09-15"))
|
||
);
|
||
|
||
// The escape's `_` half is not only for repos: a receiver `a_db` and a
|
||
// receiver `a.b` would both key to `a_db` if the slug's own `_` passed
|
||
// through raw, so the slug is escaped as `a__db` and the two differ.
|
||
let mut underscored_receiver = hand_off_sample();
|
||
underscored_receiver.to_instance = slug("a_db");
|
||
let mut dotted_twin = hand_off_sample();
|
||
dotted_twin.to_instance = slug("a.b");
|
||
let underscored_receiver = key(underscored_receiver).expect("an underscored receiver keys");
|
||
assert!(underscored_receiver.contains("a__db"), "{underscored_receiver}: the slug's underscore is escaped");
|
||
assert_ne!(key(dotted_twin), Ok(underscored_receiver), "`a_db` and `a.b` are two receivers");
|
||
|
||
// Both instance slugs are validated as key segments, not merely bounded.
|
||
let mut bad_receiver = hand_off_sample();
|
||
bad_receiver.to_instance = Slug("thought cage".into());
|
||
assert_eq!(
|
||
key(bad_receiver),
|
||
Err(PipelineRefusal::InvalidFormat {
|
||
field: "hand_off.to_instance".into(),
|
||
value: "thought cage".into(),
|
||
})
|
||
);
|
||
let mut bad_date = hand_off_sample();
|
||
bad_date.handed_on = Date("2026-9-15".into());
|
||
assert!(
|
||
matches!(key(bad_date), Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "hand_off.handed_on"),
|
||
"a malformed date does not compose a key"
|
||
);
|
||
}
|
||
|
||
/// A resolution whose subject is the given document, keyed.
|
||
fn resolution_of(kind: PipelineKind, id: &str) -> Result<String, PipelineRefusal> {
|
||
let mut resolution = resolution_sample();
|
||
resolution.subject = PipelineRef { kind, id: Short(id.into()) };
|
||
pipeline_key(&PipelineDocument::Resolution(resolution))
|
||
}
|
||
|
||
/// R1, the grammar's own test: every key is `<root>:<kind>:<local>`, three
|
||
/// segments with no kind excused, the root a `Slug`, the kind the literal
|
||
/// name, and every local part a `Label`. A root that would add a segment is
|
||
/// refused where the key is composed: `pipeline_key` is `pub` and does not
|
||
/// validate the document first, so this is reachable without one.
|
||
#[test]
|
||
fn every_key_has_exactly_three_segments() {
|
||
let nested = resolution_of(PipelineKind::Resolution, &format!("{CAMPAIGN}:resolution:question.Q1.n1"))
|
||
.expect("a resolution of a resolution keys");
|
||
let keys = samples()
|
||
.into_iter()
|
||
.map(|(document, _)| (document.kind(), pipeline_key(&document).expect("every sample keys")))
|
||
.chain([(PipelineKind::Resolution, nested)]);
|
||
for (kind, key) in keys {
|
||
let segments = key.split(':').collect::<Vec<_>>();
|
||
assert_eq!(segments.len(), 3, "{kind:?} key {key} has three segments");
|
||
assert_eq!(dotted_text("root", segments[0]), Ok(()), "{key}: the root is a slug");
|
||
assert_eq!(segments[1], kind.name(), "{key}: the kind segment is the literal kind");
|
||
assert!(PipelineKind::ALL.iter().any(|known| known.name() == segments[1]));
|
||
for part in segments[2].split('.') {
|
||
assert_eq!(label_text("local", part), Ok(()), "{key}: local part {part:?} is a label");
|
||
}
|
||
}
|
||
|
||
// A root that would add a segment, one that would empty a part, and one
|
||
// wider than a segment are all refused before a key is formatted. The
|
||
// refusal names the part that failed, so an empty part reports `""`.
|
||
let trailing_dot = format!("{CAMPAIGN}.");
|
||
let leading_dot = format!(".{CAMPAIGN}");
|
||
let over = "a".repeat(65);
|
||
for root in ["a:b", trailing_dot.as_str(), leading_dot.as_str(), "a..b", over.as_str()] {
|
||
let PipelineDocument::Target(mut target) = samples().remove(1).0 else { unreachable!() };
|
||
target.campaign = Slug(root.into());
|
||
let key = pipeline_key(&PipelineDocument::Target(target));
|
||
assert!(
|
||
matches!(&key, Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "target.campaign"),
|
||
"root {root:?} does not compose a key, got {key:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Defect 1: the two roots are in distinct namespaces because the kind
|
||
/// segment distinguishes them, the same mechanism that separates every
|
||
/// other pair of kinds. Both key; only the pair sees a shared namespace.
|
||
#[test]
|
||
fn roots_of_different_kinds_do_not_share_a_key() {
|
||
let PipelineDocument::Campaign(mut campaign) = campaign_sample() else { unreachable!() };
|
||
campaign.slug = slug(INSTANCE);
|
||
let campaign = pipeline_key(&PipelineDocument::Campaign(campaign));
|
||
let instance = pipeline_key(&PipelineDocument::Instance(instance_sample()));
|
||
assert_eq!(campaign, Ok(format!("{INSTANCE}:campaign:self")));
|
||
assert_eq!(instance, Ok(format!("{INSTANCE}:instance:self")));
|
||
assert_ne!(campaign, instance, "a campaign and an instance of one slug are two documents");
|
||
}
|
||
|
||
/// The root is a `Slug`, so a dotted root keys and reads back whole on
|
||
/// both sides: the writer's root check and the reader's root check are
|
||
/// each `dotted_text`, not `label_text`. A campaign `game.cult` and an
|
||
/// instance `ygg.drasil` key, a document under the dotted campaign keys,
|
||
/// and each reads back through `pipeline_id` recovering the root exactly.
|
||
#[test]
|
||
fn dotted_roots_key_and_read_back() {
|
||
let PipelineDocument::Campaign(mut campaign) = campaign_sample() else { unreachable!() };
|
||
campaign.slug = slug("game.cult");
|
||
let campaign_key = pipeline_key(&PipelineDocument::Campaign(campaign)).expect("a dotted campaign keys");
|
||
assert_eq!(campaign_key, "game.cult:campaign:self");
|
||
assert_eq!(
|
||
pipeline_id("read_back", &campaign_key, PipelineKind::Campaign),
|
||
Ok(("game.cult", ROOT_LOCAL)),
|
||
"the dotted campaign root reads back whole"
|
||
);
|
||
|
||
let mut instance = instance_sample();
|
||
instance.instance = slug("ygg.drasil");
|
||
let instance_key = pipeline_key(&PipelineDocument::Instance(instance)).expect("a dotted instance keys");
|
||
assert_eq!(instance_key, "ygg.drasil:instance:self");
|
||
assert_eq!(
|
||
pipeline_id("read_back", &instance_key, PipelineKind::Instance),
|
||
Ok(("ygg.drasil", ROOT_LOCAL)),
|
||
"the dotted instance root reads back whole"
|
||
);
|
||
|
||
let PipelineDocument::Target(mut target) = samples().remove(1).0 else { unreachable!() };
|
||
target.campaign = slug("game.cult");
|
||
let target_key = pipeline_key(&PipelineDocument::Target(target)).expect("a target under a dotted campaign keys");
|
||
assert_eq!(target_key, "game.cult:target:r2");
|
||
assert_eq!(
|
||
pipeline_id("read_back", &target_key, PipelineKind::Target),
|
||
Ok(("game.cult", "r2")),
|
||
"the dotted root and the local both read back"
|
||
);
|
||
|
||
let resolution = resolution_of(PipelineKind::Campaign, &campaign_key).expect("a resolution of a dotted campaign keys");
|
||
assert_eq!(resolution, "game.cult:resolution:campaign.self.n1");
|
||
assert_eq!(
|
||
pipeline_id("read_back", &resolution, PipelineKind::Resolution),
|
||
Ok(("game.cult", "campaign.self.n1")),
|
||
"a resolution under a dotted root reads back"
|
||
);
|
||
}
|
||
|
||
/// Cut 6c: a resolution's referent is a full id of the kind it declares,
|
||
/// validated through `PipelineRef` like every other referent, so a
|
||
/// well-formed id of the wrong kind is refused and the refusal names the
|
||
/// entry. The positive case is Ghostlight's own: a ruling partly
|
||
/// overturned by two later records, one of them a resolution named by the
|
||
/// key it has, which only the 6b grammar made nameable. An id no
|
||
/// resolution derives, `eureka-state:resolution:R8`, still validates:
|
||
/// whether a document with an id exists is admission's rule.
|
||
#[test]
|
||
fn resolution_outcome_referents_are_parsed_ids_of_their_kind() {
|
||
let resolved = |subject: PipelineRef, outcome: ResolutionOutcome| {
|
||
let mut resolution = resolution_sample();
|
||
resolution.subject = subject;
|
||
resolution.outcome = outcome;
|
||
PipelineDocument::Resolution(resolution).validate()
|
||
};
|
||
let refused = |field: &str, result: Result<(), PipelineRefusal>| {
|
||
assert!(
|
||
matches!(&result, Err(PipelineRefusal::InvalidFormat { field: at, .. }) if at == field),
|
||
"expected InvalidFormat at {field}, got {result:?}"
|
||
);
|
||
};
|
||
let ruling = |label: &str| PipelineRef { kind: PipelineKind::Ruling, id: id("ruling", label) };
|
||
let question = |label: &str| PipelineRef { kind: PipelineKind::Question, id: id("question", label) };
|
||
let inner = pipeline_key(&PipelineDocument::Resolution(resolution_sample())).expect("the sample keys");
|
||
let resolution = PipelineRef { kind: PipelineKind::Resolution, id: Short(inner.clone()) };
|
||
|
||
let superseded = |second: PipelineRef| ResolutionOutcome::Superseded { by: vec![ruling("R9"), second] };
|
||
assert_eq!(resolved(ruling("R8"), superseded(resolution)), Ok(()));
|
||
let forged = PipelineRef { kind: PipelineKind::Ruling, id: Short(inner) };
|
||
refused("resolution.outcome.by[1].id", resolved(ruling("R8"), superseded(forged)));
|
||
let underived = PipelineRef { kind: PipelineKind::Resolution, id: Short(format!("{CAMPAIGN}:resolution:R8")) };
|
||
assert_eq!(resolved(ruling("R8"), superseded(underived)), Ok(()), "well-formed grammar is not the shape's to refuse");
|
||
|
||
assert_eq!(resolved(question("Q1"), ResolutionOutcome::Answered { by: ruling("R8") }), Ok(()));
|
||
let forged = PipelineRef { kind: PipelineKind::Question, id: id("ruling", "R8") };
|
||
refused("resolution.outcome.by.id", resolved(question("Q1"), ResolutionOutcome::Answered { by: forged }));
|
||
|
||
assert_eq!(resolved(question("Q1"), ResolutionOutcome::Deferred { to: question("Q2") }), Ok(()));
|
||
let forged = PipelineRef { kind: PipelineKind::Ruling, id: id("question", "Q2") };
|
||
refused("resolution.outcome.to.id", resolved(question("Q1"), ResolutionOutcome::Deferred { to: forged }));
|
||
|
||
let finding = PipelineRef { kind: PipelineKind::Finding, id: id("finding", "cut-3a.s2.F4") };
|
||
let fixed = |by: Option<PipelineRef>| ResolutionOutcome::Fixed { commit: sha(), by };
|
||
assert_eq!(resolved(finding.clone(), fixed(None)), Ok(()));
|
||
assert_eq!(resolved(finding.clone(), fixed(Some(ruling("R8")))), Ok(()));
|
||
let forged = PipelineRef { kind: PipelineKind::Finding, id: id("ruling", "R8") };
|
||
refused("resolution.outcome.by.id", resolved(finding, fixed(Some(forged))));
|
||
|
||
// Soul F2: the validator's bound on a supersession is its own, not
|
||
// only the schema's. Eight referents are the most a subject names;
|
||
// nine are refused as a count before any item is read.
|
||
let rulings = |count: usize| (1..=count).map(|n| ruling(&format!("R{n}"))).collect::<Vec<_>>();
|
||
assert_eq!(resolved(ruling("R8"), ResolutionOutcome::Superseded { by: rulings(8) }), Ok(()));
|
||
assert_eq!(
|
||
resolved(ruling("R8"), ResolutionOutcome::Superseded { by: rulings(9) }),
|
||
Err(PipelineRefusal::FieldBound { field: "resolution.outcome.by".into(), limit: 8, actual: 9 })
|
||
);
|
||
|
||
// Soul F3: the two reason-carrying outcomes are bounded like every
|
||
// `Line`; the shared arm is pinned on both so neither can drop out.
|
||
let wide = Line("a".repeat(1001));
|
||
for outcome in [
|
||
ResolutionOutcome::Recorded { reason: wide.clone() },
|
||
ResolutionOutcome::Withdrawn { reason: wide },
|
||
] {
|
||
let finding = PipelineRef { kind: PipelineKind::Finding, id: id("finding", "cut-3a.s2.F4") };
|
||
assert_eq!(
|
||
resolved(finding, outcome),
|
||
Err(PipelineRefusal::FieldBound { field: "resolution.outcome.reason".into(), limit: 1000, actual: 1001 })
|
||
);
|
||
}
|
||
|
||
// A night's closing is bounded on both of its fields: her line like any `Line`, and the memory it names
|
||
// like any referent.
|
||
let run = PipelineRef { kind: PipelineKind::Run, id: id("run", "night") };
|
||
let memory = PipelineRef { kind: PipelineKind::Memory, id: id("memory", "m-1") };
|
||
let slept = |line: Line, day_through: Option<PipelineRef>| resolved(run.clone(), ResolutionOutcome::Slept { line, day_through });
|
||
assert_eq!(slept("ok".into(), Some(memory.clone())), Ok(()));
|
||
assert_eq!(slept("ok".into(), None), Ok(()));
|
||
assert_eq!(slept(Line("a".repeat(1001)), None), Err(PipelineRefusal::FieldBound { field: "resolution.outcome.line".into(), limit: 1000, actual: 1001 }));
|
||
refused("resolution.outcome.day_through.id", slept("ok".into(), Some(PipelineRef { kind: PipelineKind::Memory, id: id("ruling", "R8") })));
|
||
|
||
// A move names the receiving mind as any other slug is named, and refuses one that is not a slug.
|
||
let campaign = PipelineRef { kind: PipelineKind::Campaign, id: id("campaign", "self") };
|
||
let moved = |to: &str| resolved(campaign.clone(), ResolutionOutcome::Moved { to: Slug(to.into()) });
|
||
assert_eq!(moved("heimdall"), Ok(()));
|
||
refused("resolution.outcome.to", moved("Not A Slug!"));
|
||
refused("resolution.outcome.to", moved(""));
|
||
}
|
||
|
||
/// Ruling B: a fix names the tree where the finding stopped being true,
|
||
/// as a `Sha`. Uppercase hex of a legal length is the forgery a length
|
||
/// check passes and `hex` refuses.
|
||
#[test]
|
||
fn fixed_resolution_requires_a_commit_sha() {
|
||
let fixed = |commit: &str, by: Option<PipelineRef>| {
|
||
let mut resolution = resolution_sample();
|
||
resolution.subject = PipelineRef { kind: PipelineKind::Finding, id: id("finding", "cut-3a.s2.F4") };
|
||
resolution.outcome = ResolutionOutcome::Fixed { commit: Sha(commit.into()), by };
|
||
PipelineDocument::Resolution(resolution).validate()
|
||
};
|
||
assert_eq!(fixed("5f98228d9c", None), Ok(()));
|
||
for forged in ["5F98228D9C", "5f9822", "dirty-worktree", ""] {
|
||
assert_eq!(
|
||
fixed(forged, None),
|
||
Err(PipelineRefusal::InvalidFormat { field: "resolution.outcome.commit".into(), value: forged.into() }),
|
||
"{forged:?} is not a commit"
|
||
);
|
||
}
|
||
// Soul F1: naming the record that fixed it does not excuse the commit.
|
||
let ruling = PipelineRef { kind: PipelineKind::Ruling, id: id("ruling", "R8") };
|
||
assert_eq!(
|
||
fixed("dirty-worktree", Some(ruling)),
|
||
Err(PipelineRefusal::InvalidFormat { field: "resolution.outcome.commit".into(), value: "dirty-worktree".into() }),
|
||
"a fix with a named record still names a commit"
|
||
);
|
||
}
|
||
|
||
/// A mutation record has a key-safe identity a verdict claim can name,
|
||
/// and is pinned to a tree: its label is a `Label` and its commit a
|
||
/// `Sha`, so a dotted label and free text where a sha belongs are refused.
|
||
#[test]
|
||
fn mutation_records_carry_a_dot_free_label_and_a_commit() {
|
||
let recorded = |label: &str, commit: &str| {
|
||
let mut report = report_sample();
|
||
// Through `From<&str>`, which every text type has, so the
|
||
// widened types under M19 and M20 still compile and the
|
||
// forgeries reach validation.
|
||
report.mutations[0].label = label.into();
|
||
report.mutations[0].commit = commit.into();
|
||
PipelineDocument::CutReport(report).validate()
|
||
};
|
||
assert_eq!(recorded("M1", "5f98228d"), Ok(()));
|
||
assert_eq!(
|
||
recorded("M1.a", "5f98228d"),
|
||
Err(PipelineRefusal::InvalidFormat { field: "cut_report.mutations[0].label".into(), value: "M1.a".into() })
|
||
);
|
||
assert_eq!(
|
||
recorded("M1", "dirty-worktree"),
|
||
Err(PipelineRefusal::InvalidFormat {
|
||
field: "cut_report.mutations[0].commit".into(),
|
||
value: "dirty-worktree".into(),
|
||
})
|
||
);
|
||
}
|
||
|
||
/// Ruling A's shape half: a claim names the promise it measured and the
|
||
/// mutations it ran, both as `Label`s, and names at most eight mutations.
|
||
#[test]
|
||
fn a_verdict_claim_names_the_promise_and_the_mutation_it_measured() {
|
||
let sample = verdict_sample();
|
||
assert_eq!(sample.claims[0].promise, Some(l("P1")));
|
||
assert_eq!(sample.claims[0].mutations, vec![l("M1")]);
|
||
assert_eq!(PipelineDocument::Verdict(sample).validate(), Ok(()));
|
||
|
||
let claimed = |promise: Option<Label>, mutations: Vec<Label>| {
|
||
let mut verdict = verdict_sample();
|
||
verdict.claims[0].promise = promise;
|
||
verdict.claims[0].mutations = mutations;
|
||
PipelineDocument::Verdict(verdict).validate()
|
||
};
|
||
assert_eq!(claimed(None, vec![]), Ok(()), "a claim need not measure a promise");
|
||
assert_eq!(
|
||
claimed(Some(l("P1.a")), vec![]),
|
||
Err(PipelineRefusal::InvalidFormat { field: "verdict.claims[0].promise".into(), value: "P1.a".into() })
|
||
);
|
||
assert_eq!(
|
||
claimed(None, vec![l("M1.a")]),
|
||
Err(PipelineRefusal::InvalidFormat { field: "verdict.claims[0].mutations[0]".into(), value: "M1.a".into() })
|
||
);
|
||
let labels = |count: usize| (1..=count).map(|n| l(&format!("M{n}"))).collect::<Vec<_>>();
|
||
assert_eq!(claimed(None, labels(8)), Ok(()));
|
||
assert_eq!(
|
||
claimed(None, labels(9)),
|
||
Err(PipelineRefusal::FieldBound { field: "verdict.claims[0].mutations".into(), limit: 8, actual: 9 })
|
||
);
|
||
}
|
||
|
||
/// A tripwire on the one hazard of retyping the estimate: the delta's two
|
||
/// `u32` fields are not interchangeable. The sample spec is Cut 3a's, a
|
||
/// net addition of 900 lines, and the order is asserted.
|
||
#[test]
|
||
fn the_sample_cut_spec_estimates_a_net_addition() {
|
||
let PipelineDocument::CutSpec(spec) = samples().remove(4).0 else { unreachable!() };
|
||
assert_eq!(spec.estimate.lines_added, 900);
|
||
assert_eq!(spec.estimate.lines_removed, 0);
|
||
assert_eq!(spec.estimate, report_sample().structural_delta, "the estimate and the actual are one shape");
|
||
}
|
||
|
||
/// Defect 2: a resolution's key carries its subject's kind as a literal
|
||
/// part of the local, so nothing infers it and two subjects of one local
|
||
/// and different kinds resolve to two keys. Each reads back through the
|
||
/// reader with the subject kind it was built from.
|
||
#[test]
|
||
fn a_resolution_names_its_subjects_kind() {
|
||
let pairs = [
|
||
(PipelineKind::Question, id("question", "Q1").0, PipelineKind::Ruling, id("ruling", "Q1").0),
|
||
(
|
||
PipelineKind::Campaign,
|
||
format!("{CAMPAIGN}:campaign:self"),
|
||
PipelineKind::Instance,
|
||
format!("{CAMPAIGN}:instance:self"),
|
||
),
|
||
];
|
||
for (left_kind, left_id, right_kind, right_id) in pairs {
|
||
let left = resolution_of(left_kind, &left_id).expect("the left subject keys");
|
||
let right = resolution_of(right_kind, &right_id).expect("the right subject keys");
|
||
assert_ne!(left, right, "resolutions of {left_id} and {right_id} are two documents");
|
||
for (kind, key) in [(left_kind, &left), (right_kind, &right)] {
|
||
let (root, local) = pipeline_id("read_back", key, PipelineKind::Resolution).expect("reads back");
|
||
assert_eq!(root, CAMPAIGN);
|
||
assert_eq!(local.split('.').next(), Some(kind.name()), "{key} names its subject's kind");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Defect 3: a resolution's own key is an ordinary id, so it composes as a
|
||
/// subject like any other, and the key reads back to the inner key -- the
|
||
/// whole inner key, sequence included, since the subject is recovered by
|
||
/// stripping this resolution's own sequence from the end and the kind from
|
||
/// the front. The depth bound is tested per kind by
|
||
/// `every_resolvable_kind_resolves_two_deep`.
|
||
#[test]
|
||
fn a_resolution_of_a_resolution_reads_back() {
|
||
let inner = resolution_of(PipelineKind::Question, &id("question", "Q1").0).expect("the inner keys");
|
||
let mut outer = resolution_sample();
|
||
outer.subject = PipelineRef { kind: PipelineKind::Resolution, id: Short(inner.clone()) };
|
||
let outer = PipelineDocument::Resolution(outer);
|
||
assert_eq!(outer.validate(), Ok(()));
|
||
let key = pipeline_key(&outer).expect("a resolution of a resolution keys");
|
||
assert_eq!(key, format!("{CAMPAIGN}:resolution:resolution.question.Q1.n1.n1"));
|
||
let (root, local) = pipeline_id("read_back", &key, PipelineKind::Resolution).expect("reads back");
|
||
let (subject_kind, rest) = local.split_once('.').expect("the local names a subject");
|
||
let (subject_local, _sequence) = rest.rsplit_once('.').expect("the local ends in this resolution's sequence");
|
||
assert_eq!(format!("{root}:{subject_kind}:{subject_local}"), inner, "the recovered subject is the inner key");
|
||
}
|
||
|
||
/// Each level of a nesting carries its own sequence, and only its own: the
|
||
/// part appended is this resolution's `sequence` field, never a copy of the
|
||
/// sequence the subject already ends in. The fixtures elsewhere all sit at
|
||
/// `1` at every level, where the two are indistinguishable, so the chain
|
||
/// here differs at every level and reads back level by level.
|
||
///
|
||
/// On spelling, so silence is not read as coverage: the leaf emits `n<N>`
|
||
/// with `N` as `u32::to_string` writes it, so `n0` and `n12` are emitted
|
||
/// and `n01` is not. A hand-built id ending `n01`, `n1x`, or no `n<N>` at
|
||
/// all is still a well-formed reference -- those are legal labels, and a
|
||
/// grammar that reads keys may not refuse them on spelling. Nothing in the
|
||
/// leaf canonicalises them either: such an id simply names a document the
|
||
/// leaf would never key, which admission's A7 sees as a missing referent.
|
||
#[test]
|
||
fn every_level_of_a_nesting_keeps_its_own_sequence() {
|
||
let resolved = |kind: PipelineKind, subject: &str, sequence: u32| {
|
||
let mut resolution = resolution_sample();
|
||
resolution.subject = PipelineRef { kind, id: Short(subject.into()) };
|
||
resolution.sequence = sequence;
|
||
pipeline_key(&PipelineDocument::Resolution(resolution)).expect("a sequenced resolution keys")
|
||
};
|
||
let first = resolved(PipelineKind::Question, &id("question", "Q1").0, 9);
|
||
let second = resolved(PipelineKind::Resolution, &first, 10);
|
||
let third = resolved(PipelineKind::Resolution, &second, 0);
|
||
let fourth = resolved(PipelineKind::Resolution, &third, 12);
|
||
assert_eq!(first, format!("{CAMPAIGN}:resolution:question.Q1.n9"));
|
||
assert_eq!(second, format!("{CAMPAIGN}:resolution:resolution.question.Q1.n9.n10"));
|
||
assert_eq!(third, format!("{CAMPAIGN}:resolution:resolution.resolution.question.Q1.n9.n10.n0"));
|
||
assert_eq!(
|
||
fourth,
|
||
format!("{CAMPAIGN}:resolution:resolution.resolution.resolution.question.Q1.n9.n10.n0.n12")
|
||
);
|
||
for (outer, inner, sequence) in [(&fourth, &third, 12), (&third, &second, 0), (&second, &first, 10)] {
|
||
let (root, local) = pipeline_id("read_back", outer, PipelineKind::Resolution).expect("reads back");
|
||
let (subject_kind, rest) = local.split_once('.').expect("the local names a subject");
|
||
let (subject_local, last) = rest.rsplit_once('.').expect("the local ends in this resolution's sequence");
|
||
assert_eq!(subject_kind, "resolution", "{outer}: the subject of a nesting is a resolution");
|
||
assert_eq!(format!("{root}:{subject_kind}:{subject_local}"), *inner, "{outer}: the subject is the inner key");
|
||
assert_eq!(last, format!("n{sequence}"), "{outer}: this level's own sequence, not the subject's");
|
||
}
|
||
}
|
||
|
||
/// A stewardship is the other sequenced kind, and it composes as a subject
|
||
/// like any other: the subject's whole local, escaped repo and sequence
|
||
/// both, is the resolution's local, with the resolution's own sequence
|
||
/// last. So the two assignments of one repo have two resolutions, and a
|
||
/// resolution of either is itself resolvable.
|
||
#[test]
|
||
fn a_resolution_of_a_stewardship_reads_back() {
|
||
let stewarded = |sequence: u32| {
|
||
let mut stewardship = stewardship_sample();
|
||
stewardship.sequence = sequence;
|
||
pipeline_key(&PipelineDocument::Stewardship(stewardship)).expect("a sequenced stewardship keys")
|
||
};
|
||
let first = stewarded(1);
|
||
let second = stewarded(2);
|
||
assert_eq!(first, format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1"));
|
||
assert_eq!(second, format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2"));
|
||
|
||
let resolved = resolution_of(PipelineKind::Stewardship, &first).expect("a resolution of a stewardship keys");
|
||
let resolved_second = resolution_of(PipelineKind::Stewardship, &second).expect("the second assignment resolves");
|
||
assert_eq!(resolved, format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n1.n1"));
|
||
assert_eq!(resolved_second, format!("{INSTANCE}:resolution:stewardship.gamecult_-epiphany.n2.n1"));
|
||
assert_ne!(resolved, resolved_second, "each assignment keeps its own resolution");
|
||
|
||
for (key, subject) in [(&resolved, &first), (&resolved_second, &second)] {
|
||
let (root, local) = pipeline_id("read_back", key, PipelineKind::Resolution).expect("reads back");
|
||
assert_eq!(root, INSTANCE, "{key}: a stewardship's resolution lives in the mind's root");
|
||
let (subject_kind, rest) = local.split_once('.').expect("the local names a subject");
|
||
let (subject_local, last) = rest.rsplit_once('.').expect("the local ends in this resolution's sequence");
|
||
assert_eq!(subject_kind, PipelineKind::Stewardship.name(), "{key} names its subject's kind");
|
||
assert_eq!(
|
||
format!("{root}:{subject_kind}:{subject_local}"),
|
||
*subject,
|
||
"{key}: the subject's own sequence is part of the recovered key"
|
||
);
|
||
assert_eq!(last, "n1", "{key}: this resolution's own sequence");
|
||
|
||
// A withdrawal of that resolution nests one out, as it does under
|
||
// any other subject, and reads back to the resolution it withdraws.
|
||
let withdrawal = resolution_of(PipelineKind::Resolution, key).expect("a withdrawal keys");
|
||
let subject_local = key.split(':').nth(2).expect("three segments");
|
||
assert_eq!(withdrawal, format!("{INSTANCE}:resolution:resolution.{subject_local}.n1"));
|
||
let (root, local) = pipeline_id("read_back", &withdrawal, PipelineKind::Resolution).expect("reads back");
|
||
let (subject_kind, rest) = local.split_once('.').expect("the local names a subject");
|
||
let (subject_local, _) = rest.rsplit_once('.').expect("the local ends in its own sequence");
|
||
assert_eq!(format!("{root}:{subject_kind}:{subject_local}"), **key, "{withdrawal}: the withdrawn resolution");
|
||
}
|
||
}
|
||
|
||
/// A legal dotted local of exactly `length` bytes (`a.a.a`, each part a
|
||
/// label), for bounds wider than one label.
|
||
fn dotted_local(length: usize) -> String {
|
||
match length % 2 {
|
||
1 => format!("{}a", "a.".repeat(length / 2)),
|
||
_ => format!("{}aa", "a.".repeat(length / 2 - 1)),
|
||
}
|
||
}
|
||
|
||
/// A resolution of `subject`, at `sequence`, keyed.
|
||
fn resolved_at(kind: PipelineKind, subject: &str, sequence: u32) -> Result<String, PipelineRefusal> {
|
||
let mut resolution = resolution_sample();
|
||
resolution.subject = PipelineRef { kind, id: Short(subject.into()) };
|
||
resolution.sequence = sequence;
|
||
pipeline_key(&PipelineDocument::Resolution(resolution))
|
||
}
|
||
|
||
/// every-subject-resolvable: for each kind that can be a subject, at the
|
||
/// widest subject local (64) and `u32::MAX` sequences, depth one and depth
|
||
/// two key and read back through `pipeline_id`; depth three is refused on
|
||
/// the local bound.
|
||
#[test]
|
||
fn every_resolvable_kind_resolves_two_deep() {
|
||
let wide = "a".repeat(SUBJECT_LOCAL_MAX);
|
||
let mut widest = 0;
|
||
for kind in PipelineKind::ALL.iter().copied().filter(|kind| *kind != PipelineKind::Resolution) {
|
||
let subject = format!("{CAMPAIGN}:{}:{wide}", kind.name());
|
||
assert_eq!(pipeline_id("subject", &subject, kind), Ok((CAMPAIGN, wide.as_str())), "{kind:?}: the subject parses");
|
||
let one = resolved_at(kind, &subject, u32::MAX).unwrap_or_else(|error| panic!("{kind:?} depth one: {error}"));
|
||
let two = resolved_at(PipelineKind::Resolution, &one, u32::MAX)
|
||
.unwrap_or_else(|error| panic!("{kind:?} depth two: {error}"));
|
||
for key in [&one, &two] {
|
||
assert!(key.len() <= 200, "{key}: an id fits a Short");
|
||
let (root, local) = pipeline_id("read_back", key, PipelineKind::Resolution)
|
||
.unwrap_or_else(|error| panic!("{kind:?}: {key} does not read back: {error}"));
|
||
assert_eq!(root, CAMPAIGN);
|
||
assert!(local.len() <= RESOLUTION_LOCAL_MAX);
|
||
}
|
||
widest = widest.max(two.len() - format!("{CAMPAIGN}:resolution:").len());
|
||
assert!(
|
||
matches!(
|
||
resolved_at(PipelineKind::Resolution, &two, u32::MAX),
|
||
Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "resolution.key"
|
||
),
|
||
"{kind:?}: depth three is refused on the local bound"
|
||
);
|
||
}
|
||
assert_eq!(widest, RESOLUTION_LOCAL_MAX, "the bound is exactly the widest depth-two chain");
|
||
assert_eq!(RESOLUTION_LOCAL_MAX, 111);
|
||
}
|
||
|
||
#[test]
|
||
fn the_longest_kind_name_is_the_longest() {
|
||
let longest = PipelineKind::ALL.iter().map(|kind| kind.name().len()).max();
|
||
assert_eq!(longest, Some(LONGEST_KIND_NAME));
|
||
}
|
||
|
||
/// The parser bounds an id's local by kind, exactly where the composer does.
|
||
#[test]
|
||
fn an_id_local_is_bounded_by_its_kind() {
|
||
let at = |kind: PipelineKind, length: usize| {
|
||
pipeline_id("probe", &format!("{CAMPAIGN}:{}:{}", kind.name(), dotted_local(length)), kind).map(|_| ())
|
||
};
|
||
assert_eq!(at(PipelineKind::Question, SUBJECT_LOCAL_MAX), Ok(()));
|
||
assert!(at(PipelineKind::Question, SUBJECT_LOCAL_MAX + 1).is_err());
|
||
assert_eq!(at(PipelineKind::Resolution, RESOLUTION_LOCAL_MAX), Ok(()));
|
||
assert!(at(PipelineKind::Resolution, RESOLUTION_LOCAL_MAX + 1).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn sha_names_same_commit() {
|
||
let full = Sha("5f98228d1c2b3a4f5e6d7c8b9a0f1e2d3c4b5a69".into());
|
||
assert!(full.names_same_commit(&Sha("5f98228".into())));
|
||
assert!(Sha("5f98228".into()).names_same_commit(&full));
|
||
assert!(full.names_same_commit(&full));
|
||
assert!(!full.names_same_commit(&Sha("5f98229".into())));
|
||
assert!(!Sha("5f98228".into()).names_same_commit(&Sha("5f98229".into())));
|
||
assert!(!Sha("5f98228a".into()).names_same_commit(&Sha("5f98228b".into())), "equal length, differing");
|
||
}
|
||
|
||
/// The total bound, in the one place it lives: parts that are each a legal
|
||
/// label compose a local wider than 64 bytes and are refused as a whole.
|
||
#[test]
|
||
fn a_composed_local_is_bounded_whole() {
|
||
let mut wide = hand_off_sample();
|
||
wide.to_instance = Slug("a".repeat(60));
|
||
assert_eq!(wide.to_instance.validate("hand_off.to_instance"), Ok(()), "the receiver alone is legal");
|
||
assert_eq!(
|
||
label_text("part", &key_segment(&wide.repo.0)),
|
||
Ok(()),
|
||
"the repo alone is legal"
|
||
);
|
||
let key = pipeline_key(&PipelineDocument::HandOff(wide));
|
||
assert!(
|
||
matches!(&key, Err(PipelineRefusal::InvalidFormat { field, value }) if field == "hand_off.key" && value.len() > 64),
|
||
"a local of legal parts is still bounded whole, got {key:?}"
|
||
);
|
||
|
||
// The bound is 64 exactly, on both sides, whether the part that fills
|
||
// it is a plain label or an escaped repo. A hand-off local is
|
||
// `<receiver>.<repo>.<date>`, the repo `gamecult_-epiphany` and the
|
||
// date ten bytes, so the receiver fills the rest; a stewardship local is
|
||
// `<repo escaped>.n<N>`, so `GameCult_-` and `.n1` bound the name.
|
||
let hand_off_rest = "gamecult_-epiphany".len() + ".".len() + date().0.len() + ".".len();
|
||
let stewardship_rest = "GameCult_-".len() + ".n1".len();
|
||
let received = |receiver: usize| {
|
||
let mut hand_off = hand_off_sample();
|
||
hand_off.to_instance = Slug("a".repeat(receiver));
|
||
pipeline_key(&PipelineDocument::HandOff(hand_off))
|
||
};
|
||
let stewarded = |name: usize| {
|
||
let mut stewardship = stewardship_sample();
|
||
stewardship.repo = OrgRepo(format!("GameCult/{}", "a".repeat(name)));
|
||
pipeline_key(&PipelineDocument::Stewardship(stewardship))
|
||
};
|
||
for (label, at_64, at_65) in [
|
||
("plain label", received(64 - hand_off_rest), received(65 - hand_off_rest)),
|
||
("escaped repo", stewarded(64 - stewardship_rest), stewarded(65 - stewardship_rest)),
|
||
] {
|
||
let keyed = at_64.unwrap_or_else(|error| panic!("{label}: a 64-byte local keys: {error}"));
|
||
assert_eq!(keyed.split(':').nth(2).map(str::len), Some(64), "{label}: {keyed} fills the local exactly");
|
||
assert!(
|
||
matches!(&at_65, Err(PipelineRefusal::InvalidFormat { value, .. }) if value.len() == 65),
|
||
"{label}: a 65-byte local is refused, got {at_65:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The resolution side of the bound: 111 keys and 112 is refused on
|
||
/// `resolution.key`, filled by a resolution subject (`resolution.<L>.n1`).
|
||
#[test]
|
||
fn a_composed_resolution_local_is_bounded_whole() {
|
||
let overhead = "resolution.".len() + ".n1".len();
|
||
let subject = |length: usize| format!("{CAMPAIGN}:resolution:{}", dotted_local(length));
|
||
let at_max = resolved_at(PipelineKind::Resolution, &subject(RESOLUTION_LOCAL_MAX - overhead), 1)
|
||
.expect("a 111-byte resolution local keys");
|
||
assert_eq!(at_max.split(':').nth(2).map(str::len), Some(RESOLUTION_LOCAL_MAX));
|
||
assert!(matches!(
|
||
resolved_at(PipelineKind::Resolution, &subject(RESOLUTION_LOCAL_MAX - overhead + 1), 1),
|
||
Err(PipelineRefusal::InvalidFormat { field, value }) if field == "resolution.key" && value.len() == RESOLUTION_LOCAL_MAX + 1
|
||
));
|
||
}
|
||
|
||
/// R2, on the composer every kind goes through: no local part carries the
|
||
/// separator, the head included. The live case is a hand-off with a dotted
|
||
/// receiver, which keys to an escaped label rather than a wider local.
|
||
#[test]
|
||
fn no_local_part_carries_the_separator() {
|
||
assert_eq!(
|
||
local("probe", PipelineKind::Question, &["a.b"]),
|
||
Err(PipelineRefusal::InvalidFormat { field: "probe".into(), value: "a.b".into() }),
|
||
"the head may not carry the separator"
|
||
);
|
||
assert_eq!(
|
||
local("probe", PipelineKind::Question, &["a", "b.c"]),
|
||
Err(PipelineRefusal::InvalidFormat { field: "probe".into(), value: "b.c".into() }),
|
||
"a tail part may not carry the separator"
|
||
);
|
||
assert_eq!(local("probe", PipelineKind::Question, &["a", "b", "c"]), Ok("a.b.c".into()));
|
||
|
||
let mut dotted = hand_off_sample();
|
||
dotted.to_instance = slug("thought-cage.GameCult");
|
||
let key = pipeline_key(&PipelineDocument::HandOff(dotted)).expect("a dotted receiver keys");
|
||
let local = key.split(':').nth(2).expect("three segments");
|
||
assert_eq!(local.split('.').count(), 3, "{key}: the receiver is one part, not two");
|
||
assert_eq!(local.split('.').next(), Some("thought-cage_dGameCult"));
|
||
}
|
||
|
||
/// Q17 B: a subject's resolutions are distinct records, ordered by a
|
||
/// per-subject sequence the key carries as its last part, so a withdrawn
|
||
/// resolution stays under its subject and the next closure is nameable.
|
||
/// The sequence is a field the writer sets and this crate only composes:
|
||
/// it bounds nothing beyond `u32` and the local's own width, so `0` and a
|
||
/// gap both key. Which sequence a document may claim, and whether an
|
||
/// earlier resolution still stands, are admission's rules, exactly as
|
||
/// `revision: 0` is.
|
||
#[test]
|
||
fn a_subject_keeps_every_resolution_it_had() {
|
||
let resolved = |sequence: u32| {
|
||
let mut resolution = resolution_sample();
|
||
resolution.sequence = sequence;
|
||
pipeline_key(&PipelineDocument::Resolution(resolution)).expect("a sequenced resolution keys")
|
||
};
|
||
let first = resolved(1);
|
||
let second = resolved(2);
|
||
assert_eq!(first, format!("{CAMPAIGN}:resolution:question.Q1.n1"));
|
||
assert_eq!(second, format!("{CAMPAIGN}:resolution:question.Q1.n2"));
|
||
assert_ne!(first, second, "two resolutions of one subject are two documents");
|
||
for (sequence, key) in [(1, &first), (2, &second)] {
|
||
let (root, local) = pipeline_id("read_back", key, PipelineKind::Resolution).expect("reads back");
|
||
assert_eq!(root, CAMPAIGN, "{key}: the subject's root");
|
||
let last = format!("n{sequence}");
|
||
assert_eq!(local.rsplit('.').next(), Some(last.as_str()), "{key} ends in its sequence");
|
||
assert_eq!(local.split('.').count(), 3, "{key}: subject kind, subject local, sequence");
|
||
}
|
||
|
||
// Outcome-invariance now holds per sequence: one record under two
|
||
// outcomes is still one key, which is exactly why a second closure of
|
||
// a subject needs a second sequence and not a second outcome.
|
||
let mut withdrawn = resolution_sample();
|
||
withdrawn.outcome = ResolutionOutcome::Withdrawn { reason: "moot".into() };
|
||
assert_eq!(
|
||
pipeline_key(&PipelineDocument::Resolution(withdrawn)),
|
||
Ok(first.clone()),
|
||
"one sequence is one key whatever the outcome"
|
||
);
|
||
|
||
// Stated limits. Both are admission's to refuse and neither is the
|
||
// leaf's, so both compose here.
|
||
assert_eq!(resolved(0), format!("{CAMPAIGN}:resolution:question.Q1.n0"), "the leaf refuses no sequence value");
|
||
let mut widest = resolution_sample();
|
||
widest.subject = PipelineRef { kind: PipelineKind::Ruling, id: id("ruling", "A") };
|
||
widest.sequence = u32::MAX;
|
||
assert_eq!(
|
||
pipeline_key(&PipelineDocument::Resolution(widest)),
|
||
Ok(format!("{CAMPAIGN}:resolution:ruling.A.n4294967295")),
|
||
"the widest sequence is an eleven-byte label, not a refusal"
|
||
);
|
||
}
|
||
|
||
/// D2: the sequence is the *last* part, so a subject's resolutions and
|
||
/// only they share the prefix `<root>:resolution:<kind>.<local>.n`. R2 is
|
||
/// what makes the prefix exact: no local part carries a dot, so
|
||
/// `question.Q1.n` cannot match `question.Q10.n1`, and the withdrawal of
|
||
/// `Q1`'s first resolution is a record of that resolution, one nesting
|
||
/// out and outside `Q1`'s prefix. Pinned as a string property, which is
|
||
/// what a prefix query is.
|
||
#[test]
|
||
fn a_subjects_resolutions_share_a_prefix_no_other_key_has() {
|
||
let resolved = |kind: PipelineKind, subject: &str, sequence: u32| {
|
||
let mut resolution = resolution_sample();
|
||
resolution.subject = PipelineRef { kind, id: Short(subject.into()) };
|
||
resolution.sequence = sequence;
|
||
pipeline_key(&PipelineDocument::Resolution(resolution)).expect("the subject keys")
|
||
};
|
||
let history = [1, 2, 3].map(|sequence| resolved(PipelineKind::Question, &id("question", "Q1").0, sequence));
|
||
let others = [
|
||
resolved(PipelineKind::Question, &id("question", "Q10").0, 1),
|
||
resolved(PipelineKind::Ruling, &id("ruling", "Q1").0, 1),
|
||
resolved(PipelineKind::Campaign, &format!("{CAMPAIGN}:campaign:self"), 1),
|
||
resolved(PipelineKind::Resolution, &history[0], 1),
|
||
];
|
||
assert_eq!(
|
||
others[3],
|
||
format!("{CAMPAIGN}:resolution:resolution.question.Q1.n1.n1"),
|
||
"a withdrawal keys under the resolution it withdraws, not under the subject"
|
||
);
|
||
let prefix = format!("{CAMPAIGN}:resolution:question.Q1.n");
|
||
let selected = history
|
||
.iter()
|
||
.chain(others.iter())
|
||
.filter(|key| key.starts_with(&prefix))
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
selected,
|
||
history.iter().collect::<Vec<_>>(),
|
||
"the prefix selects the subject's resolutions and nothing else"
|
||
);
|
||
}
|
||
|
||
/// Q18 A and Q20 A: a repo's stewardships on a mind are distinct records,
|
||
/// ordered by a per-`(instance, repo)` sequence the key carries last, so a
|
||
/// repo transferred away and later transferred back is two records under
|
||
/// one prefix rather than one document overwriting the other. The date is
|
||
/// not in the key: `assigned_on` is validated where every field is, by the
|
||
/// derived `Bounded` impl, and two assignments differing only in it are
|
||
/// one document.
|
||
#[test]
|
||
fn a_repo_keeps_every_stewardship_it_had() {
|
||
let stewarded = |repo: &str, sequence: u32, assigned_on: &str| {
|
||
let mut stewardship = stewardship_sample();
|
||
stewardship.repo = OrgRepo(repo.into());
|
||
stewardship.sequence = sequence;
|
||
stewardship.assigned_on = Date(assigned_on.into());
|
||
pipeline_key(&PipelineDocument::Stewardship(stewardship)).expect("a sequenced stewardship keys")
|
||
};
|
||
let first = stewarded("GameCult/Epiphany", 1, "2026-09-15");
|
||
let second = stewarded("GameCult/Epiphany", 2, "2026-09-16");
|
||
assert_eq!(first, format!("{INSTANCE}:stewardship:gamecult_-epiphany.n1"));
|
||
assert_eq!(second, format!("{INSTANCE}:stewardship:gamecult_-epiphany.n2"));
|
||
assert_ne!(first, second, "two assignments of one repo are two documents");
|
||
for (sequence, key) in [(1, &first), (2, &second)] {
|
||
let (root, local) = pipeline_id("read_back", key, PipelineKind::Stewardship).expect("reads back");
|
||
assert_eq!(root, INSTANCE, "{key}: the mind's root");
|
||
let last = format!("n{sequence}");
|
||
assert_eq!(local.rsplit('.').next(), Some(last.as_str()), "{key} ends in its sequence");
|
||
assert_eq!(local.split('.').count(), 2, "{key}: the escaped repo and the sequence, nothing else");
|
||
}
|
||
|
||
assert_eq!(
|
||
stewarded("GameCult/Epiphany", 1, "2026-09-16"),
|
||
first,
|
||
"assigned_on is a field, not a key part: one sequence is one key whatever the date"
|
||
);
|
||
|
||
let others = [
|
||
stewarded("GameCult/Epiphany_thing", 1, "2026-09-15"),
|
||
stewarded("GameCult/Huginn", 1, "2026-09-15"),
|
||
];
|
||
let prefix = format!("{INSTANCE}:stewardship:gamecult_-epiphany.n");
|
||
let history = [first, second];
|
||
let selected = history
|
||
.iter()
|
||
.chain(others.iter())
|
||
.filter(|key| key.starts_with(&prefix))
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
selected,
|
||
history.iter().collect::<Vec<_>>(),
|
||
"the prefix selects the repo's assignments on this mind and nothing else"
|
||
);
|
||
|
||
assert_eq!(
|
||
stewarded("GameCult/Epiphany", 0, "2026-09-15"),
|
||
format!("{INSTANCE}:stewardship:gamecult_-epiphany.n0"),
|
||
"the leaf refuses no sequence value"
|
||
);
|
||
}
|
||
|
||
fn read_anchor(line: u32, end_line: Option<u32>, symbol: Option<&str>) -> ReadAnchor {
|
||
ReadAnchor {
|
||
location: CodeLocation { path: s("crates/eureka-pipeline/src/lib.rs"), line, end_line, symbol: symbol.map(Symbol::from) },
|
||
why: "read".into(),
|
||
}
|
||
}
|
||
|
||
fn cut_spec_sample() -> PipelineCutSpec {
|
||
let PipelineDocument::CutSpec(spec) = samples().remove(4).0 else { unreachable!() };
|
||
spec
|
||
}
|
||
|
||
/// Every committed golden line decodes, from its committed payload bytes,
|
||
/// to the corpus document it was derived from. The pre-field lines carry no
|
||
/// `reads` or `symbol` key, so they decode to empty and `None` only
|
||
/// because both fields default on read.
|
||
#[test]
|
||
fn golden_payloads_decode_to_their_documents() -> Result<()> {
|
||
let cache = schema_cache()?;
|
||
let golden = include_str!("../golden/envelopes.txt");
|
||
let lines: BTreeMap<&str, &str> = golden
|
||
.lines()
|
||
.filter(|line| !line.starts_with('#') && !line.is_empty())
|
||
.filter_map(|line| Some((line.split(' ').next()?, line.rsplit(' ').next()?)))
|
||
.collect();
|
||
let corpus = golden_documents();
|
||
assert_eq!(lines.len(), corpus.len());
|
||
for (label, document) in corpus {
|
||
let hex = lines[label.as_str()];
|
||
let mut envelope = document.prepare(&cache)?;
|
||
envelope.payload = (0..hex.len()).step_by(2).map(|at| u8::from_str_radix(&hex[at..at + 2], 16).unwrap()).collect();
|
||
assert_eq!(PipelineDocument::decode(&envelope)?, document, "{label} decodes to its document");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The MCP door: JSON with no `reads` key and locations with no `symbol`
|
||
/// key is the pre-field shape, and it decodes.
|
||
#[test]
|
||
fn pre_field_json_decodes() -> Result<()> {
|
||
let json = serde_json::to_value(PipelineDocument::CutSpec(cut_spec_sample()))?;
|
||
assert!(json["value"].get("reads").is_none(), "an empty reads is not written");
|
||
assert!(json["value"]["file_changes"][0]["location"].get("symbol").is_none(), "an absent symbol is not written");
|
||
let PipelineDocument::CutSpec(spec) = serde_json::from_value::<PipelineDocument>(json)? else { unreachable!() };
|
||
assert!(spec.reads.is_empty());
|
||
assert_eq!(spec.file_changes[0].location.symbol, None);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn symbol_and_reads_round_trip() -> Result<()> {
|
||
let mut spec = cut_spec_sample();
|
||
spec.reads = (0..16).map(|index| read_anchor(index + 1, Some(index + 100), Some("value_types"))).collect();
|
||
let mut finding = finding_sample();
|
||
finding.locations[0].symbol = Some("&1153090498".into());
|
||
for document in [PipelineDocument::CutSpec(spec), PipelineDocument::Finding(finding)] {
|
||
document.validate()?;
|
||
let json = serde_json::to_value(&document)?;
|
||
assert_eq!(serde_json::from_value::<PipelineDocument>(json.clone())?, document);
|
||
let packed = rmp_serde::to_vec_named(&document)?;
|
||
assert_eq!(rmp_serde::from_slice::<PipelineDocument>(&packed)?, document);
|
||
assert!(packed.windows(6).any(|window| window == b"symbol"), "the symbol key is written");
|
||
assert_eq!(packed.windows(5).any(|window| window == b"reads"), matches!(document, PipelineDocument::CutSpec(_)));
|
||
assert!(json.to_string().contains("\"symbol\""));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn read_and_symbol_bounds() {
|
||
let with_reads = |reads: Vec<ReadAnchor>| {
|
||
let mut spec = cut_spec_sample();
|
||
spec.reads = reads;
|
||
PipelineDocument::CutSpec(spec).validate()
|
||
};
|
||
let with_symbol = |symbol: &str| {
|
||
let mut finding = finding_sample();
|
||
finding.locations[0].symbol = Some(symbol.into());
|
||
PipelineDocument::Finding(finding).validate()
|
||
};
|
||
assert_eq!(with_reads((0..16).map(|_| read_anchor(1, Some(100), None)).collect()), Ok(()));
|
||
assert_eq!(
|
||
with_reads((0..17).map(|_| read_anchor(1, Some(100), None)).collect()),
|
||
Err(PipelineRefusal::FieldBound { field: "cut_spec.reads".into(), limit: 16, actual: 17 })
|
||
);
|
||
assert_eq!(
|
||
with_reads(vec![read_anchor(5, Some(4), None)]),
|
||
Err(PipelineRefusal::InvalidFormat { field: "cut_spec.reads[0].location.end_line".into(), value: "4".into() })
|
||
);
|
||
assert_eq!(
|
||
with_reads(vec![read_anchor(0, None, None)]),
|
||
Err(PipelineRefusal::InvalidFormat { field: "cut_spec.reads[0].location.line".into(), value: "0".into() })
|
||
);
|
||
assert_eq!(with_reads(vec![read_anchor(7, Some(7), None)]), Ok(()), "a one-line span");
|
||
assert_eq!(with_reads(vec![read_anchor(10, Some(109), None)]), Ok(()), "a span of 100");
|
||
assert_eq!(
|
||
with_reads(vec![read_anchor(10, Some(110), None)]),
|
||
Err(PipelineRefusal::FieldBound { field: "cut_spec.reads[0].location.end_line".into(), limit: 100, actual: 101 })
|
||
);
|
||
assert_eq!(with_symbol(&"s".repeat(128)), Ok(()));
|
||
assert_eq!(
|
||
with_symbol(&"s".repeat(129)),
|
||
Err(PipelineRefusal::FieldBound { field: "finding.locations[0].symbol".into(), limit: 128, actual: 129 })
|
||
);
|
||
for bad in ["a b", "a\tb", "", "a\u{0}b", "a\u{3000}b"] {
|
||
assert_eq!(
|
||
with_symbol(bad),
|
||
Err(PipelineRefusal::InvalidFormat { field: "finding.locations[0].symbol".into(), value: bad.into() }),
|
||
"{bad:?}"
|
||
);
|
||
}
|
||
// A location outside a read anchor keeps its stored shape.
|
||
let mut finding = finding_sample();
|
||
finding.locations[0].line = 0;
|
||
finding.locations[0].end_line = Some(0);
|
||
assert_eq!(PipelineDocument::Finding(finding).validate(), Ok(()));
|
||
}
|
||
|
||
#[test]
|
||
fn target_repos_absent_decode_and_round_trip() -> Result<()> {
|
||
// The MCP door: a JSON target with no `repos` key decodes to an empty list.
|
||
let PipelineDocument::Target(sample) = samples().remove(1).0 else { unreachable!() };
|
||
let mut json = serde_json::to_value(PipelineDocument::Target(sample.clone()))?;
|
||
assert!(json["value"].get("repos").is_none(), "an empty repos is not written");
|
||
let PipelineDocument::Target(decoded) = serde_json::from_value::<PipelineDocument>(json.clone())? else { unreachable!() };
|
||
assert!(decoded.repos.is_empty());
|
||
assert!(!rmp_serde::to_vec_named(&PipelineDocument::Target(sample.clone()))?.windows(5).any(|window| window == b"repos"));
|
||
|
||
let mut named = sample;
|
||
named.repos = vec![OrgRepo("GameCult/Epiphany".into()), OrgRepo("GameCult/Huginn".into())];
|
||
let document = PipelineDocument::Target(named);
|
||
document.validate()?;
|
||
json = serde_json::to_value(&document)?;
|
||
assert_eq!(serde_json::from_value::<PipelineDocument>(json)?, document);
|
||
let packed = rmp_serde::to_vec_named(&document)?;
|
||
assert_eq!(rmp_serde::from_slice::<PipelineDocument>(&packed)?, document);
|
||
assert!(packed.windows(5).any(|window| window == b"repos"), "the repos key is written");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn follow_up_origin_absent_is_not_written_and_a_direct_origin_round_trips() -> Result<()> {
|
||
let sample = samples()
|
||
.into_iter()
|
||
.find_map(|(document, _)| if let PipelineDocument::FollowUp(follow_up) = document { Some(follow_up) } else { None })
|
||
.expect("a follow_up sample");
|
||
let json = serde_json::to_value(PipelineDocument::FollowUp(sample.clone()))?;
|
||
assert!(json["value"].get("origin").is_none(), "an absent origin is not written");
|
||
assert!(!rmp_serde::to_vec_named(&PipelineDocument::FollowUp(sample.clone()))?.windows(6).any(|window| window == b"origin"));
|
||
let PipelineDocument::FollowUp(decoded) = serde_json::from_value::<PipelineDocument>(json)? else { unreachable!() };
|
||
assert_eq!(decoded.origin, None);
|
||
|
||
let mut direct = sample;
|
||
direct.origin = Some(MemoryOrigin::Direct { room: "!dm:chat".into(), with: vec!["@sample:chat".into()] });
|
||
let document = PipelineDocument::FollowUp(direct);
|
||
document.validate()?;
|
||
let json = serde_json::to_value(&document)?;
|
||
assert_eq!(serde_json::from_value::<PipelineDocument>(json)?, document);
|
||
let packed = rmp_serde::to_vec_named(&document)?;
|
||
assert_eq!(rmp_serde::from_slice::<PipelineDocument>(&packed)?, document);
|
||
assert!(packed.windows(6).any(|window| window == b"origin"), "the origin key is written");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn target_repos_bounded_and_distinct() {
|
||
let with_repos = |repos: Vec<OrgRepo>| {
|
||
let PipelineDocument::Target(mut target) = samples().remove(1).0 else { unreachable!() };
|
||
target.repos = repos;
|
||
PipelineDocument::Target(target).validate()
|
||
};
|
||
let distinct = |count: usize| (0..count).map(|index| OrgRepo(format!("GameCult/Repo{index}"))).collect::<Vec<_>>();
|
||
assert_eq!(with_repos(distinct(16)), Ok(()));
|
||
assert_eq!(
|
||
with_repos(distinct(17)),
|
||
Err(PipelineRefusal::FieldBound { field: "target.repos".into(), limit: 16, actual: 17 })
|
||
);
|
||
assert_eq!(
|
||
with_repos(vec![OrgRepo("GameCult/Huginn".into()), OrgRepo("gamecult/huginn".into())]),
|
||
Err(PipelineRefusal::DuplicateRepo { field: "target.repos".into(), repo: "gamecult/huginn".into() })
|
||
);
|
||
}
|
||
|
||
/// The marker needs an `Absent` spelling; the two impls say what absent is.
|
||
#[test]
|
||
fn absent_is_none_and_empty() {
|
||
assert!(Absent::absent(&None::<u32>));
|
||
assert!(!Absent::absent(&Some(0u32)));
|
||
assert!(Absent::absent(&Vec::<u32>::new()));
|
||
assert!(!Absent::absent(&vec![0u32]));
|
||
}
|
||
|
||
/// Cut 8: the leaf owns the serialisation of the shapes it owns, so a wire
|
||
/// carrying a document or a refusal reads back the same value rather than
|
||
/// a mirror definition kept in step by hand in the reader's repo. Both
|
||
/// encodings are pinned: JSON is what a schema describes, MessagePack is
|
||
/// what a CultCache payload is written in, and an adjacent tag has to
|
||
/// survive both. The tag is asserted against `name()` rather than against
|
||
/// a literal, because the point of the tag is that a reader dispatching on
|
||
/// a key's kind segment finds the same string here.
|
||
#[test]
|
||
fn every_document_and_refusal_serialises_and_reads_back() -> Result<()> {
|
||
for (document, _) in samples() {
|
||
let json = serde_json::to_value(&document)?;
|
||
assert_eq!(json["kind"].as_str(), Some(document.kind().name()), "{json} is tagged with its kind name");
|
||
assert_eq!(serde_json::from_value::<PipelineDocument>(json.clone())?, document, "{json} reads back");
|
||
let packed = rmp_serde::to_vec_named(&document)?;
|
||
assert_eq!(
|
||
rmp_serde::from_slice::<PipelineDocument>(&packed)?,
|
||
document,
|
||
"{:?} reads back through MessagePack",
|
||
document.kind()
|
||
);
|
||
}
|
||
|
||
// Every variant, and every one of them carrying its named parts: a
|
||
// refusal read off a wire names the field it refused, as a local one
|
||
// does.
|
||
let refusals = [
|
||
PipelineRefusal::FieldBound { field: "campaign.title".into(), limit: 200, actual: 201 },
|
||
PipelineRefusal::InvalidFormat { field: "campaign.slug".into(), value: "a b".into() },
|
||
PipelineRefusal::InvalidIdentity {
|
||
kind: PipelineKind::Campaign,
|
||
key: format!("{CAMPAIGN}:campaign:forged"),
|
||
expected: format!("{CAMPAIGN}:campaign:self"),
|
||
},
|
||
PipelineRefusal::DuplicateRepo { field: "campaign.repos".into(), repo: "gamecult/epiphany".into() },
|
||
PipelineRefusal::ForeignStore { r#type: <ForeignDocument as DatabaseEntry>::TYPE.into() },
|
||
];
|
||
for refusal in &refusals {
|
||
let json = serde_json::to_value(refusal)?;
|
||
assert_eq!(&serde_json::from_value::<PipelineRefusal>(json.clone())?, refusal, "{json} reads back");
|
||
let packed = rmp_serde::to_vec_named(refusal)?;
|
||
assert_eq!(
|
||
&rmp_serde::from_slice::<PipelineRefusal>(&packed)?,
|
||
refusal,
|
||
"{refusal:?} reads back through MessagePack"
|
||
);
|
||
}
|
||
|
||
// The derived schemas list the same two sets. A `const` in the tag
|
||
// position is what a schema reader dispatches on, so it is read as the
|
||
// schema writes it and compared to `name()`, not to a literal list.
|
||
let tag = |variant: &serde_json::Value| {
|
||
variant["properties"]["kind"]["const"]
|
||
.as_str()
|
||
.unwrap_or_else(|| panic!("a document variant schema carries a kind const: {variant}"))
|
||
.to_owned()
|
||
};
|
||
let document_schema = serde_json::to_value(schemars::schema_for!(PipelineDocument))?;
|
||
let variants = document_schema["oneOf"].as_array().expect("the document schema is a oneOf");
|
||
assert_eq!(PipelineKind::ALL.len(), 21, "twenty-one kinds");
|
||
assert_eq!(
|
||
variants.iter().map(tag).collect::<Vec<_>>(),
|
||
PipelineKind::ALL.iter().map(|kind| kind.name().to_owned()).collect::<Vec<_>>(),
|
||
"the document schema lists exactly the twenty-one kinds, by name"
|
||
);
|
||
|
||
let refusal_schema = serde_json::to_value(schemars::schema_for!(PipelineRefusal))?;
|
||
let refusal_variants = refusal_schema["oneOf"].as_array().expect("the refusal schema is a oneOf");
|
||
let named = refusal_variants
|
||
.iter()
|
||
.map(|variant| {
|
||
let properties = variant["properties"].as_object().expect("a refusal variant is an object");
|
||
assert_eq!(properties.len(), 1, "{variant} is externally tagged");
|
||
properties.keys().next().expect("one key").to_owned()
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(named, vec!["FieldBound", "InvalidFormat", "InvalidIdentity", "DuplicateRepo", "ForeignStore"]);
|
||
assert_eq!(named.len(), refusals.len(), "every refusal variant is in the schema and in the round trip");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn pipeline_published_schemas_match_derivation() -> Result<()> {
|
||
let published = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas/cultnet");
|
||
let index: serde_json::Value = serde_json::from_slice(&std::fs::read(published.join("index.json"))?)?;
|
||
// A directory this run alone writes, so everything in it is what this
|
||
// run derived. A previous run's leftovers -- a mutation run's
|
||
// especially, since those deliberately derive schemas no committed type
|
||
// produces -- are not this derivation, and copying one of those into
|
||
// `schemas/cultnet` publishes a schema no Rust type produces. A shared
|
||
// directory cleared first would have to defend that clear against a
|
||
// held handle; an unshared one has nothing to defend. The cost is that
|
||
// nothing ever removes one. The directory is only created when
|
||
// something is stale, so a passing run leaves none -- but a failing run
|
||
// leaves its copies behind for good, one directory per failing run
|
||
// where the shared design left one however often it failed. That is the
|
||
// trade: the copies are the evidence the failure message names, and
|
||
// they outlive the run that wrote them. Whoever reads the failure is
|
||
// the one who deletes them.
|
||
let derived_dir = std::env::temp_dir().join(format!(
|
||
"eureka-pipeline-schemas-{}-{}",
|
||
std::process::id(),
|
||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos()
|
||
));
|
||
let mut stale = Vec::new();
|
||
for kind in PipelineKind::ALL {
|
||
let file = format!("{}.schema.json", kind.type_id());
|
||
let mut schema = serde_json::to_value(kind.derived_schema())?;
|
||
schema["$id"] = format!("https://gamecult.dev/epiphany/cultnet/{file}").into();
|
||
let derived = format!("{}\n", serde_json::to_string_pretty(&schema)?);
|
||
if std::fs::read(published.join(&file)).ok().as_deref() != Some(derived.as_bytes()) {
|
||
std::fs::create_dir_all(&derived_dir)?;
|
||
std::fs::write(derived_dir.join(&file), &derived)?;
|
||
stale.push(derived_dir.join(&file));
|
||
}
|
||
let entry = serde_json::json!({
|
||
"schemaId": format!("https://gamecult.dev/epiphany/cultnet/{file}"),
|
||
"kind": "document_payload",
|
||
"wireContracts": ["cultnet.schema.v0"],
|
||
"schemaVersion": kind.type_id(),
|
||
"documentType": kind.type_id(),
|
||
"title": format!("Epiphany Pipeline {kind:?} v2"),
|
||
"path": file,
|
||
});
|
||
assert!(
|
||
index["schemas"].as_array().is_some_and(|schemas| schemas.contains(&entry)),
|
||
"index.json lacks {entry}"
|
||
);
|
||
}
|
||
assert!(stale.is_empty(), "published pipeline schemas differ from the Rust derivation; derived copies: {stale:?}");
|
||
Ok(())
|
||
}
|
||
|
||
/// A refusal read as text names its variant and its parts, so an operator
|
||
/// who sees only the message (a log line, an `anyhow` chain) can tell which
|
||
/// field was refused and why.
|
||
#[test]
|
||
fn a_refusal_read_as_text_names_its_variant_and_parts() {
|
||
let text = PipelineRefusal::FieldBound { field: "title".into(), limit: 200, actual: 201 }.to_string();
|
||
assert!(text.starts_with("pipeline refusal: "), "{text}");
|
||
for part in ["FieldBound", "title", "200", "201"] {
|
||
assert!(text.contains(part), "{part} is missing from {text}");
|
||
}
|
||
}
|
||
|
||
/// The catalogue is exactly the files beside it: every entry's `path` is a
|
||
/// schema file in the directory, and every schema file in the directory
|
||
/// has an entry, so a schema removed from the catalogue (or a file left
|
||
/// behind by a move) fails here instead of publishing a dangling or
|
||
/// unlisted contract. The one rule: every `*.schema.json` in the directory,
|
||
/// Huginn's own wire schemas included, is listed.
|
||
#[test]
|
||
fn the_catalogue_lists_exactly_the_schema_files_it_publishes() -> Result<()> {
|
||
let published = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schemas/cultnet");
|
||
let index: serde_json::Value = serde_json::from_slice(&std::fs::read(published.join("index.json"))?)?;
|
||
let listed = index["schemas"]
|
||
.as_array()
|
||
.expect("index.json carries a schemas array")
|
||
.iter()
|
||
.map(|entry| entry["path"].as_str().expect("every entry names its file").to_owned())
|
||
.collect::<std::collections::BTreeSet<_>>();
|
||
for path in &listed {
|
||
assert!(published.join(path).is_file(), "index.json lists {path}, which is not in the directory");
|
||
}
|
||
let mut files = std::collections::BTreeSet::new();
|
||
for entry in std::fs::read_dir(&published)? {
|
||
let name = entry?.file_name().to_string_lossy().into_owned();
|
||
if name.ends_with(".schema.json") {
|
||
files.insert(name);
|
||
}
|
||
}
|
||
assert_eq!(listed, files, "the catalogue and the schema files beside it disagree");
|
||
Ok(())
|
||
}
|
||
|
||
/// The epoch is the version every kind is published at: its version
|
||
/// segment is read from the constant, not restated, and every type id
|
||
/// carries the same one, so a bumped epoch fails here until the kinds
|
||
/// move with it. The live registrar registers the kinds and nothing else,
|
||
/// so the foreign stand-in is not among them.
|
||
#[test]
|
||
fn every_kind_is_at_the_epochs_version() -> Result<()> {
|
||
let (name, version) = PIPELINE_SCHEMA_EPOCH.rsplit_once('.').expect("the epoch carries a version");
|
||
assert_eq!(name, "epiphany.pipeline.epoch");
|
||
assert!(
|
||
version.len() > 1 && version.starts_with('v') && version[1..].bytes().all(|byte| byte.is_ascii_digit()),
|
||
"{version:?} is not a version segment"
|
||
);
|
||
for kind in PipelineKind::ALL {
|
||
let type_id = kind.type_id();
|
||
assert!(type_id.ends_with(&format!(".{version}")), "{type_id} is not at the epoch's version {version}");
|
||
}
|
||
|
||
let mut cache = CultCache::new();
|
||
register_pipeline_document_types(&mut cache)?;
|
||
let registered = cache.registered_entry_types();
|
||
assert_eq!(registered.len(), PipelineKind::ALL.len(), "the live registrar registers exactly the kinds: {registered:?}");
|
||
for kind in PipelineKind::ALL {
|
||
assert!(registered.iter().any(|name| name == kind.type_id()), "{kind:?} is registered");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The public door onto the reference grammar, for the read side that
|
||
/// holds a `kind` and an `id` and no way to reach `Bounded`. Every sample
|
||
/// document's own key is a reference of its own kind; a kind that is not
|
||
/// the id's, and a local no writer composes, are `InvalidFormat` at `ref.id`.
|
||
#[test]
|
||
fn a_ref_validates_as_an_id_of_the_kind_it_declares() -> Result<()> {
|
||
for (document, expected) in samples() {
|
||
let kind = document.kind();
|
||
let key = pipeline_key(&document)?;
|
||
assert_eq!(key, expected);
|
||
assert_eq!(
|
||
PipelineRef { kind, id: s(&key) }.validate_ref(),
|
||
Ok(()),
|
||
"{kind:?}'s own key is not a reference of its kind"
|
||
);
|
||
}
|
||
|
||
let refused = |reference: PipelineRef, why: &str| {
|
||
let read = reference.validate_ref();
|
||
assert!(
|
||
matches!(&read, Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "ref.id"),
|
||
"{why}: {reference:?} was not refused, got {read:?}"
|
||
);
|
||
};
|
||
|
||
// The kind and the id's kind segment cannot disagree: the whole reason
|
||
// the door exists, since a reader that cannot ask would look this up
|
||
// and report the nothing it found.
|
||
refused(
|
||
PipelineRef { kind: PipelineKind::Campaign, id: id("question", "Q1") },
|
||
"a question id is not a campaign reference",
|
||
);
|
||
refused(
|
||
PipelineRef { kind: PipelineKind::Ruling, id: id("question", "Q1") },
|
||
"a question id is not a ruling reference",
|
||
);
|
||
// The local is `dotted_text` whole, so a trailing dot is refused by the
|
||
// reader and not only by the writer that never composes one.
|
||
refused(
|
||
PipelineRef { kind: PipelineKind::Question, id: s(&format!("{CAMPAIGN}:question:Q1.")) },
|
||
"a trailing dot is no local",
|
||
);
|
||
|
||
// The boundary, pinned rather than assumed: a resolution id missing the
|
||
// per-subject sequence its writer always composes is still a
|
||
// well-formed id of its kind here. The leaf's grammar is kind-deep, and
|
||
// the local's per-kind shape belongs to admission; refusing this would
|
||
// be a new rule in `pipeline_id`, not a door onto the one that exists.
|
||
assert_eq!(
|
||
PipelineRef { kind: PipelineKind::Resolution, id: s(&format!("{CAMPAIGN}:resolution:question.Q1")) }
|
||
.validate_ref(),
|
||
Ok(()),
|
||
"the leaf reads a resolution id kind-deep"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// The public door onto the slug grammar, for a caller outside this crate
|
||
/// holding a bare declared name (Huginn's `require_instance` and
|
||
/// `Mind::open`, before either reaches admission or the filesystem). It
|
||
/// applies exactly `dotted_text`: one valid dotted name, and refusals for
|
||
/// fullwidth characters, `..`, both path separators, the empty string,
|
||
/// and an empty label.
|
||
#[test]
|
||
fn slug_validate_applies_the_dotted_grammar() {
|
||
assert_eq!(slug("huginn-yggdrasil").validate_slug(), Ok(()));
|
||
assert_eq!(slug("outer.inner").validate_slug(), Ok(()));
|
||
|
||
let refused = |value: &str, why: &str| {
|
||
let result = slug(value).validate_slug();
|
||
assert!(
|
||
matches!(&result, Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "slug"),
|
||
"{why}: {value:?} was not refused, got {result:?}"
|
||
);
|
||
};
|
||
|
||
refused("\u{FF41}\u{FF41}", "fullwidth characters are not ascii alphanumeric");
|
||
refused("..", "a bare .. is an empty label either side of the dot");
|
||
refused("../escaped", "a forward-slash path separator is not a label byte");
|
||
refused("..\\escaped", "a backslash path separator is not a label byte");
|
||
refused("outer\\inner", "a lone backslash inside one label is not a label byte, with no empty label to hide behind");
|
||
refused("", "the empty string has no label");
|
||
refused("outer..inner", "an empty label between two dots is refused");
|
||
refused(".", "a single dot is one empty label");
|
||
|
||
// S5: the 64-byte whole-name bound, pinned with a two-label name
|
||
// whose parts sit far under 64 bytes each (31 and 32, then 32 and
|
||
// 32), so only `dotted_text`'s own `value.len() > 64` check -- not
|
||
// `label_text`'s per-part one -- can be the check that refuses 65.
|
||
let filler = |n: usize| "a".repeat(n);
|
||
let at_64 = format!("{}.{}", filler(31), filler(32));
|
||
let at_65 = format!("{}.{}", filler(32), filler(32));
|
||
assert_eq!(at_64.len(), 64, "a 64-byte whole name, two labels well under 64 each");
|
||
assert_eq!(at_65.len(), 65, "one byte past the whole-name bound, same two-label shape");
|
||
assert_eq!(slug(&at_64).validate_slug(), Ok(()));
|
||
refused(&at_65, "65 bytes overall is refused, even though neither label alone reaches 64");
|
||
|
||
// S5: the 64-byte per-label bound, pinned directly against `Label`
|
||
// (the type `label_text` guards on its own, with no separate
|
||
// whole-name wrapper), and with a lone label carrying no dot, so only
|
||
// `label_text`'s own length check can be the one refusing 65.
|
||
let label_at_64 = Label::from(filler(64).as_str());
|
||
let label_at_65 = Label::from(filler(65).as_str());
|
||
assert_eq!(label_at_64.validate("label"), Ok(()));
|
||
assert!(
|
||
matches!(
|
||
label_at_65.validate("label"),
|
||
Err(PipelineRefusal::InvalidFormat { ref field, .. }) if field == "label"
|
||
),
|
||
"a 65-byte label was not refused, got {:?}",
|
||
label_at_65.validate("label")
|
||
);
|
||
|
||
// S5: NUL is refused as a label byte, not merely as one more
|
||
// non-alphanumeric character that happens to be caught by coincidence.
|
||
refused("a\u{0}b", "NUL is not a label byte");
|
||
}
|
||
|
||
/// The two doors added beside `Slug::validate_slug` (RS-L), on its
|
||
/// pattern: each delegates to the grammar its own document field already
|
||
/// carries, so a declared name checked through the door and a field of
|
||
/// the same type refuse the same inputs. `OrgRepo` is the `repo` alias's
|
||
/// domain and `Label` is the `cut` alias's (RS-3's vocabulary table).
|
||
#[test]
|
||
fn the_org_repo_and_label_doors_are_the_grammar() {
|
||
assert_eq!(OrgRepo::from("GameCult/Epiphany").validate_org_repo(), Ok(()));
|
||
assert_eq!(OrgRepo::from("a/b").validate_org_repo(), Ok(()));
|
||
assert_eq!(OrgRepo::from("a-b/c.d_e").validate_org_repo(), Ok(()));
|
||
// Consecutive hyphens stay allowed on both sides; only a leading or
|
||
// trailing owner hyphen is refused.
|
||
assert_eq!(OrgRepo::from("a--b/c--d").validate_org_repo(), Ok(()));
|
||
// S4: a 39-byte owner is accepted, a 100-byte repo is accepted.
|
||
let owner_at_39 = format!("{}/b", "a".repeat(39));
|
||
assert_eq!(OrgRepo::from(owner_at_39.as_str()).validate_org_repo(), Ok(()));
|
||
let repo_at_100 = format!("a/{}", "a".repeat(100));
|
||
assert_eq!(OrgRepo::from(repo_at_100.as_str()).validate_org_repo(), Ok(()));
|
||
let repo_refused = |value: &str, why: &str| {
|
||
let result = OrgRepo::from(value).validate_org_repo();
|
||
assert!(
|
||
matches!(&result, Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "org_repo"),
|
||
"{why}: {value:?} was not refused, got {result:?}"
|
||
);
|
||
};
|
||
repo_refused("GameCult", "no slash at all");
|
||
repo_refused("/Repo", "an empty org before the slash");
|
||
repo_refused("GameCult/", "an empty repo after the slash");
|
||
repo_refused("a/b/c", "a second slash makes the repo half ambiguous");
|
||
repo_refused("../..", "an owner of dots is not GitHub's grammar");
|
||
repo_refused("a/..", "a repo of .. is refused outright");
|
||
repo_refused("a/.", "a repo of . is refused outright");
|
||
repo_refused("-a/b", "a leading hyphen is not a valid owner");
|
||
repo_refused("a-/b", "a trailing hyphen is not a valid owner");
|
||
repo_refused("a b/c", "a space is not an owner byte");
|
||
repo_refused("a/b c", "a space is not a repo byte");
|
||
repo_refused("\u{e9}/\u{fc}", "non-ascii bytes are refused on either side");
|
||
repo_refused("\0/\0", "NUL is refused on either side");
|
||
repo_refused("a\n/b", "a newline is not an owner byte");
|
||
let owner_at_40 = format!("{}/b", "a".repeat(40));
|
||
repo_refused(&owner_at_40, "40 bytes is past the 39-byte owner bound");
|
||
let repo_at_101 = format!("a/{}", "a".repeat(101));
|
||
repo_refused(&repo_at_101, "101 bytes is past the 100-byte repo bound");
|
||
repo_refused("a//b", "an empty middle segment is still a second slash");
|
||
repo_refused("/b", "an empty owner is refused");
|
||
repo_refused("a/", "an empty repo is refused");
|
||
repo_refused("a.b/c", "a dot is not an owner byte");
|
||
repo_refused("a_b/c", "an underscore is not an owner byte");
|
||
// F7 fix batch: a repo name ending in `.git` is refused.
|
||
repo_refused("a/b.git", "a repo ending in .git is refused");
|
||
repo_refused("a/.git", "a repo of exactly .git is still a .git suffix");
|
||
// S-2: the `.git` refusal is ASCII-case-insensitive, so a case variant
|
||
// cannot dodge it and reach an `identity()` the validator refuses.
|
||
repo_refused("a/b.GIT", "a repo ending in .GIT is refused like .git");
|
||
repo_refused("a/.GIT", "a repo of exactly .GIT is still a .git suffix");
|
||
repo_refused("A/B.GIT", "a mixed-case owner and a .GIT repo both refuse");
|
||
repo_refused("a/b.GiT", "a mixed-case .git suffix is refused");
|
||
|
||
// identity(): the one canonical key, ASCII-lowercased. Every
|
||
// comparison, hash, ordering and key derivation goes through it.
|
||
assert_eq!(
|
||
OrgRepo::from("GameCult/Epiphany").identity(),
|
||
OrgRepo::from("gamecult/epiphany").identity()
|
||
);
|
||
assert_eq!(OrgRepo::from("GameCult/Epiphany").identity(), "gamecult/epiphany");
|
||
|
||
// S-1: it is structural, not advisory. `==` on `OrgRepo` compares
|
||
// identity, not the raw string, so the two spellings are one
|
||
// repository wherever `OrgRepo` is compared, hashed or ordered.
|
||
assert_eq!(OrgRepo::from("GameCult/Epiphany"), OrgRepo::from("gamecult/epiphany"));
|
||
assert_eq!(OrgRepo::from("GameCult/Epiphany").cmp(&OrgRepo::from("gamecult/epiphany")), std::cmp::Ordering::Equal);
|
||
assert_eq!(
|
||
OrgRepo::from("GameCult/Epiphany").partial_cmp(&OrgRepo::from("gamecult/epiphany")),
|
||
Some(OrgRepo::from("GameCult/Epiphany").cmp(&OrgRepo::from("gamecult/epiphany"))),
|
||
"partial_cmp must agree with cmp for an equal pair"
|
||
);
|
||
|
||
// RS-L closing fix 1: every assertion above compares values that
|
||
// should be equal, so `eq -> true`, `hash -> ()` and `cmp -> Equal`
|
||
// all survive as mutants. Pin the unequal direction as well: two
|
||
// different repos must be unequal, hash differently (with
|
||
// overwhelming probability, not by construction, so this is belt and
|
||
// suspenders on top of the `!=` and `cmp` checks), and not compare
|
||
// `Equal`, and `partial_cmp` must agree with `cmp` for an unequal
|
||
// pair too, per std's own contract that `partial_cmp(a, b) ==
|
||
// Some(cmp(a, b))`.
|
||
let a = OrgRepo::from("GameCult/Epiphany");
|
||
let b = OrgRepo::from("GameCult/Aetheria");
|
||
assert_ne!(a, b, "two different repos must not compare equal");
|
||
assert_ne!(
|
||
a.cmp(&b),
|
||
std::cmp::Ordering::Equal,
|
||
"two different repos must not order as Equal"
|
||
);
|
||
assert_eq!(
|
||
a.partial_cmp(&b),
|
||
Some(a.cmp(&b)),
|
||
"partial_cmp must agree with cmp for an unequal pair"
|
||
);
|
||
let hash_of = |repo: &OrgRepo| {
|
||
use std::hash::{Hash, Hasher};
|
||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||
repo.hash(&mut hasher);
|
||
hasher.finish()
|
||
};
|
||
assert_ne!(
|
||
hash_of(&a),
|
||
hash_of(&b),
|
||
"two different repos must hash differently"
|
||
);
|
||
|
||
let mut hash_map = std::collections::HashMap::new();
|
||
hash_map.insert(OrgRepo::from("GameCult/Epiphany"), 1);
|
||
hash_map.insert(OrgRepo::from("gamecult/epiphany"), 2);
|
||
assert_eq!(hash_map.len(), 1, "a HashMap keyed by both spellings holds one entry");
|
||
assert_eq!(hash_map.get(&OrgRepo::from("GAMECULT/EPIPHANY")), Some(&2));
|
||
|
||
let mut btree_map = std::collections::BTreeMap::new();
|
||
btree_map.insert(OrgRepo::from("GameCult/Epiphany"), 1);
|
||
btree_map.insert(OrgRepo::from("gamecult/epiphany"), 2);
|
||
assert_eq!(btree_map.len(), 1, "a BTreeMap keyed by both spellings holds one entry");
|
||
assert_eq!(btree_map.get(&OrgRepo::from("GAMECULT/EPIPHANY")), Some(&2));
|
||
|
||
// `identity()` is always itself a valid `OrgRepo`: lowercasing never
|
||
// turns a valid repository into one the grammar refuses, and never
|
||
// turns a refused one into one it accepts.
|
||
for value in ["GameCult/Epiphany", "A/B", "a-B/c.D_e", "GameCult/Epiphany.GIT"] {
|
||
let repo = OrgRepo::from(value);
|
||
assert_eq!(
|
||
repo.validate_org_repo().is_ok(),
|
||
OrgRepo::from(repo.identity().as_str()).validate_org_repo().is_ok(),
|
||
"identity() of {value:?} disagreed with the value's own validity"
|
||
);
|
||
}
|
||
|
||
assert_eq!(Label::from("cut-10").validate_label(), Ok(()));
|
||
let label_refused = |value: &str, why: &str| {
|
||
let result = Label::from(value).validate_label();
|
||
assert!(
|
||
matches!(&result, Err(PipelineRefusal::InvalidFormat { field, .. }) if field == "label"),
|
||
"{why}: {value:?} was not refused, got {result:?}"
|
||
);
|
||
};
|
||
label_refused("", "the empty string has no label");
|
||
label_refused("a.b", "a dot is not a label byte");
|
||
label_refused(&"a".repeat(65), "65 bytes is past the 64-byte label bound");
|
||
label_refused("\u{e9}", "a non-ascii byte is not a label byte");
|
||
}
|
||
|
||
/// Q-RS1, ruled B: `question` and `ruling` gain a required `title`, the
|
||
/// new `Title` type shared with the two titles that move off `Short`
|
||
/// (campaign, cut spec). Each kind round-trips through `prepare`/`decode`
|
||
/// with its title intact, an empty title is refused for both, and the
|
||
/// bound is pinned at its edge: 200 bytes accepted, 201 refused (L3 dies
|
||
/// on the removal of the non-empty check, not on the length bound, since
|
||
/// `Title`'s length bound is shared with every other `bounded_text!`
|
||
/// member and already covered by `bounds_refuse_in_utf8_bytes`).
|
||
#[test]
|
||
fn question_and_ruling_carry_a_title() -> Result<()> {
|
||
let cache = schema_cache()?;
|
||
|
||
let mut question = question_sample();
|
||
question.title = t("Who owns the state?");
|
||
let envelope = PipelineDocument::Question(question.clone()).prepare(&cache)?;
|
||
assert_eq!(PipelineDocument::decode(&envelope)?, PipelineDocument::Question(question.clone()));
|
||
|
||
let mut ruling = ruling_sample();
|
||
ruling.title = t("An instance owns its mind");
|
||
let envelope = PipelineDocument::Ruling(ruling.clone()).prepare(&cache)?;
|
||
assert_eq!(PipelineDocument::decode(&envelope)?, PipelineDocument::Ruling(ruling.clone()));
|
||
|
||
question.title = Title(String::new());
|
||
assert_eq!(
|
||
PipelineDocument::Question(question.clone()).validate(),
|
||
Err(PipelineRefusal::InvalidFormat { field: "question.title".into(), value: String::new() })
|
||
);
|
||
ruling.title = Title(String::new());
|
||
assert_eq!(
|
||
PipelineDocument::Ruling(ruling.clone()).validate(),
|
||
Err(PipelineRefusal::InvalidFormat { field: "ruling.title".into(), value: String::new() })
|
||
);
|
||
|
||
// F2 second fix batch: a title holds at least one
|
||
// `char::is_alphanumeric()` character, no `char::is_control()`
|
||
// character, and no bidi control character. No zero-width denylist:
|
||
// a zero-width joiner/non-joiner inside real text is allowed, and a
|
||
// string of nothing but invisible characters is refused because it
|
||
// has no alphanumeric character, not because those characters are
|
||
// individually denylisted.
|
||
let refused = |value: &str| {
|
||
let mut refused_question = question.clone();
|
||
refused_question.title = Title(value.into());
|
||
assert_eq!(
|
||
PipelineDocument::Question(refused_question).validate(),
|
||
Err(PipelineRefusal::InvalidFormat { field: "question.title".into(), value: value.into() }),
|
||
"{value:?} should be refused"
|
||
);
|
||
};
|
||
refused(" ");
|
||
refused("\u{7f}");
|
||
refused("a\u{7f}");
|
||
refused("\u{0}");
|
||
refused("\u{9f}");
|
||
refused("\u{2028}");
|
||
refused("\u{202e}"); // RLO alone
|
||
refused("\u{202e}text"); // RLO plus text
|
||
refused("\u{200e}"); // LRM alone
|
||
refused("\u{0301}"); // a lone combining mark
|
||
refused("\u{fe0f}"); // a variation selector alone
|
||
refused("\u{a0}"); // NBSP alone
|
||
refused("\u{1f680}"); // emoji only
|
||
|
||
// S-3: every one of the twelve bidi control code points is pinned
|
||
// with an embedded fixture (real alphanumeric text on both sides), so
|
||
// the alphanumeric rule cannot mask a hole in the bidi match arm the
|
||
// way a lone-character fixture would.
|
||
refused("a\u{061C}b"); // ALM
|
||
refused("a\u{200E}b"); // LRM
|
||
refused("a\u{200F}b"); // RLM
|
||
refused("a\u{202A}b"); // LRE
|
||
refused("a\u{202B}b"); // RLE
|
||
refused("a\u{202C}b"); // PDF
|
||
refused("a\u{202D}b"); // LRO
|
||
refused("a\u{202E}b"); // RLO
|
||
refused("a\u{2066}b"); // LRI
|
||
refused("a\u{2067}b"); // RLI
|
||
refused("a\u{2068}b"); // FSI
|
||
refused("a\u{2069}b"); // PDI
|
||
|
||
// S-4: U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are
|
||
// refused inside otherwise-alphanumeric text, not only alone, since
|
||
// `char::is_control()` does not reach `Zl`/`Zp`.
|
||
refused("a\u{2028}b");
|
||
refused("a\u{2029}b");
|
||
|
||
let accepted = |value: &str| {
|
||
let mut accepted_question = question.clone();
|
||
accepted_question.title = Title(value.into());
|
||
assert_eq!(
|
||
PipelineDocument::Question(accepted_question).validate(),
|
||
Ok(()),
|
||
"{value:?} should validate"
|
||
);
|
||
};
|
||
accepted("a");
|
||
accepted("\u{e9}");
|
||
accepted("\u{200c}\u{647}\u{6cc}"); // Persian text containing ZWNJ
|
||
accepted("a\u{200d}b");
|
||
accepted("a \u{1f680}");
|
||
accepted(" a ");
|
||
// Discrepancy from the fix spec: HANGUL FILLER (U+3164) is Unicode
|
||
// General_Category Lo, so `char::is_alphanumeric()` is true for it in
|
||
// this toolchain; it is not a denylist candidate, since the fix
|
||
// batch deletes the hand-written exclusion list on purpose. A title
|
||
// of U+3164 alone is therefore accepted, not refused.
|
||
accepted("\u{3164}");
|
||
|
||
question.title = Title("a".repeat(200));
|
||
assert_eq!(PipelineDocument::Question(question.clone()).validate(), Ok(()));
|
||
question.title = Title("a".repeat(201));
|
||
assert_eq!(
|
||
PipelineDocument::Question(question).validate(),
|
||
Err(PipelineRefusal::FieldBound { field: "question.title".into(), limit: 200, actual: 201 })
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
fn desk_answer_sample() -> PipelineDeskAnswer {
|
||
let PipelineDocument::DeskAnswer(answer) = samples().pop().unwrap().0 else { unreachable!() };
|
||
answer
|
||
}
|
||
|
||
#[test]
|
||
fn a_desk_answer_is_keyed_under_its_question_and_sequence() {
|
||
let mut answer = desk_answer_sample();
|
||
let key = |answer: &PipelineDeskAnswer| pipeline_key(&PipelineDocument::DeskAnswer(answer.clone()));
|
||
assert_eq!(key(&answer), Ok(format!("{CAMPAIGN}:desk_answer:Q1.n1")));
|
||
// Every 64-byte question label is answerable at the largest sequence.
|
||
let label = "q".repeat(64);
|
||
answer.fork = id("question", &label);
|
||
answer.sequence = u32::MAX;
|
||
answer.outcome = DeskOutcome::Counted;
|
||
assert_eq!(key(&answer), Ok(format!("{CAMPAIGN}:desk_answer:{label}.n4294967295")));
|
||
// The fork must be a question of the same campaign.
|
||
answer.fork = Short(format!("other:question:{label}"));
|
||
assert!(matches!(key(&answer), Err(PipelineRefusal::InvalidFormat { .. })));
|
||
answer.fork = id("ruling", "Q1");
|
||
assert!(matches!(key(&answer), Err(PipelineRefusal::InvalidFormat { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn a_desk_outcome_names_desk_answers_by_id() {
|
||
let mut answer = desk_answer_sample();
|
||
for bad in [id("question", "Q1"), Short("n1".into())] {
|
||
answer.outcome = DeskOutcome::Retraction { retracts: bad.clone() };
|
||
assert!(matches!(PipelineDocument::DeskAnswer(answer.clone()).validate(), Err(PipelineRefusal::InvalidFormat { .. })), "{bad:?}");
|
||
answer.outcome = DeskOutcome::Revision { of: bad.clone() };
|
||
assert!(matches!(PipelineDocument::DeskAnswer(answer.clone()).validate(), Err(PipelineRefusal::InvalidFormat { .. })), "{bad:?}");
|
||
}
|
||
answer.outcome = DeskOutcome::Retraction { retracts: id("desk_answer", "Q1.n2") };
|
||
assert_eq!(PipelineDocument::DeskAnswer(answer).validate(), Ok(()));
|
||
}
|
||
|
||
#[test]
|
||
fn a_refused_target_is_bounded() {
|
||
let mut answer = desk_answer_sample();
|
||
answer.outcome = DeskOutcome::Refused { code: DeskRefusal::BadRetraction, retracts: Some(Short("x".repeat(200))) };
|
||
assert_eq!(PipelineDocument::DeskAnswer(answer.clone()).validate(), Ok(()));
|
||
answer.outcome = DeskOutcome::Refused { code: DeskRefusal::BadRetraction, retracts: Some(Short("x".repeat(201))) };
|
||
assert_eq!(
|
||
PipelineDocument::DeskAnswer(answer).validate(),
|
||
Err(PipelineRefusal::FieldBound { field: "desk_answer.outcome.retracts".into(), limit: 200, actual: 201 })
|
||
);
|
||
}
|
||
}
|