From 58d5345a758c87fe403c8f70801cb7aa55188c29 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 19:23:44 +0200 Subject: [PATCH] feat(aura-runner, aura-cli): reproduce returns its report; mismatch guard replaces silent DIVERGED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reproduce_family returns Result — divergence is a result the embedding caller reads (all_identical()), never an error; the CLI renders the per-member lines and summary byte-identically and owns the exit (1 on divergence, no stderr line). The library's print block and the empty-message refusal are gone; exit_on_runner_error always prints (no empty producer remains). reproduce_family_in gains the identity/pip guard, firing before any store fetch: an instrument mismatch (incl. synthetic-for-real) and a broker/pip mismatch — forward-built label compare against the stored broker string (RunManifest carries no pip field; the mint's label constructors became shared pub(crate) helpers, so guard and stamp cannot drift). The class is context-borne: the explicit-source seam refuses 2 (caller-named source), the derived simple path refuses 1 (data drift), same prose. A window guard is deliberately absent — no reproduce path takes a caller window. In-process tests pin the promise: divergence returns Ok, drift refuses class 1, the host survives. Fork minutes: issues/299#issuecomment-4875, -4877, -4879. closes #299 --- crates/aura-cli/src/main.rs | 173 ++++++++++++++++++-- crates/aura-runner/src/member.rs | 14 +- crates/aura-runner/src/reproduce.rs | 236 +++++++++++++++++++++++++--- docs/authoring-guide.md | 7 +- 4 files changed, 389 insertions(+), 41 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 07744ae..4b35c40 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1069,12 +1069,11 @@ pub(crate) fn exit_on_campaign_result(r: Result) { } } -/// The dispatch_reproduce pattern, shared: print the runner refusal and -/// exit with its class (#297). Empty message = no stderr line. +/// The shared runner-refusal arm: print the message and exit with its +/// class (#297). Every producer carries prose since #299 retired the +/// empty-message divergence refusal (divergence is report data now). fn exit_on_runner_error(e: aura_runner::RunnerError) -> ! { - if !e.message.is_empty() { - eprintln!("aura: {}", e.message); - } + eprintln!("aura: {}", e.message); std::process::exit(e.exit_code); } @@ -1467,13 +1466,24 @@ fn dispatch_runs(a: RunsCmd, env: &aura_runner::project::Env) { } fn dispatch_reproduce(a: ReproduceCmd, env: &aura_runner::project::Env) { - // `reproduce_family` (#295) returns a `RunnerError` instead of exiting - // the process itself; the shared helper prints its message (empty for - // the "not every member reproduced" refusal, since no stderr line - // accompanies it — only the stdout summary `reproduce_family` already - // emitted) and exits with its code. - if let Err(e) = aura_runner::reproduce::reproduce_family(&a.id, env) { - exit_on_runner_error(e); + // `reproduce_family` (#299) returns the report as a value — the library + // never prints and never exits. This arm renders it (byte-identical to + // the pre-#299 shell) and owns the exit: `exit(1)` when not every member + // reproduced, with NO accompanying stderr line (unchanged observable). + match aura_runner::reproduce::reproduce_family(&a.id, env) { + Ok(report) => { + let total = report.outcomes.len(); + let ok = report.outcomes.iter().filter(|(_, b)| *b).count(); + for (label, identical) in &report.outcomes { + let verdict = if *identical { "bit-identical" } else { "DIVERGED" }; + println!("{} member {label} reproduced: {verdict}", a.id); + } + println!("reproduced {ok}/{total} members bit-identically"); + if ok != total { + std::process::exit(1); // no stderr line — unchanged observable + } + } + Err(e) => exit_on_runner_error(e), } } @@ -1933,6 +1943,24 @@ mod tests { .expect("loads") } + /// An `Env` whose registry resolves to `dir` (#299): the + /// project-facing surface `reproduce_family` reads via `env.registry()`, + /// built directly (no `Aura.toml` on disk) by pointing `paths.runs` at the + /// same absolute directory a test's own `Registry::open` uses — so seeding + /// through the raw `Registry` handle and reading back through + /// `reproduce_family`'s derived path see the identical `runs.jsonl`. + fn env_over(dir: &std::path::Path) -> aura_runner::project::Env { + aura_runner::project::Env::with_project(aura_runner::project::ProjectEnv { + root: std::path::PathBuf::from("."), + toml: aura_runner::project::AuraToml { + paths: aura_runner::project::AuraPaths { data: None, runs: Some(dir.to_path_buf()) }, + nodes: Default::default(), + }, + commit: None, + native: None, + }) + } + fn cmp_member(key: &str, ts: &[i64], vals: &[f64]) -> FamilyMember { cmp_member_win(key, ts, vals, (0, 0)) } @@ -2302,6 +2330,127 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #299: `reproduce_family` (the derived-source seam behind + /// plain `aura reproduce `) survives a family whose STORED metrics were + /// hand-falsified after minting — it returns `Ok(report)` with + /// `all_identical() == false`, never a process exit or panic. The process + /// obviously survives: this test returns normally and inspects the value + /// `reproduce_family` handed back, not a captured exit code. + #[test] + fn reproduce_family_survives_a_falsified_stored_metric() { + let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-repro-falsified"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let reg = Registry::open(dir.join("runs.jsonl")); + + let env = aura_runner::project::Env::std(); + let closed = load_closed_r_sma(); + let doc = blueprint_to_json(&closed).expect("serializes"); + let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); + let topo = test_topology_hash(&reload()); + let space = blueprint_axis_probe(&doc, &env).param_space(); + let data = DataSource::Synthetic; + let pip = data.pip_size(); + let window = data.full_window(&env).expect("synthetic full_window never refuses"); + let binding = aura_runner::binding::resolve_binding("falsified", reload().input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + + let mut report = run_blueprint_member( + reload(), + &[], + &space, + data.run_sources(&env, &binding.columns()).expect("synthetic run_sources never refuses"), + window, + 0, + pip, + &topo, + &env, + StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, + &binding, + &[], + "", + ) + .expect("no cost model: nothing to refuse"); + // Hand-falsify the STORED metric after an honest mint: the record on + // disk now diverges from what a re-run actually produces — exactly the + // property `reproduce_family` must surface as data, not a process exit. + report.metrics.total_pips += 1.0; + + let canonical = blueprint_to_json(&reload()).unwrap(); + reg.put_blueprint(&topo, &canonical).expect("store blueprint"); + let id = reg.append_family("falsified", FamilyKind::Sweep, &[report]).expect("append"); + + let rep = aura_runner::reproduce::reproduce_family(&id, &env_over(&dir)) + .expect("a divergent member is a reported outcome, not a refusal"); + assert!(!rep.all_identical(), "the falsified member must report DIVERGED: {:?}", rep.outcomes); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// #299: the derived-source seam's identity/pip guard + /// (Fork 6) refuses class 1, NOT class 2, on `reproduce_family` — a + /// drifted stored broker label is data drift within the family (this + /// path's `data` is reconstructed FROM the manifest, never caller-given), + /// distinct from `reproduce_family_in`'s explicit-source class 2 (pinned + /// in `aura_runner::reproduce`'s own test module). + #[test] + fn reproduce_family_reports_the_derived_path_mismatch_as_class_one() { + let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) + .join("aura-repro-derived-class"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let reg = Registry::open(dir.join("runs.jsonl")); + + let env = aura_runner::project::Env::std(); + let closed = load_closed_r_sma(); + let doc = blueprint_to_json(&closed).expect("serializes"); + let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); + let topo = test_topology_hash(&reload()); + let space = blueprint_axis_probe(&doc, &env).param_space(); + let data = DataSource::Synthetic; + let pip = data.pip_size(); + let window = data.full_window(&env).expect("synthetic full_window never refuses"); + let binding = aura_runner::binding::resolve_binding("driftclass", reload().input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + + let mut report = run_blueprint_member( + reload(), + &[], + &space, + data.run_sources(&env, &binding.columns()).expect("synthetic run_sources never refuses"), + window, + 0, + pip, + &topo, + &env, + StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, + &binding, + &[], + "", + ) + .expect("no cost model: nothing to refuse"); + // Drift the STORED broker label away from what this pip re-derives + // (mirrors `aura_runner::reproduce`'s own pip-mismatch guard fixture, + // but read back through the derived `reproduce_family` seam here). + report.manifest.broker = "sim-optimal(pip_size=0.0002)".to_string(); + + let canonical = blueprint_to_json(&reload()).unwrap(); + reg.put_blueprint(&topo, &canonical).expect("store blueprint"); + let id = reg.append_family("driftclass", FamilyKind::Sweep, &[report]).expect("append"); + + let err = aura_runner::reproduce::reproduce_family(&id, &env_over(&dir)) + .expect_err("a drifted stored broker label must refuse, not silently reproduce"); + assert_eq!(err.exit_code, 1, "derived-path mismatch is class 1, not class 2: {}", err.message); + assert!( + err.message.contains("reproduce broker/pip mismatch"), + "the refusal names the mismatch: {}", + err.message + ); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn reproduce_re_derives_the_member_stop_regime_not_the_default() { // Property: reproduce re-runs each member under the SAME stop regime the member diff --git a/crates/aura-runner/src/member.rs b/crates/aura-runner/src/member.rs index 018043c..738af90 100644 --- a/crates/aura-runner/src/member.rs +++ b/crates/aura-runner/src/member.rs @@ -77,7 +77,7 @@ pub fn sim_optimal_manifest( defaults: Vec::new(), window, seed, - broker: format!("sim-optimal(pip_size={pip_size})"), + broker: sim_optimal_broker_label(pip_size), selection: None, instrument: None, topology_hash: None, @@ -101,10 +101,18 @@ pub fn raw_bound_defaults(signal: &Composite) -> Vec<(String, Scalar)> { signal.bound_param_space().into_iter().map(|b| (b.name, b.value)).collect() } +/// The plain sim-optimal broker label (no RiskExecutor branch): shared by +/// `sim_optimal_manifest` and (#299) `reproduce_family_in`'s forward-built +/// guard comparison, so the two cannot drift. +pub(crate) fn sim_optimal_broker_label(pip_size: f64) -> String { + format!("sim-optimal(pip_size={pip_size})") +} + /// The honest broker label for the dual-tap r-sma harness: it runs a RiskExecutor /// branch alongside the SimBroker, so the plain "sim-optimal" label would under-report -/// it (#132). Shared by the single run and the sweep so the two cannot drift. -fn r_sma_broker_label(pip_size: f64) -> String { +/// it (#132). Shared by the single run and the sweep (#299: and `reproduce_family_in`'s +/// forward-built guard comparison) so they cannot drift. +pub(crate) fn r_sma_broker_label(pip_size: f64) -> String { format!("sim-optimal+risk-executor(pip_size={pip_size})") } diff --git a/crates/aura-runner/src/reproduce.rs b/crates/aura-runner/src/reproduce.rs index bcea473..1808e7a 100644 --- a/crates/aura-runner/src/reproduce.rs +++ b/crates/aura-runner/src/reproduce.rs @@ -1,10 +1,13 @@ //! Bit-identical reproduction (`aura reproduce`) — #295. //! -//! A library function here reports a refusal as a returned -//! [`crate::RunnerError`] rather than ending the process. The shell dispatch -//! arm (`aura-cli`'s `dispatch_reproduce`) is the single place that prints -//! the error's message to stderr and calls `std::process::exit` on its code, -//! keeping the observable stderr/exit bytes unchanged (C18). +//! Both outcomes here are values, not process control flow: success returns a +//! [`ReproduceReport`] the caller inspects (`ReproduceReport::all_identical`); +//! a refusal returns a [`crate::RunnerError`] — neither ends the process. The +//! shell dispatch arm (`aura-cli`'s `dispatch_reproduce`) is the single place +//! that renders the report and prints a refusal's message to stderr, calling +//! `std::process::exit` on its code, keeping the observable stderr/exit bytes +//! unchanged (C18); a library embedder (a World program) reads the returned +//! report directly instead. use std::collections::BTreeMap; use std::sync::mpsc; @@ -23,10 +26,18 @@ use crate::RunnerError; /// The outcome of reproducing one persisted family: per member, whether its re-run /// metrics are bit-identical to the stored metrics (C1). +#[derive(Debug)] pub struct ReproduceReport { pub outcomes: Vec<(String, bool)>, } +impl ReproduceReport { + /// True iff every member reproduced bit-identically (C1). + pub fn all_identical(&self) -> bool { + self.outcomes.iter().all(|(_, identical)| *identical) + } +} + /// Look up a persisted family by id, or refuse (exit code 1: unknown id / registry /// load failure) — the single place `reproduce_family` and `reproduce_family_in` /// resolve a family, so the two exit-1 error phrasings can't drift out of sync @@ -68,11 +79,35 @@ pub fn load_family(reg: &Registry, id: &str) -> Result { /// Re-derive every member of a persisted sweep family from the content-addressed store /// and compare to the stored result, against an explicit registry (testable seam). +/// +/// Refusal contract: an identity/pip mismatch (below) refuses class 2 — this is the +/// explicit-source seam, so a caller-given `data` that contradicts the member +/// manifests is argv-named content (C14). `reproduce_family`, the simple/derived +/// path, hits the same guard at class 1 instead (Fork 6, #299: on that path `data` +/// is derived FROM the family's own manifest, so a mismatch there is data drift, not +/// a caller error). A WINDOW guard is deliberately absent: no reproduce path takes a +/// caller window (WF and real-sweep re-runs load stored.manifest.window; MonteCarlo +/// derives its walk from manifest.seed; synthetic uses the full window). pub fn reproduce_family_in( reg: &Registry, id: &str, data: &DataSource, env: &Env, +) -> Result { + reproduce_family_with_class(reg, id, data, env, 2) +} + +/// The shared reproduce loop (#299 Fork 6): `mismatch_class` is the exit code the +/// identity/pip guard refuses with — context-borne, not a property of the guard +/// itself. `reproduce_family_in` (explicit-source seam) passes 2; `reproduce_family` +/// (derived-source seam) passes 1. Guard messages are identical either way; only the +/// class varies. +fn reproduce_family_with_class( + reg: &Registry, + id: &str, + data: &DataSource, + env: &Env, + mismatch_class: i32, ) -> Result { let family = load_family(reg, id)?; let pip = data.pip_size(); @@ -83,6 +118,52 @@ pub fn reproduce_family_in( exit_code: 1, message: "family member has no topology_hash; not a generated run".to_string(), })?; + // Identity/geometry guard (#299): a source whose identity or pip + // contradicts the member manifests makes divergence mechanical. The + // class is context-borne (Fork 6, see this fn's doc): caller error on + // the explicit-source seam, data drift on the derived seam. + if let Some(recorded) = stored.manifest.instrument.as_deref() { + match data { + DataSource::Synthetic => { + return Err(RunnerError { + exit_code: mismatch_class, + message: format!( + "reproduce source mismatch: family member was recorded over instrument '{recorded}', the source is synthetic" + ), + }); + } + DataSource::Real { symbol, .. } if symbol != recorded => { + return Err(RunnerError { + exit_code: mismatch_class, + message: format!( + "reproduce source mismatch: family member was recorded over instrument '{recorded}', the source names '{symbol}'" + ), + }); + } + _ => {} + } + } + // pip has no manifest field of its own: forward-build the label the + // FAMILY MINT stamps for this pip (the r_sma/risk-executor variant — + // `run_blueprint_member` unconditionally stamps that label) and + // compare against the recorded one. The plain-arm branch below + // tolerates a stored record shaped like a single-run stamp (legacy or + // otherwise), not a variant any live re-run of THIS family would + // itself produce. + let expected = if stored.manifest.broker.contains("+risk-executor") { + crate::member::r_sma_broker_label(pip) + } else { + crate::member::sim_optimal_broker_label(pip) + }; + if expected != stored.manifest.broker { + return Err(RunnerError { + exit_code: mismatch_class, + message: format!( + "reproduce broker/pip mismatch: manifest recorded '{}', the source's pip renders '{expected}'", + stored.manifest.broker + ), + }); + } let doc = reg .get_blueprint(&hash) .map_err(|e| RunnerError { exit_code: 1, message: format!("{e}") })? @@ -253,13 +334,17 @@ pub fn reproduce_family_in( /// re-derive a real-data family over the wrong stream; see `reproduce_family_in`'s /// per-member window loader for how the reconstructed source is actually used. /// -/// Prints its own per-member + summary lines to stdout (unchanged from the -/// pre-#295 shell function — this is the report's normal output, not an error -/// path); only the final "not every member reproduced" outcome is a refusal, -/// returned with an EMPTY message (there is no accompanying stderr line today, -/// so the shell dispatch arm must not synthesize one either — see -/// `dispatch_reproduce`). -pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> { +/// Returns the report as a value (#299): divergence is data the caller inspects +/// (`ReproduceReport::all_identical`), not a process exit — this library never +/// prints and never exits. The CLI's `dispatch_reproduce` renders the report and +/// owns the exit; a library embedder (a World program) reads `report.outcomes` +/// directly. +/// +/// This is the derived-source seam (Fork 6, #299): `data` is reconstructed FROM +/// the family's own manifest, not caller-given, so the identity/pip guard inside +/// the shared loop refuses class 1 here (data drift within the stored family), +/// not class 2 (`reproduce_family_in`'s explicit-source class — see its doc). +pub fn reproduce_family(id: &str, env: &Env) -> Result { let reg = env.registry(); let family = load_family(®, id)?; // Reconstruct the DataSource the family was minted over: `None` instrument @@ -272,18 +357,7 @@ pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> { DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env)? } }; - let rep = reproduce_family_in(®, id, &data, env)?; - let total = rep.outcomes.len(); - let ok = rep.outcomes.iter().filter(|(_, b)| *b).count(); - for (label, identical) in &rep.outcomes { - let verdict = if *identical { "bit-identical" } else { "DIVERGED" }; - println!("{id} member {label} reproduced: {verdict}"); - } - println!("reproduced {ok}/{total} members bit-identically"); - if ok != total { - return Err(RunnerError { exit_code: 1, message: String::new() }); - } - Ok(()) + reproduce_family_with_class(®, id, &data, env, 1) } #[cfg(test)] @@ -293,7 +367,9 @@ mod tests { use aura_engine::RunManifest; use aura_registry::{FamilyKind, Registry}; - use super::load_family; + use super::{load_family, reproduce_family_in, ReproduceReport}; + use crate::family::DataSource; + use crate::project::Env; /// A registry over a fresh per-test directory (the #258 tag-keyed pattern: /// fixed name under the build-tree tmp anchor, pre-create wipe). @@ -324,6 +400,116 @@ mod tests { } } + /// A persisted member carrying a `topology_hash` (#299 guard tests): the + /// identity/pip guards fire BEFORE the blueprint fetch, so a seeded record + /// needs a `topology_hash` to clear the earlier "not a generated run" + /// check, but the hash itself never resolves against the store. + fn stamped_report(instrument: Option<&str>, broker: &str) -> RunReport { + RunReport { + manifest: RunManifest { + topology_hash: Some("deadbeef".to_string()), + instrument: instrument.map(str::to_string), + broker: broker.to_string(), + ..minimal_report().manifest + }, + ..minimal_report() + } + } + + /// The instrument guard (#299): a family member recorded over a real + /// instrument refuses class 2 — before any blueprint fetch — when the + /// caller-given source is `Synthetic`, naming the recorded instrument in + /// the message. + #[test] + fn reproduce_family_in_refuses_synthetic_source_for_a_real_instrument_member() { + let reg = temp_registry("guard-synthetic-for-real"); + reg.append_family( + "f", + FamilyKind::Sweep, + &[stamped_report(Some("GER40"), "sim-optimal(pip_size=0.0001)")], + ) + .expect("append family"); + + let err = reproduce_family_in(®, "f", &DataSource::Synthetic, &Env::std()) + .expect_err("a synthetic source must not silently reproduce a real-instrument member"); + assert_eq!(err.exit_code, 2); + assert!(err.message.contains("GER40"), "message: {}", err.message); + assert!(err.message.contains("synthetic"), "message: {}", err.message); + } + + /// The instrument guard's Real-vs-Real twin: a caller-given `Real` source + /// naming a DIFFERENT symbol than the member's recorded instrument refuses + /// class 2, naming both sides. + #[test] + fn reproduce_family_in_refuses_a_real_source_naming_the_wrong_symbol() { + let reg = temp_registry("guard-wrong-symbol"); + reg.append_family( + "f", + FamilyKind::Sweep, + &[stamped_report(Some("GER40"), "sim-optimal(pip_size=0.0001)")], + ) + .expect("append family"); + + // A hand-built `DataSource::Real` fixture: `DataServer::new` merely + // scans its base path (a no-op on a missing directory), so the guard + // — which never touches the server — is exercised without any real + // archive on disk. + let server = std::sync::Arc::new(data_server::DataServer::new("/nonexistent-aura-test-path")); + let data = DataSource::Real { + server, + symbol: "US500".to_string(), + from_ms: None, + to_ms: None, + pip: 1.0, + }; + + let err = reproduce_family_in(®, "f", &data, &Env::std()) + .expect_err("a real source naming the wrong symbol must not silently reproduce"); + assert_eq!(err.exit_code, 2); + assert!(err.message.contains("GER40"), "message: {}", err.message); + assert!(err.message.contains("US500"), "message: {}", err.message); + } + + /// The broker/pip guard (#299): the recorded manifest carries no pip + /// field of its own — the pip survives only inside the broker label — so + /// the guard forward-builds the label the family mint stamps for this pip + /// (tolerating a plain-shape stored record generically, not asserting a + /// live re-run would itself produce it — #299) and refuses + /// class 2 on a label mismatch, naming both labels. + #[test] + fn reproduce_family_in_refuses_a_pip_mismatch_via_the_broker_label() { + let reg = temp_registry("guard-pip-mismatch"); + // Recorded under a pip the actual synthetic run (0.0001, `SYNTHETIC_PIP_SIZE`) + // does not match: the label the re-run would forward-build diverges from + // the stored one, so the guard must fire before any re-run. + reg.append_family( + "f", + FamilyKind::Sweep, + &[stamped_report(None, "sim-optimal(pip_size=0.0002)")], + ) + .expect("append family"); + + let err = reproduce_family_in(®, "f", &DataSource::Synthetic, &Env::std()) + .expect_err("a pip mismatch forward-built via the broker label must refuse"); + assert_eq!(err.exit_code, 2); + assert!(err.message.contains("sim-optimal(pip_size=0.0002)"), "message: {}", err.message); + assert!(err.message.contains("sim-optimal(pip_size=0.0001)"), "message: {}", err.message); + } + + /// `all_identical` is true iff every outcome reproduced bit-identically. + #[test] + fn all_identical_reflects_every_outcome() { + let all_ok = ReproduceReport { + outcomes: vec![("a".to_string(), true), ("b".to_string(), true)], + }; + assert!(all_ok.all_identical()); + + let one_diverged = ReproduceReport { + outcomes: vec![("a".to_string(), true), ("b".to_string(), false)], + }; + assert!(!one_diverged.all_identical()); + } + /// One id vocabulary across enumeration and reproduction (C18, #298): the /// family-identity string a consumer lifts off the registry's own member /// enumeration (`FamilyRunRecord.family`) resolves through the reproduce diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index e373238..e6dc174 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -857,7 +857,12 @@ error; only its environment refusals (a tap-trace store write failure) exit 1. The no-data family (no local data, no data in the requested window, no recorded geometry) belongs to the real-data verbs — `aura run`, `reproduce`, the campaign legs — where it likewise exits 1 (#297; the full -partition lives in [C14](design/contracts/c14-headless-two-faces.md)). +partition lives in [C14](design/contracts/c14-headless-two-faces.md)). `aura +reproduce`'s identity/pip guard (a stored member's recorded instrument or +broker/pip label contradicting the source it re-derives against) is +likewise class 1 on the plain verb — its source is derived from the +family's own manifest, so a mismatch there is data drift, not a caller +error (Fork 6, #299). The class boundary is FORM vs VALUE, not "environment/data/IO vs everything else": an `--override` value that is well-formed but out of the node's own