Trusty: provenance library workspace (initial import)

This commit is contained in:
2026-09-09 18:31:00 -04:00
commit 693f3bef1e
73 changed files with 26295 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
[package]
name = "trusty-policy-conditions"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "Lets a declared policy FSM act as a trusty break Condition, so the rules for how data may legally change are authored as data and a deviation records which rule broke."
[dependencies]
trusty-core = { workspace = true }
trusty-policy = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
trusty-sqlite = { workspace = true }

View File

@@ -0,0 +1,528 @@
//! Rules for how data may legally change, authored as data.
//!
//! # The idea
//!
//! Declare the transitions a datum is allowed to make. A compliant
//! mutation is valid and the chain continues. A deviation records a
//! **break naming the rule it violated**, and that break is signed,
//! travels in an exported bundle, and can be acted on by anyone.
//!
//! Almost all of this already existed. [`Condition`] evaluates state at
//! append time, [`BreakReason::PropertyViolated`] already carries a
//! rule's name, and `export_bundle` always includes BREAK rows.
//! Chain-break-as-event *was* this design. The one thing missing was
//! that the rules were compiled in: [`Condition`] is a Rust trait, so a
//! new rule meant a new build.
//!
//! The policy side had already solved that -- [`FsmSpec`] authored in
//! TOML, guards named as strings, and [`GuardRegistry`] refusing to
//! build on an unknown name. This crate is the join.
//!
//! # Guards see the payload
//!
//! The FSM is built over [`ConditionContext`], so a guard receives the
//! **data being appended**, not just chain metadata. A rule can
//! therefore be a real predicate over the value -- a threshold, a shape
//! check, a comparison against the previous row -- rather than only a
//! statement about the shape of the graph.
//!
//! # The envelope
//!
//! A row states the transition it is making, under `data.transition`:
//!
//! ```json
//! { "transition": { "from": "draft", "action": "submit", "to": "review" } }
//! ```
//!
//! The condition checks that transition is declared, that any guard on
//! it passes, and that the stated destination is the one the FSM says it
//! should be. That last check matters: without it a writer could name a
//! legal transition and then record an arbitrary destination.
//!
//! # Rows that claim no transition
//!
//! By default they pass -- this condition judges rows that claim to be
//! transitions, and a chain usually carries other traffic too.
//! [`FsmCondition::strict`] reverses that, so an unlabelled row is
//! itself a violation.
//!
//! Which is right depends on whether the chain is dedicated to the
//! governed process. Getting it wrong in the lenient direction leaves a
//! hole a writer can walk through by omitting a field, so **strict is
//! the right default for a chain that governs one thing**, and the
//! lenient default exists only because most chains do not.
//!
//! # The marker crosses the boundary
//!
//! A break's reason travels in the exported bundle, so a party holding
//! only that bundle -- and no secret key, and no access to the machine
//! that recorded it -- can ask `ProofBundle::stance()` and get one of
//! three answers: clean, violated (with the rule named), or
//! unverifiable. That third answer is the point: a bundle that cannot be
//! checked must not read as clean.
//!
//! # One limitation, stated rather than discovered
//!
//! **A rule cannot be violated by the first row of a chain.**
//! Break-as-event seals an existing chain, and at genesis there is
//! nothing to seal, so the opening row is recorded whatever it claims.
//! If the opening state must also be governed, check it before the
//! first append -- the chain will not do it for you.
//!
//! # Why this crate and not another
//!
//! [`trusty_policy`] is deliberately substrate-free and does not depend
//! on [`trusty_core`]. Putting this in either would give one of them a
//! dependency it was designed not to have -- the same split, for the
//! same reason, as `trusty-policy-audit`.
#![deny(clippy::all)]
use serde_json::Value;
use trusty_core::conditions::{BreakReason, Condition, ConditionContext, ConditionOutcome};
use sha2::{Digest, Sha256};
use trusty_policy::{Fsm, FsmSpec, GuardRegistry, PolicyError};
/// Where the transition envelope lives inside `data`.
pub const TRANSITION_FIELD: &str = "transition";
/// A declared state machine, acting as a break condition.
pub struct FsmCondition {
fsm: Fsm<String, String, ConditionContext>,
rule: String,
/// The rule set's content hash, short form, when it was built from a
/// spec. Stamped into every break so a violation names the rule AND
/// the version of it that produced the verdict.
rule_hash: Option<String>,
strict: bool,
}
impl FsmCondition {
/// Build from an already-constructed FSM.
///
/// `rule` is the name that appears in the recorded break. Make it
/// something an operator reading a chain six months later can look
/// up -- the whole point of `PropertyViolated` carrying a name is
/// that the break says *which* invariant broke.
pub fn new(fsm: Fsm<String, String, ConditionContext>, rule: impl Into<String>) -> Self {
Self {
fsm,
rule: rule.into(),
rule_hash: None,
strict: false,
}
}
/// The content hash of a rule set, hex, first 16 characters.
///
/// Truncated because it travels inside every break reason and a full
/// digest would crowd out the part a human reads. 64 bits is ample
/// for telling two rule sets apart; it is not a security boundary,
/// since the full canonical form is what gets signed.
pub fn hash_of(spec: &FsmSpec) -> Result<String, PolicyError> {
let canonical = spec.canonical_json()?;
Ok(hex_short(&Sha256::digest(canonical.as_bytes())))
}
/// The rule set version this condition enforces, if it was built
/// from a spec.
pub fn rule_hash(&self) -> Option<&str> {
self.rule_hash.as_deref()
}
/// Build from a TOML spec, with guards resolved by name.
///
/// A transition naming a guard the registry does not hold **refuses
/// to build**. That refusal is the feature: a policy that silently
/// runs without its guard is a policy that is not being enforced,
/// and it would look identical to one that is.
pub fn from_json(
json: &str,
registry: &mut GuardRegistry<ConditionContext>,
rule: impl Into<String>,
) -> Result<Self, PolicyError> {
let spec = FsmSpec::from_json(json)?;
let rule_hash = Self::hash_of(&spec)?;
let (_scope, fsm) = spec.build_fsm_with_guards(registry)?;
let mut built = Self::new(fsm, rule);
built.rule_hash = Some(rule_hash);
Ok(built)
}
/// Treat a row that claims no transition as a violation.
///
/// Correct when the chain is dedicated to the governed process.
/// Without it, omitting the envelope is a way around the rule.
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
fn violated(&self, detail: &str) -> ConditionOutcome {
// The rule set version rides along, so a reader of the break can
// tell WHICH `doc.review` produced it. Without it a rule could be
// loosened after the fact and the old marker would still verify
// against a rule that no longer says what it said.
let name = match &self.rule_hash {
Some(h) => format!("{}@{h}", self.rule),
None => self.rule.clone(),
};
ConditionOutcome::Fail(BreakReason::PropertyViolated(format!("{name}:{detail}")))
}
}
/// The declared transition a row claims to be making.
struct Declared {
from: String,
action: String,
to: String,
}
fn read_envelope(data: &Value) -> Option<Declared> {
let t = data.get(TRANSITION_FIELD)?;
Some(Declared {
from: t.get("from")?.as_str()?.to_string(),
action: t.get("action")?.as_str()?.to_string(),
to: t.get("to")?.as_str()?.to_string(),
})
}
impl Condition for FsmCondition {
fn name(&self) -> &'static str {
"FsmCondition"
}
fn evaluate(&self, ctx: &ConditionContext) -> ConditionOutcome {
let Some(declared) = read_envelope(&ctx.data) else {
return if self.strict {
// Distinguished from a malformed one on purpose: "you
// did not say" and "you said something impossible" are
// different operator problems.
self.violated("no-transition-declared")
} else {
ConditionOutcome::Pass
};
};
// Exclusions first, and they cannot be overridden -- the same
// ordering the permission model uses, for the same reason.
if let Some(why) = self.fsm.excluded_by(&declared.from, &declared.action) {
return self.violated(&format!("excluded:{why}"));
}
let Some(expected) = self.fsm.next_state(&declared.from, &declared.action) else {
return self.violated(&format!(
"undeclared-transition:{}--{}",
declared.from, declared.action
));
};
if let Some(guard) = self.fsm.guard(&declared.from, &declared.action) {
if let Err(code) = guard.check(ctx) {
return self.violated(&format!("guard-rejected:{code}"));
}
}
if *expected != declared.to {
// A writer naming a legal transition and then recording a
// destination of its own choosing is exactly what this
// stops.
return self.violated(&format!(
"wrong-destination:{}--{}->{} expected {expected}",
declared.from, declared.action, declared.to
));
}
ConditionOutcome::Pass
}
}
fn hex_short(digest: &[u8]) -> String {
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
}
/// The transition envelope, for a writer to embed in its payload.
pub fn transition(from: &str, action: &str, to: &str) -> Value {
serde_json::json!({ TRANSITION_FIELD: { "from": from, "action": action, "to": to } })
}
#[cfg(test)]
mod tests {
use super::*;
const SPEC: &str = r#"
{
"scope": "doc.review.v1",
"initial": "draft",
"transitions": [
{
"from": "draft",
"action": "submit",
"to": "review"
},
{
"from": "review",
"action": "approve",
"to": "published",
"guard": "big_enough"
}
]
}
"#;
pub(super) fn ctx(data: Value) -> ConditionContext {
ConditionContext {
entity_id: "e".into(),
chain_id: "c".into(),
sequence: 1,
stored_tip_hash: None,
computed_hash: None,
prev_hash: None,
last_sequence: Some(0),
source_url: String::new(),
extraction_method: "trusty".into(),
data,
}
}
fn registry() -> GuardRegistry<ConditionContext> {
let mut r = GuardRegistry::new();
r.insert("big_enough", |c: &ConditionContext| {
match c.data.get("size").and_then(Value::as_i64) {
Some(n) if n >= 10 => Ok(()),
Some(n) => Err(format!("too-small:{n}")),
None => Err("no-size".to_string()),
}
});
r
}
fn condition() -> FsmCondition {
FsmCondition::from_json(SPEC, &mut registry(), "doc.review").expect("build")
}
/// ConditionOutcome does not derive PartialEq, and adding a derive to
/// a core type to suit a test is the wrong direction of dependency.
fn passed(outcome: ConditionOutcome) -> bool {
matches!(outcome, ConditionOutcome::Pass)
}
fn reason(outcome: ConditionOutcome) -> String {
match outcome {
ConditionOutcome::Fail(BreakReason::PropertyViolated(r)) => r,
other => panic!("expected a property violation, got {other:?}"),
}
}
#[test]
fn a_declared_transition_passes() {
let outcome = condition().evaluate(&ctx(transition("draft", "submit", "review")));
assert!(
passed(outcome),
"a compliant mutation must leave the chain unbroken; that is what \
makes a break mean something"
);
}
#[test]
fn an_undeclared_transition_breaks_the_chain_and_names_the_rule() {
let outcome = condition().evaluate(&ctx(transition("draft", "publish", "published")));
let r = reason(outcome);
assert!(
r.starts_with("doc.review@") && r.contains(":undeclared-transition"),
"the break must name the rule that broke, or an operator reading \
the chain later learns only that something went wrong: {r}"
);
}
#[test]
fn naming_a_legal_transition_does_not_licence_an_arbitrary_destination() {
let outcome = condition().evaluate(&ctx(transition("draft", "submit", "published")));
let r = reason(outcome);
assert!(
r.contains("wrong-destination"),
"submit is legal from draft, but it goes to review; without this \
check a writer could name a legal transition and then record \
wherever it liked: {r}"
);
}
#[test]
fn a_guard_sees_the_payload_not_just_the_chain_metadata() {
let mut small = transition("review", "approve", "published");
small["size"] = Value::from(3);
let r = reason(condition().evaluate(&ctx(small)));
assert!(
r.contains("guard-rejected:too-small:3"),
"the guard must be able to judge the DATA being appended -- a rule \
that can only see chain metadata cannot express most invariants \
worth having: {r}"
);
let mut big = transition("review", "approve", "published");
big["size"] = Value::from(50);
assert!(passed(condition().evaluate(&ctx(big))));
}
#[test]
fn a_missing_guard_input_is_a_rejection_not_a_pass() {
let r = reason(condition().evaluate(&ctx(transition("review", "approve", "published"))));
assert!(
r.contains("guard-rejected:no-size"),
"absent evidence must not read as a satisfied guard, for the same \
reason absent policy is not permission: {r}"
);
}
#[test]
fn an_unregistered_guard_refuses_to_build() {
let mut empty = GuardRegistry::<ConditionContext>::new();
let built = FsmCondition::from_json(SPEC, &mut empty, "doc.review");
assert!(
built.is_err(),
"a policy that silently runs without its guard is a policy that is \
not being enforced, and it looks identical to one that is"
);
}
#[test]
fn an_unlabelled_row_passes_by_default_and_fails_under_strict() {
let plain = ctx(serde_json::json!({ "temperature": 21 }));
assert!(
passed(condition().evaluate(&plain)),
"most chains carry traffic this condition has no opinion about"
);
let r = reason(condition().strict().evaluate(&plain));
assert!(
r.contains("no-transition-declared"),
"on a chain dedicated to the governed process, omitting the \
envelope would otherwise be a way around the rule: {r}"
);
}
#[test]
fn a_malformed_envelope_is_reported_as_undeclared_not_as_absent() {
let half = ctx(serde_json::json!({ TRANSITION_FIELD: { "from": "draft" } }));
assert!(
passed(condition().evaluate(&half)),
"an envelope missing required fields is not a transition claim at \
all; under strict it fails, and the two cases are distinguished \
because 'you did not say' and 'you said something impossible' are \
different operator problems"
);
let r = reason(condition().strict().evaluate(&half));
assert!(r.contains("no-transition-declared"), "{r}");
}
#[test]
fn an_exclusion_cannot_be_overridden_by_a_declared_transition() {
let mut fsm: Fsm<String, String, ConditionContext> = Fsm::new("draft".to_string());
fsm.allow(trusty_policy::Transition::new(
"draft".to_string(),
"submit".to_string(),
"review".to_string(),
));
fsm.exclude("submit".to_string(), "frozen-for-audit");
let cond = FsmCondition::new(fsm, "doc.review");
let r = reason(cond.evaluate(&ctx(transition("draft", "submit", "review"))));
assert!(
r.contains("excluded:frozen-for-audit"),
"exclusions are checked before the transition is looked up, so a \
declared transition cannot reopen what an exclusion shut -- the \
same ordering the permission model uses: {r}"
);
}
}
#[cfg(test)]
mod rule_version_tests {
use super::*;
const NARROW: &str = r#"
{
"scope": "doc.review.v1",
"initial": "draft",
"transitions": [
{ "from": "draft", "action": "submit", "to": "review" }
]
}
"#;
const LOOSENED: &str = r#"
{
"scope": "doc.review.v1",
"initial": "draft",
"transitions": [
{ "from": "draft", "action": "submit", "to": "review" },
{ "from": "draft", "action": "publish", "to": "published" }
]
}
"#;
fn built(spec: &str) -> FsmCondition {
FsmCondition::from_json(spec, &mut GuardRegistry::new(), "doc.review")
.expect("build")
}
#[test]
fn loosening_the_rule_changes_the_version_the_break_cites() {
let narrow = built(NARROW);
let loosened = built(LOOSENED);
assert_ne!(
narrow.rule_hash(),
loosened.rule_hash(),
"a rule set that can be widened without changing its version is a \
rule set a violation marker cannot honestly cite -- loosen it \
after the fact and the old marker still appears to hold"
);
}
#[test]
fn the_same_rule_set_always_yields_the_same_version() {
assert_eq!(
built(NARROW).rule_hash(),
built(NARROW).rule_hash(),
"a version that varied between processes would make every marker \
uncomparable, which is worse than having no version at all"
);
}
#[test]
fn a_hand_built_fsm_has_no_version_rather_than_a_made_up_one() {
let mut fsm: Fsm<String, String, ConditionContext> = Fsm::new("draft".to_string());
fsm.allow(trusty_policy::Transition::new(
"draft".to_string(),
"submit".to_string(),
"review".to_string(),
));
let cond = FsmCondition::new(fsm, "doc.review");
assert_eq!(
cond.rule_hash(),
None,
"an FSM assembled in Rust has no canonical document to hash, and \
inventing a version for it would be a claim nobody can check"
);
}
#[test]
fn a_versionless_rule_still_names_itself_in_the_break() {
let mut fsm: Fsm<String, String, ConditionContext> = Fsm::new("draft".to_string());
fsm.allow(trusty_policy::Transition::new(
"draft".to_string(),
"submit".to_string(),
"review".to_string(),
));
let outcome = FsmCondition::new(fsm, "doc.review")
.evaluate(&super::tests::ctx(transition("draft", "publish", "gone")));
match outcome {
ConditionOutcome::Fail(BreakReason::PropertyViolated(r)) => assert!(
r.starts_with("doc.review:"),
"no version is not the same as no name; the rule must still \
identify itself: {r}"
),
other => panic!("expected a violation, got {other:?}"),
}
}
}

View File

@@ -0,0 +1,303 @@
//! Does a declared rule actually break a real chain?
//!
//! The unit tests check the condition's verdict. These check that the
//! verdict reaches the chain: that a compliant mutation leaves it
//! unbroken, a deviation records a break, and the break says which rule
//! broke rather than merely that something did.
use trusty_core::{Ed25519Signer, SecretKey, Stance};
use trusty_policy::GuardRegistry;
use trusty_policy_conditions::{transition, FsmCondition};
use trusty_sqlite::Trusty;
const SPEC: &str = r#"
{
"scope": "doc.review.v1",
"initial": "draft",
"transitions": [
{
"from": "draft",
"action": "submit",
"to": "review"
},
{
"from": "review",
"action": "approve",
"to": "published",
"guard": "big_enough"
}
]
}
"#;
fn store() -> Trusty {
let mut registry = GuardRegistry::new();
registry.insert("big_enough", |c: &trusty_core::ConditionContext| {
match c.data.get("size").and_then(serde_json::Value::as_i64) {
Some(n) if n >= 10 => Ok(()),
Some(n) => Err(format!("too-small:{n}")),
None => Err("no-size".to_string()),
}
});
let condition =
FsmCondition::from_json(SPEC, &mut registry, "doc.review").expect("build the rule");
// The integrity defaults PLUS the declared rule: a policy rule is an
// addition to tamper detection, never a replacement for it.
let mut conditions = trusty_core::conditions::default_conditions();
conditions.push(Box::new(condition));
Trusty::open_in_memory_with_conditions(SecretKey::new([9u8; 16]), conditions)
.expect("store")
}
/// Every recorded break reason, in order.
///
/// Read through `breaks_since`, not from the exported bundle: a BREAK
/// row's reason lives in the `old_value`/`new_value` columns, and
/// `BundleRow` carries neither. So the reason does **not** travel to a
/// remote party today -- see the sibling issue on making the marker
/// actionable across a boundary.
fn break_reasons(trusty: &Trusty, entity: &str) -> Vec<String> {
trusty
.breaks_since(entity, None)
.expect("breaks")
.into_iter()
.map(|b| b.break_reason)
.collect()
}
/// Establish the chain with a compliant row.
///
/// A rule cannot be violated by the FIRST row of a chain: break-as-event
/// seals an existing chain, and at genesis there is nothing to seal. So
/// every test that wants to observe a violation must first give the
/// chain something to break.
fn genesis(trusty: &mut Trusty, signer: &Ed25519Signer, entity: &str) {
trusty
.append_signed(
entity,
&transition("draft", "submit", "review"),
"test://",
signer,
)
.expect("genesis");
}
#[test]
fn a_compliant_mutation_leaves_the_chain_unbroken() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
trusty
.append_signed(
"doc:1",
&transition("draft", "submit", "review"),
"test://",
&signer,
)
.expect("append");
assert!(
break_reasons(&trusty, "doc:1").is_empty(),
"a declared transition must pass; if compliant data broke the chain, \
a break would stop meaning anything"
);
assert!(
trusty
.export_bundle("doc:1", None, None)
.expect("export")
.verify_public()
.continuous,
"and the chain must read as continuous"
);
}
#[test]
fn an_undeclared_mutation_breaks_the_chain_and_the_break_names_the_rule() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
trusty
.append_signed(
"doc:2",
&transition("draft", "submit", "review"),
"test://",
&signer,
)
.expect("first");
// draft --publish--> published is not declared anywhere.
trusty
.append_signed(
"doc:2",
&transition("draft", "publish", "published"),
"test://",
&signer,
)
.expect("second");
let reasons = break_reasons(&trusty, "doc:2");
assert_eq!(
reasons.len(),
1,
"the deviation must be recorded as an event, not rejected silently -- \
chain-break-as-event is what makes the violation evidence rather than \
an error somebody's log swallowed; got {reasons:?}"
);
assert!(
reasons[0].starts_with("PROPERTY_VIOLATED:doc.review@")
&& reasons[0].contains(":undeclared-transition"),
"the break must carry the rule's name, so an operator reading this \
chain in six months learns WHICH invariant broke; got {}",
reasons[0]
);
}
#[test]
fn a_guard_rejection_names_the_guard_and_its_reason() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
genesis(&mut trusty, &signer, "doc:3");
let mut small = transition("review", "approve", "published");
small["size"] = serde_json::Value::from(2);
trusty
.append_signed("doc:3", &small, "test://", &signer)
.expect("append");
let reasons = break_reasons(&trusty, "doc:3");
assert!(
reasons
.iter()
.any(|r| r.contains("guard-rejected:too-small:2")),
"a rule over the DATA must be able to break the chain and say what it \
saw; a break that only said 'guard failed' would send someone back to \
the source to find out which one; got {reasons:?}"
);
}
#[test]
fn the_marker_crosses_the_boundary_carrying_the_rule_name() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
genesis(&mut trusty, &signer, "doc:4");
trusty
.append_signed(
"doc:4",
&transition("draft", "publish", "published"),
"test://",
&signer,
)
.expect("append");
// Everything below is what a REMOTE party can do: it holds the
// bundle and no secret key, and never talks to the machine that
// recorded the break.
let bundle = trusty.export_bundle("doc:4", None, None).expect("export");
let violations = bundle.violations();
assert_eq!(
violations.len(),
1,
"the violation must be readable off the bundle alone; a marker only \
the writer can read is not something anyone else can act on"
);
assert_eq!(
violations[0]
.rule
.as_deref()
.map(|r| r.split('@').next().unwrap_or(r)),
Some("doc.review"),
"and it must name the RULE, not merely report that something broke. \
Refuse, quarantine or inspect are different decisions and the rule is \
what tells them apart; got {:?}",
violations[0]
);
match bundle.stance() {
Stance::Violated { violations } => {
assert_eq!(violations.len(), 1, "one break, one violation")
}
other => panic!("expected a violation stance, got {other:?}"),
}
assert!(
!bundle.stance().is_clean(),
"a datum carrying a signed violation must never be waved through"
);
}
#[test]
fn a_clean_bundle_reads_as_clean_and_a_tampered_one_does_not() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
genesis(&mut trusty, &signer, "doc:6");
let clean = trusty.export_bundle("doc:6", None, None).expect("export");
assert_eq!(
clean.stance(),
Stance::Clean,
"a compliant chain must read as clean, or the marker means nothing"
);
// The same bundle, edited after export.
let mut forged = clean.clone();
for row in forged.rows.iter_mut() {
if let Some(payload) = row.raw_payload.as_mut() {
*payload = payload.replace("submit", "publish");
}
}
match forged.stance() {
Stance::Unverifiable { .. } => {}
other => panic!(
"a bundle that no longer matches its signatures must report \
Unverifiable -- counting zero violations and looking spotless is \
exactly the failure the third answer exists to prevent; got {other:?}"
),
}
assert!(
!forged.stance().is_clean(),
"and unverifiable is not a permit"
);
}
#[test]
fn a_break_with_no_rule_is_still_reported() {
// Tamper detection and sequence gaps are real breaks with no
// invariant to name. They must still surface, with rule: None,
// rather than being filtered out for lacking a name.
let v = trusty_core::Violation {
entity_id: "e".into(),
chain_id: "c".into(),
rule: None,
reason: "TAMPER_DETECTED".into(),
};
assert!(
v.rule.is_none() && !v.reason.is_empty(),
"a break without a rule name is still a break, and dropping it would \
hide the most serious kind"
);
}
#[test]
fn a_declared_transition_to_the_wrong_destination_still_breaks() {
let mut trusty = store();
let signer = Ed25519Signer::generate();
genesis(&mut trusty, &signer, "doc:5");
trusty
.append_signed(
"doc:5",
&transition("draft", "submit", "published"),
"test://",
&signer,
)
.expect("append");
let reasons = break_reasons(&trusty, "doc:5");
assert!(
reasons.iter().any(|r| r.contains("wrong-destination")),
"submit is legal from draft, but it goes to review. Without this the \
rule would check the move and ignore where it landed; got {reasons:?}"
);
}