feat(aura-cli, aura-runner): exec routing-seam refusals + by-name unbound-role prose

Harvest sweep, batch 2 of 6.

- exec's file-target classification is now a four-way shape peek
  (campaign / blueprint / op-script / wrong-kind): a valid-JSON op-script
  array refuses at the routing seam with a build-first pointer ('aura
  graph build') instead of the blueprint leg's false 'not valid JSON'
  claim, and a kind-bearing non-campaign document names the found kind
  plus exec's two executable classes. Exit-code classes unchanged
  (op-script usage-class 2, wrong-kind runtime-class 1).
- the bare-tap measurement leg renders CompileError::UnboundRootRole by
  the signal's own root-role NAME (captured pre-consumption), retiring
  the raw Debug-form leak; every other variant keeps the total fallback.

refs #342
refs #339
This commit is contained in:
2026-07-26 11:47:21 +02:00
parent 6b6086fdef
commit 521459dd50
4 changed files with 186 additions and 39 deletions
+105 -31
View File
@@ -663,13 +663,17 @@ fn parse_override_tokens(tokens: &[String]) -> Vec<(String, Scalar)> {
// The declarative argument grammar. clap owns argv tokenizing, scoped `--help`,
// `--version`, `--flag=value`, `--`, and long-option abbreviation; the `dispatch_*`
// handlers below convert each `*Cmd` into the argument shapes the existing execution
// fns accept. `exec` (#319) is the one document-executing verb: it dispatches on
// `is_blueprint_file` layered with `classify_exec_document_file` (the "kind"-field
// peek) — a first-positional naming an existing `.json` file that parses as JSON
// and carries no top-level "kind" selects the loaded-blueprint leg; a file
// carrying "kind", or a target that is not a readable `.json` file at all (a
// fns accept. `exec` (#319, extended #342) is the one document-executing verb: it
// dispatches on `is_blueprint_file` layered with `classify_exec_document_file`'s
// shape peek (top-level array vs. object, then the "kind" field) — a
// first-positional naming an existing `.json` file that parses as JSON and carries
// no top-level "kind" selects the loaded-blueprint leg; a file carrying
// `"kind":"campaign"`, or a target that is not a readable `.json` file at all (a
// registered content id), selects the campaign leg; a file that does not parse as
// JSON at all refuses neutrally before either leg's own validation runs.
// JSON at all refuses neutrally before either leg's own validation runs; a
// top-level JSON array (an op-script) or a `"kind"` naming anything other than
// "campaign" (e.g. a process document) is likewise refused right at this seam,
// with exec's own vocabulary, before either leg ever sees it.
// `--override`/`--tap` tokens are lexed by `parse_override_tokens`/
// `tap_plan_from_args` above, shared across both legs where applicable. Usage
// errors exit 2; runtime refusals exit 1; a campaign that completes with ≥1
@@ -967,27 +971,63 @@ fn is_blueprint_file(arg: &Option<String>) -> Option<&str> {
.filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file())
}
/// `exec`'s FILE-target shape, discriminated once at the routing seam
/// (#342 items 3-4, extending #319's review Minor-3): a document exec
/// cannot execute at all is refused HERE, with exec's own vocabulary,
/// before either leg's own document-shape validation ever runs — never by
/// falling through to a leg whose own refusal prose assumes a shape this
/// document never had.
enum ExecFileShape {
/// A research-document envelope carrying top-level `"kind":"campaign"`
/// — the campaign leg.
Campaign,
/// No top-level `"kind"` at all (a `{"format_version":N,"blueprint":{…}}`
/// envelope) — the blueprint leg.
Blueprint,
/// A top-level JSON array: a construction op-script (#157) — not an
/// envelope of either executable class, however valid its JSON is.
OpScript,
/// A top-level `"kind"` naming something other than `"campaign"` (e.g.
/// a process document) — a real, kind-bearing document, just not one
/// of exec's two executable classes. Carries the found kind value.
WrongKind(String),
}
/// `exec`'s FILE-target leg discriminator, layered on top of
/// `is_blueprint_file`: a research-document envelope (process/campaign)
/// carries a required top-level `"kind"` field; a blueprint envelope
/// (`{"format_version":N,"blueprint":{…}}`) carries none. `is_blueprint_file`
/// alone only tells "an existing `.json` file" from a bare harness-kind/id
/// token — it cannot tell a campaign document ending in `.json` from a
/// blueprint file, both being ordinary readable `.json` files — so `exec`'s
/// routing peeks this one cheap key before choosing a leg.
/// (`{"format_version":N,"blueprint":{…}}`) carries none; a construction
/// op-script (#157) is a top-level JSON array, not an object at all.
/// `is_blueprint_file` alone only tells "an existing `.json` file" from a
/// bare harness-kind/id token — it cannot tell these shapes apart, all
/// being ordinary readable `.json` files — so `exec`'s routing peeks the
/// top-level shape and the `"kind"` key before choosing a leg.
///
/// `Ok(true)`/`Ok(false)` classifies a file that parses as JSON at all
/// (campaign vs. not); `Err` means the file does NOT parse as JSON at all —
/// review Minor-3: a file this malformed has no leg to misattribute the
/// fault to (neither leg's own "document is not valid JSON" prose is the
/// right cause when the shape was never determined), so the caller refuses
/// neutrally instead of silently falling through to the blueprint leg.
fn classify_exec_document_file(path: &str) -> Result<bool, String> {
/// `Ok(_)` classifies a file that parses as JSON at all; `Err` means the
/// file does NOT parse as JSON at all — review Minor-3: a file this
/// malformed has no leg to misattribute the fault to (neither leg's own
/// "document is not valid JSON" prose is the right cause when the shape was
/// never determined), so the caller refuses neutrally instead of silently
/// falling through to the blueprint leg.
fn classify_exec_document_file(path: &str) -> Result<ExecFileShape, String> {
let text =
std::fs::read_to_string(path).map_err(|e| format!("target file is not readable: {e}"))?;
let v: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| format!("target file is not valid JSON: {e}"))?;
Ok(v.get("kind").is_some())
if v.is_array() {
return Ok(ExecFileShape::OpScript);
}
match v.get("kind") {
None => Ok(ExecFileShape::Blueprint),
Some(k) => {
let found = k.as_str().map(str::to_string).unwrap_or_else(|| k.to_string());
if found == "campaign" {
Ok(ExecFileShape::Campaign)
} else {
Ok(ExecFileShape::WrongKind(found))
}
}
}
}
/// Terminate per a campaign-path result (#272): a String error is a refusal
@@ -1032,26 +1072,60 @@ fn tap_plan_from_args(args: &[String]) -> Result<TapPlan, String> {
/// The one executor verb (#319): a campaign document (file or content id)
/// or a signal blueprint (single run). Flag discipline is per-leg. A
/// `.json` file target is CLASSIFIED once, up front (review Minor-3): a file
/// that does not parse as JSON at all refuses neutrally here, before either
/// leg's own document-shape validation ever runs — so a malformed campaign
/// document is never misattributed to the blueprint leg's "blueprint
/// document is not valid JSON" prose.
/// `.json` file target is CLASSIFIED once, up front (review Minor-3, #342
/// items 3-4): a file that does not parse as JSON at all refuses neutrally
/// here, before either leg's own document-shape validation ever runs — so a
/// malformed campaign document is never misattributed to the blueprint
/// leg's "blueprint document is not valid JSON" prose; an op-script or a
/// kind-bearing non-campaign document is likewise refused right here, with
/// exec's own vocabulary, rather than falling through to a leg whose own
/// validation would misattribute or under-explain the cause.
fn dispatch_exec(a: ExecCmd, env: &aura_runner::project::Env) {
let target = Some(a.target.clone());
let file_path = is_blueprint_file(&target);
let is_campaign_file = match file_path.map(classify_exec_document_file) {
Some(Ok(is_campaign)) => is_campaign,
let shape = match file_path.map(classify_exec_document_file) {
Some(Ok(shape)) => Some(shape),
Some(Err(msg)) => {
eprintln!("aura: exec: {}: {msg}", file_path.expect("Some by the match arm above"));
std::process::exit(2);
}
None => false,
None => None,
};
let blueprint_path = file_path.filter(|_| !is_campaign_file);
match blueprint_path {
Some(path) => exec_blueprint_leg(path, &a.r#override, &a.tap, env),
None => {
match (file_path, shape) {
// #342 item 3: a valid-JSON op-script array is not a document exec
// can execute at all — point at the build step that turns it into
// one, instead of letting it fall through to the blueprint leg's
// "not valid JSON" refusal (false: it IS valid JSON, just the wrong
// shape). Same exit-code class as that refusal (usage-class, 2).
(Some(path), Some(ExecFileShape::OpScript)) => {
eprintln!(
"aura: exec: {path}: this document is an op-script (a JSON array of \
construction ops), not something exec can run directly — build it into a blueprint first \
with `aura graph build < {path}`, then exec the result"
);
std::process::exit(2);
}
// #342 item 4: a kind-bearing document that is not a campaign (e.g.
// a process document) names the found kind and exec's two
// executable classes, instead of the campaign leg's generic
// "kind" key must be "campaign" (which never says WHAT was found or
// what exec accepts at all). Same exit-code class as today's
// fall-through refusal (runtime-class, 1 — mirrors
// `exit_on_campaign_result`'s `Err` arm).
(Some(path), Some(ExecFileShape::WrongKind(found))) => {
eprintln!(
"aura: exec: {path}: this document's kind is \"{found}\", but exec executes \
only a campaign document or a fully-bound blueprint"
);
std::process::exit(1);
}
(Some(path), Some(ExecFileShape::Blueprint)) => {
exec_blueprint_leg(path, &a.r#override, &a.tap, env)
}
// A campaign-kind file, or a non-file target (a bare 64-hex content
// id): both run the campaign leg, which re-resolves the target
// itself (file-vs-id, then its own full parse+validate).
_ => {
if !a.tap.is_empty() {
eprintln!(
"aura: exec: --tap applies only to a blueprint target; a campaign \
+45
View File
@@ -258,6 +258,51 @@ fn exec_malformed_json_file_refuses_neutrally_not_misrouted() {
);
}
/// #342 item 3: a valid-JSON op-script (a top-level JSON array of
/// construction ops) handed to `exec` must not fall through to the
/// blueprint leg's "blueprint document is not valid JSON" refusal — that
/// claim is false, the document IS valid JSON, just the wrong shape. It
/// refuses right at the routing seam with prose that names the op-script
/// diagnosis and points at the build step that turns it into a runnable
/// blueprint (`aura graph build`).
#[test]
fn exec_op_script_file_refuses_with_build_hint_not_invalid_json() {
let dir = temp_cwd("exec-op-script");
let op_script = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"feed","role":"price","into":["fast.series"]},
{"op":"expose","from":"fast.value","as":"bias"}
]"#;
write_doc(&dir, "script.json", op_script);
let (out, code) = run_code_in(&dir, &["exec", "script.json"]);
assert_eq!(code, Some(2), "stdout/stderr: {out}");
assert!(out.contains("op-script"), "must name the op-script diagnosis: {out}");
assert!(out.contains("graph build"), "must point at the build step: {out}");
assert!(
!out.contains("not valid JSON"),
"the document IS valid JSON — must not claim otherwise: {out}"
);
}
/// #342 item 4: a kind-bearing document that is not a campaign (here, a
/// process document) refuses naming the found kind and exec's two
/// executable classes, instead of the campaign leg's generic "the \"kind\"
/// key must be \"campaign\"" — which never says WHAT was found, nor what
/// exec accepts at all.
#[test]
fn exec_process_document_names_found_kind_and_executable_classes() {
let dir = temp_cwd("exec-wrong-kind-process");
write_doc(&dir, "p.process.json", SWEEP_ONLY_PROCESS_DOC);
let (out, code) = run_code_in(&dir, &["exec", "p.process.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("\"process\""), "must name the found kind: {out}");
assert!(
out.contains("campaign document") && out.contains("fully-bound blueprint"),
"must name both of exec's executable classes: {out}"
);
}
// ---------------------------------------------------------------------------
// Task 2: the exec blueprint leg (synthetic single run).
// ---------------------------------------------------------------------------
+6 -6
View File
@@ -2312,10 +2312,9 @@ fn graph_build_accepts_an_open_input_pattern() {
/// through `wrap_r` (an existing, unrelated nesting mechanism unaffected by
/// this cycle), so a bare-tap (no-`bias`) pattern is used here: its
/// `run_measurement` path compiles the signal directly, hitting
/// `CompileError::UnboundRootRole` at bootstrap — via the EXISTING `{e:?}`
/// Debug rendering (a raw index, not a role name); a name mapping there is
/// left for a follow-up (out of this cycle's scope, per the plan's own
/// escape hatch), so this pins the existing form.
/// `CompileError::UnboundRootRole` at bootstrap — rendered by role NAME
/// (#342 item 3), not the raw flat index the earlier form left as a
/// follow-up.
#[test]
fn running_an_open_blueprint_refuses_at_bootstrap() {
let doc = r#"[
@@ -2333,7 +2332,8 @@ fn running_an_open_blueprint_refuses_at_bootstrap() {
assert_eq!(code, Some(1), "an open blueprint refuses standalone at bootstrap, not finish: {stdout} {stderr}");
assert!(stdout.is_empty(), "no report emitted on a bootstrap refusal: {stdout}");
assert!(
stderr.contains("UnboundRootRole"),
"the runnability gate (compile), unchanged, names the fault: {stderr}"
stderr.contains("root role \"price\" is unbound"),
"the runnability gate (compile), unchanged, now names the role, not a raw index: {stderr}"
);
assert!(!stderr.contains("UnboundRootRole"), "Debug leak: {stderr}");
}
+30 -2
View File
@@ -10,7 +10,7 @@
use std::collections::BTreeMap;
use aura_core::{Scalar, Timestamp};
use aura_engine::{Composite, Harness, MeasurementReport, RunManifest};
use aura_engine::{CompileError, Composite, Harness, MeasurementReport, RunManifest};
use crate::member::{key_supply, raw_bound_defaults, resolve_run_data, RunData};
use crate::project::Env;
@@ -51,6 +51,27 @@ pub fn measurement_manifest(
}
}
/// #339 item 3 (a #317 follow-up): `CompileError::UnboundRootRole { role }`
/// carries a flat root-role index — meaningless to a caller who authored a
/// NAMED open role (`{"op":"input","role":"price"}`). `role_names` is
/// `signal.input_roles()`'s own names, read before `compile_with_params`
/// consumes `signal` (mirrors `member::compile_error_prose`'s pre-consumption
/// `names` capture for its `ParamKindMismatch` prose). Every other
/// `CompileError` variant keeps the existing Debug fallback — unreachable on
/// this direct-compile path in practice (arity/kind/wiring faults are caught
/// earlier at `finish()`/`introspect`), but kept total rather than partial.
fn compile_error_prose(e: &CompileError, role_names: &[String]) -> String {
let CompileError::UnboundRootRole { role } = e else {
return format!("this blueprint does not compile to a runnable harness: {e:?}");
};
let name = role_names.get(*role).map(String::as_str).unwrap_or("<unknown>");
format!(
"this blueprint does not compile to a runnable harness: root role \"{name}\" is \
unbound — it is declared open (an `input` role) but there is no enclosing graph \
to wire it when run standalone"
)
}
/// The bare measurement run (C28 phase 3): `run_signal_r` MINUS `wrap_r` and the
/// eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist (C27).
/// No broker, no risk executor, no per-cycle equity/exposure/r recorders — this
@@ -79,12 +100,19 @@ pub fn run_measurement(
std::process::exit(1);
}
let names: Vec<String> = signal.param_space().iter().map(|p| p.name.clone()).collect();
// #339 item 3: `signal`'s own root-role names, captured before
// `compile_with_params` consumes it below — the bare-tap path compiles
// the signal DIRECTLY (unlike the bias/strategy arm's `wrap_r` nesting),
// so an unbound open root role surfaces here as `CompileError::
// UnboundRootRole { role }`, a flat index with no name attached at the
// engine boundary. `role_names` lets `compile_error_prose` resolve it.
let role_names: Vec<String> = signal.input_roles().iter().map(|r| r.name.clone()).collect();
let defaults = raw_bound_defaults(&signal);
let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding);
// Compile the signal DIRECTLY — no wrap_r, no broker/executor/eq-ex-r sinks.
let mut flat = signal.compile_with_params(params).unwrap_or_else(|e| {
eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}");
eprintln!("aura: {}", compile_error_prose(&e, &role_names));
std::process::exit(1);
});