Compare commits

...

6 Commits

Author SHA1 Message Date
claude fb28b831f3 feat: show resolves a unique content-id prefix (closes #302)
aura {process|campaign} show now accepts the 8-hex prefix every surface
prints: an exact match is tried first, then the prefix is resolved
against the store's candidate ids — a unique match prints the identical
stored bytes as the full id (#164 byte discipline), an ambiguous prefix
refuses listing every sharing candidate, and a genuinely-unknown id
keeps its established refusal. The resolution mirrors the semantics
reproduce shipped with #298, closing the asymmetry the #300 fieldtest
named (df_4). Registry gains list_process_ids/list_campaign_ids
(missing store dir reads as empty, matching the blueprint-scan
discipline); campaign_not_found_prose becomes the single source for
show_campaign's two not-found arms. The ambiguity arm is pinned by a
unit test at the resolution seam — hash-derived ids make a
shared-prefix E2E fixture impractical.
2026-07-21 15:23:55 +02:00
claude 6b994cead3 test: red for show resolving a unique content-id prefix (refs #302)
aura {process|campaign} show is exact-match-only while every surface
prints 8-hex prefixes and reproduce has resolved prefixes since #298;
the fieldtest's prefix paste (df_4) hits the same flat not-found prose
as a genuinely-unknown id. The E2E test pins the wanted headline: a
unique 8-hex prefix of a registered id prints byte-identically what the
full-id show prints (#164 byte discipline), both verbs. The ambiguity
refusal is deliberately left to a unit test at the resolution seam —
hash-derived ids make a shared-prefix E2E fixture impractical.
2026-07-21 15:03:56 +02:00
claude a5ca0e7481 feat: the select-rule refusal enumerates the accepted roster (closes #301)
An unknown select rule in a process document now refuses with the
accepted vocabulary beside the quoted offending value:
  block std::sweep: unknown select rule "plateau" (accepted: argmax |
  plateau:mean | plateau:worst)
mirroring the roster-naming metric-refusal shape. The roster is derived
from slot_kind_label(SlotKind::SelectRule) — CLI describe output and
the document refusal stay in sync by construction — and a companion
test pins the "select rule: " prefix that derivation trims, so a
label-wording drift breaks a test instead of silently degrading the
prose. No alias-hint sentence: the enumeration alone rescues the
bare-plateau paste case (fieldtest df_3), and the document schema stays
strict per #300 F7.
2026-07-21 14:59:35 +02:00
claude edb8c37916 test: red for the select-rule refusal enumerating the accepted roster (refs #301)
A process document carrying an unknown select rule refuses correctly
but names no accepted vocabulary; the fieldtest's bare-plateau paste
(df_3) hits an opaque wall. The unit test pins the wanted property at
the site that builds the prose (select_from): the refusal enumerates
argmax, plateau:mean, plateau:worst beside the quoted offending value.
No alias-hint sentence — the enumeration alone rescues the paste case
and the project's refusal prose stays sparse; the document schema stays
strict per #300 F7.
2026-07-21 14:44:40 +02:00
claude 0a70f67711 fix: register outside a project refuses instead of creating a stray store (closes #305)
register_process and register_campaign now carry the same
env.provenance() no-project guard as their show/runs siblings, refusing
with exit 1 and the missing-Aura.toml prose instead of letting
Env::runs_root()'s relative fallback materialise ./runs/ in an innocent
cwd. Five pre-existing register tests ran from a bare temp cwd and were
reaching their asserted behaviour only through this bug; they now run
inside the fresh_project() fixture, preserving their original property
(content addressing, intrinsic-validation refusals) in a real project.

Verified: the RED E2E test (register_outside_a_project_refuses_and_
leaves_no_store, both verbs, refusal + no side-effect store) observed
GREEN by the loop's independent verify alongside the full suite.
2026-07-21 14:41:47 +02:00
claude 27ace153f2 test: red for register's stray-store creation outside a project (refs #305)
register_process/register_campaign call env.registry() without the
no-project guard their show/runs siblings carry, so Env::runs_root()'s
relative fallback silently creates ./runs/ in an innocent cwd and
registers the document there (exit 0). The E2E test pins both verbs and
both halves of the wanted refusal: exit 1 naming the missing Aura.toml,
and no store directory created as a side effect.
2026-07-21 14:27:04 +02:00
5 changed files with 328 additions and 26 deletions
+108 -17
View File
@@ -250,6 +250,15 @@ fn validate_process_file(file: &PathBuf) -> Result<(), String> {
}
fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"process register needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let doc = parse_valid_process(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = process_to_json(&doc);
@@ -262,6 +271,28 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
Ok(())
}
/// Resolve `id` against a document store's full candidate content-id list —
/// mirroring the exact-first / unique-match / else-refuse shape
/// `aura_runner::reproduce::load_family` established for #298's family/run
/// handle. Called only once the caller's own exact-match lookup has already
/// missed, so a filter match here is always a strict, shorter prefix (ids
/// are fixed-length hashes; a full-length miss cannot be a prefix of
/// another). `Ok(None)`: no candidate matches — genuinely unknown, the
/// caller's existing not-found prose applies unchanged. `Err`: two or more
/// candidates share the prefix — refused by name, listing every candidate,
/// rather than guessed.
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
match matches.as_slice() {
[] => Ok(None),
[only] => Ok(Some((*only).clone())),
many => {
let listed = many.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
Err(format!("ambiguous id \"{prefix}\": candidates {listed}"))
}
}
}
fn show_process(id: &str, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
@@ -273,15 +304,23 @@ fn show_process(id: &str, env: &Env) -> Result<(), String> {
));
}
let registry = env.registry();
match registry.get_process(id).map_err(|e| e.to_string())? {
Some(bytes) => {
// `print!`, not `println!`: `bytes` is the content-addressed canonical
// form the store keyed `id` on — the CLI is a transport and must not
// frame the canonical bytes with a trailing newline (#164, mirroring
// `graph_construct::build_cmd`).
print!("{bytes}");
Ok(())
}
if let Some(bytes) = registry.get_process(id).map_err(|e| e.to_string())? {
// `print!`, not `println!`: `bytes` is the content-addressed canonical
// form the store keyed `id` on — the CLI is a transport and must not
// frame the canonical bytes with a trailing newline (#164, mirroring
// `graph_construct::build_cmd`).
print!("{bytes}");
return Ok(());
}
let candidates = registry.list_process_ids().map_err(|e| e.to_string())?;
match resolve_id_prefix(id, &candidates)? {
Some(full_id) => match registry.get_process(&full_id).map_err(|e| e.to_string())? {
Some(bytes) => {
print!("{bytes}");
Ok(())
}
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
},
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
}
}
@@ -383,6 +422,14 @@ fn prefix_hint(id: &str) -> &'static str {
}
}
/// `show_campaign`'s not-found refusal prose — campaigns have no `RefFault`
/// sibling (unlike processes' `ref_fault_prose`), so this is the single
/// source both the exact-miss and prefix-resolved-but-then-missing arms
/// route through, keeping the two copies from drifting.
fn campaign_not_found_prose(id: &str) -> String {
format!("campaign {id} not found in the project store{}", prefix_hint(id))
}
pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
match f {
RefFault::ProcessNotFound(id) => {
@@ -580,6 +627,15 @@ fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> {
}
fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"campaign register needs a project: the document store lives under the \
project store root (no Aura.toml found up from {cwd})"
));
}
let doc = parse_valid_campaign(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = campaign_to_json(&doc);
@@ -603,14 +659,22 @@ fn show_campaign(id: &str, env: &Env) -> Result<(), String> {
));
}
let registry = env.registry();
match registry.get_campaign(id).map_err(|e| e.to_string())? {
Some(bytes) => {
// `print!`, not `println!`: see `show_process` — the CLI must not
// frame the canonical bytes with a trailing newline (#164).
print!("{bytes}");
Ok(())
}
None => Err(format!("campaign {id} not found in the project store{}", prefix_hint(id))),
if let Some(bytes) = registry.get_campaign(id).map_err(|e| e.to_string())? {
// `print!`, not `println!`: see `show_process` — the CLI must not
// frame the canonical bytes with a trailing newline (#164).
print!("{bytes}");
return Ok(());
}
let candidates = registry.list_campaign_ids().map_err(|e| e.to_string())?;
match resolve_id_prefix(id, &candidates)? {
Some(full_id) => match registry.get_campaign(&full_id).map_err(|e| e.to_string())? {
Some(bytes) => {
print!("{bytes}");
Ok(())
}
None => Err(campaign_not_found_prose(id)),
},
None => Err(campaign_not_found_prose(id)),
}
}
@@ -797,4 +861,31 @@ mod tests {
);
}
}
/// #302: the ambiguity arm of `resolve_id_prefix` — content ids are
/// hash-derived, not choosable, so two documents sharing a printed
/// prefix cannot be constructed honestly through an E2E fixture
/// (`show_resolves_a_unique_prefix_to_the_full_id_bytes` pins the
/// unique-match arm end-to-end instead). This unit test is the only pin
/// the ambiguous-refusal arm gets: a prefix shared by more than one
/// candidate refuses rather than guesses, and the refusal names every
/// sharing candidate so the author can pick the intended one.
#[test]
fn resolve_id_prefix_refuses_an_ambiguous_prefix_listing_candidates() {
let candidates = vec!["4e2d1111".to_string(), "4e2d2222".to_string(), "9f3a0000".to_string()];
let err = resolve_id_prefix("4e2d", &candidates).expect_err("shared prefix must refuse");
assert!(err.contains("4e2d1111"), "ambiguity refusal must list candidate 1: {err}");
assert!(err.contains("4e2d2222"), "ambiguity refusal must list candidate 2: {err}");
assert!(!err.contains("9f3a0000"), "ambiguity refusal must not list a non-matching id: {err}");
// Companion sanity for the two other arms at the same seam: a unique
// prefix resolves, and no matching prefix reports as unresolved
// rather than fabricating one.
assert_eq!(
resolve_id_prefix("9f3a", &candidates).expect("unique prefix must resolve"),
Some("9f3a0000".to_string())
);
assert_eq!(resolve_id_prefix("dead", &candidates).expect("no match is not an error"), None);
}
}
+83
View File
@@ -6707,3 +6707,86 @@ fn show_is_scoped_to_its_own_document_store() {
"campaign show stderr: {campaign_stderr}"
);
}
/// Property (#302): `show` mirrors the prefix resolution `reproduce` gained
/// with #298 — a unique prefix of a registered content id, in the 8-hex form
/// every surface prints (family ids, run records), resolves to the registered
/// document, and `show` prints EXACTLY the bytes the full-id invocation
/// prints (the #164 byte-discipline: the stored canonical bytes, no framing).
/// One document per store makes any prefix of its id unique by construction.
/// Deliberately NOT pinned here: the ambiguous-prefix refusal (listing the
/// candidate ids) — content ids are hash-derived, not choosable, so two
/// documents sharing a printed prefix cannot be constructed honestly at this
/// layer; that arm belongs beside the resolution seam, where candidate id
/// lists are plain strings.
#[test]
fn show_resolves_a_unique_prefix_to_the_full_id_bytes() {
let (cwd, _g) = fresh_project();
fn aura(cwd: &Path, args: &[&str]) -> std::process::Output {
Command::new(BIN).args(args).current_dir(cwd).output().expect("spawn aura")
}
const PREFIX_PROBE_PROCESS: &str = r#"{"format_version":1,"kind":"process","name":"prefix-probe","pipeline":[{"block":"std::sweep","metric":"sqn_normalized","select":"argmax"}]}"#;
const PREFIX_PROBE_CAMPAIGN: &str = r#"{
"format_version": 1,
"kind": "campaign",
"name": "prefix-probe",
"data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] },
"strategies": [ { "ref": { "content_id": "9f3a" },
"axes": { "fast": { "kind": "I64", "values": [8] } } } ],
"process": { "ref": { "content_id": "4e2d" } },
"seed": 1,
"presentation": { "persist_taps": [], "emit": ["family_table"] }
}"#;
for (verb, file, doc) in [
("process", "probe.process.json", PREFIX_PROBE_PROCESS),
("campaign", "probe.campaign.json", PREFIX_PROBE_CAMPAIGN),
] {
std::fs::write(cwd.join(file), doc).expect("write document");
let register = aura(&cwd, &[verb, "register", file]);
assert_eq!(
register.status.code(),
Some(0),
"{verb} register must succeed: {}",
String::from_utf8_lossy(&register.stderr)
);
let register_out = String::from_utf8_lossy(&register.stdout).into_owned();
let marker = format!("registered {verb} ");
let id = register_out
.lines()
.find(|l| l.starts_with(&marker))
.expect("register line")
.trim_start_matches(marker.as_str())
.split(' ')
.next()
.expect("content id")
.to_string();
assert!(id.len() > 8, "content id longer than the printed 8-hex prefix form: {id}");
let full = aura(&cwd, &[verb, "show", &id]);
assert_eq!(
full.status.code(),
Some(0),
"{verb} show <full id> must succeed: {}",
String::from_utf8_lossy(&full.stderr)
);
assert!(!full.stdout.is_empty(), "{verb} show <full id> prints the document bytes");
// The headline: the 8-hex prefix — unique in this one-document store —
// resolves, and the output is byte-identical to the full-id output.
let prefix = &id[..8];
let short = aura(&cwd, &[verb, "show", prefix]);
assert_eq!(
short.status.code(),
Some(0),
"{verb} show {prefix} (unique prefix of {id}) must resolve: {}",
String::from_utf8_lossy(&short.stderr)
);
assert_eq!(
short.stdout, full.stdout,
"{verb} show via unique prefix must print the identical document bytes"
);
}
}
+46 -5
View File
@@ -282,7 +282,8 @@ fn process_introspect_metrics_plus_another_mode_is_usage_exit_2() {
#[test]
fn process_register_stores_content_addressed_under_runs_root() {
let dir = temp_cwd("process-register");
// #305: register needs a project (the show/runs guard family).
let (dir, _fixture) = fresh_project();
write_doc(&dir, "p.process.json", PROCESS_DOC);
let (out, code) = run_code_in(&dir, &["process", "register", "p.process.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
@@ -596,6 +597,42 @@ fn campaign_validate_outside_project_skips_referential_tier() {
assert!(out.contains("referential checks skipped (no Aura.toml found up from "));
}
/// #305: `register` outside a project (no Aura.toml up from the cwd) refuses
/// up front — exit 1, prose naming the invoked verb and the missing Aura.toml
/// (the honest-degradation guard family `show`, `campaign runs`, and the
/// dissolved run verbs already share) — and leaves NO store. Today the missing
/// `env.provenance()` guard lets `Env::registry()`'s relative `runs/` fallback
/// silently materialise `./runs/` in the innocent cwd and register the
/// document into a store no project owns, exit 0.
#[test]
fn register_outside_a_project_refuses_and_leaves_no_store() {
for (verb, file, doc) in [
("process", "p.process.json", PROCESS_DOC),
("campaign", "c.campaign.json", CAMPAIGN_DOC),
] {
let dir = temp_cwd(&format!("{verb}-register-outside-project"));
write_doc(&dir, file, doc);
let (out, code) = run_code_in(&dir, &[verb, "register", file]);
assert_eq!(
code,
Some(1),
"{verb} register outside a project must refuse; stdout/stderr: {out}"
);
assert!(
out.contains(&format!("{verb} register needs a project")),
"{verb} register's refusal must name the invoked verb: {out}"
);
assert!(
out.contains("no Aura.toml found up from"),
"{verb} register's refusal must name the missing Aura.toml: {out}"
);
assert!(
!dir.join("runs").exists(),
"{verb} register refused outside a project must leave no ./runs/ store"
);
}
}
#[test]
fn campaign_validate_refuses_empty_axis_prose_exit_1() {
let dir = temp_cwd("campaign-validate-empty-axis");
@@ -1031,7 +1068,8 @@ fn campaign_introspect_content_id_prints_hash() {
#[test]
fn campaign_register_stores_content_addressed_under_runs_root() {
let dir = temp_cwd("campaign-register");
// #305: register needs a project (the show/runs guard family).
let (dir, _fixture) = fresh_project();
write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC);
let (out, code) = run_code_in(&dir, &["campaign", "register", "c.campaign.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
@@ -1166,7 +1204,8 @@ fn wrong_kind_refusal_names_the_kind_key() {
/// have already made.
#[test]
fn campaign_register_refuses_invalid_document_and_writes_nothing() {
let dir = temp_cwd("campaign-register-invalid");
// #305: register needs a project (the show/runs guard family).
let (dir, _fixture) = fresh_project();
let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1);
write_doc(&dir, "bad.campaign.json", &bad);
let (out, code) = run_code_in(&dir, &["campaign", "register", "bad.campaign.json"]);
@@ -1187,7 +1226,8 @@ fn campaign_register_refuses_invalid_document_and_writes_nothing() {
/// would silently persist an unenforceable risk regime.
#[test]
fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
let dir = temp_cwd("campaign-register-invalid-risk");
// #305: register needs a project (the show/runs guard family).
let (dir, _fixture) = fresh_project();
let bad = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 0, \"k\": 2.0 } } ],",
@@ -1214,7 +1254,8 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
/// precedent).
#[test]
fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() {
let dir = temp_cwd("campaign-register-invalid-cost");
// #305: register needs a project (the show/runs guard family).
let (dir, _fixture) = fresh_project();
let bad = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": -2.0 } } ],",
+34
View File
@@ -189,6 +189,28 @@ impl Registry {
}
}
/// Every content id currently stored for one document kind — the
/// filename stems under `doc_dir(kind)` (`doc_path`'s write-one/read-one
/// mapping, read back), for prefix resolution (#302). A missing store
/// dir (nothing registered yet) is treat-as-empty, not an error, the
/// same discipline `find_blueprint_by_identity`'s scan applies to a
/// missing blueprints dir.
fn list_doc_ids(&self, kind: &str) -> Result<Vec<String>, RegistryError> {
let entries = match fs::read_dir(self.doc_dir(kind)) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(RegistryError::Io(e)),
};
let mut ids = Vec::new();
for entry in entries {
let entry = entry?;
if let Some(id) = entry.path().file_stem().and_then(|s| s.to_str().map(String::from)) {
ids.push(id);
}
}
Ok(ids)
}
/// Store a canonical process document; returns its content id.
pub fn put_process(&self, canonical_json: &str) -> Result<String, RegistryError> {
self.put_doc("processes", canonical_json)
@@ -199,6 +221,12 @@ impl Registry {
self.get_doc("processes", content_id)
}
/// Every content id currently registered in the process store — the
/// candidate list `show`'s prefix resolution (#302) filters against.
pub fn list_process_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("processes")
}
/// Store a canonical campaign document; returns its content id.
pub fn put_campaign(&self, canonical_json: &str) -> Result<String, RegistryError> {
self.put_doc("campaigns", canonical_json)
@@ -209,6 +237,12 @@ impl Registry {
self.get_doc("campaigns", content_id)
}
/// Every content id currently registered in the campaign store — the
/// candidate list `show`'s prefix resolution (#302) filters against.
pub fn list_campaign_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("campaigns")
}
/// The store path a process document with this content id lives at —
/// the same single mapping the put/get pair routes through, exposed so
/// consumers never re-derive the layout.
+57 -4
View File
@@ -295,10 +295,17 @@ fn select_from(
) -> Result<SelectRule, String> {
let s = require_str(m, "select", block)?;
// SelectRule's own derived Deserialize (which carries the #[serde(rename)]
// wire strings) is the type oracle, mirroring kind_tag/scalar_from_bare:
// no second hardcoded copy of the three select-rule wire strings.
serde_json::from_value(serde_json::Value::String(s.clone()))
.map_err(|_| format!("block {block}: unknown select rule \"{s}\""))
// wire strings) is the type oracle for *parsing*, mirroring
// kind_tag/scalar_from_bare. The refusal's roster text below is sourced
// from slot_kind_label(SlotKind::SelectRule) rather than a second
// hardcoded copy of the three wire strings: CLI describe output and the
// document refusal stay in sync by construction (add a SelectRule
// variant, update one string).
serde_json::from_value(serde_json::Value::String(s.clone())).map_err(|_| {
let roster =
slot_kind_label(SlotKind::SelectRule).trim_start_matches("select rule: ");
format!("block {block}: unknown select rule \"{s}\" (accepted: {roster})")
})
}
fn mode_from(
@@ -1381,6 +1388,52 @@ mod tests {
assert!(matches!(parse_process("not json"), Err(DocError::NotJson(_))));
}
/// #301 (fieldtest cycle-300 df_3): the unknown-select-rule refusal must
/// teach the accepted vocabulary, not just refuse — an author who learned
/// bare `plateau` at the CLI pastes it into a document and needs the
/// roster (argmax | plateau:mean | plateau:worst) in the refusal prose to
/// self-serve the fix. The schema stays strict: bare "plateau" remains
/// refused in documents (#300 F7 — it is a CLI-only argv alias), and the
/// prose stays sparse — the roster only, no alias-bridging sentence.
#[test]
fn unknown_select_rule_refusal_enumerates_accepted_rules() {
let bare_plateau = PROCESS_FIXTURE.replacen("plateau:worst", "plateau", 1);
let err =
parse_process(&bare_plateau).expect_err("bare plateau stays refused in documents");
let DocError::Malformed(msg) = err else {
panic!("expected DocError::Malformed, got {err:?}");
};
// The existing contract survives: the offending value stays named, quoted.
assert!(
msg.contains("unknown select rule \"plateau\""),
"offending value no longer named: {msg}"
);
// The new behaviour: the refusal enumerates the accepted rules.
for accepted in ["argmax", "plateau:mean", "plateau:worst"] {
assert!(
msg.contains(accepted),
"accepted rule {accepted:?} not enumerated in the refusal: {msg}"
);
}
// Precise and sparse: the roster alone rescues the paste case — no
// alias-hint sentence bridging plateau -> plateau:mean.
assert!(!msg.contains("alias"), "refusal grew an alias hint: {msg}");
}
/// The refusal derives its roster from slot_kind_label by trimming the
/// "select rule: " prefix. That trim is a positional contract: if the
/// label's wording ever drifts, trim_start_matches degrades to a no-op
/// and the refusal grows a redundant prefix without any test noticing.
/// This pin makes that drift loud.
#[test]
fn select_rule_label_keeps_the_prefix_the_refusal_trims() {
assert!(
slot_kind_label(SlotKind::SelectRule).starts_with("select rule: "),
"slot_kind_label(SelectRule) no longer starts with \"select rule: \"\
update select_from's roster derivation alongside the label"
);
}
pub(crate) const CAMPAIGN_FIXTURE: &str = r#"{
"format_version": 1,
"kind": "campaign",