From af5f825d606a9478872fc594bf575aaf7422074d Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 2 Jul 2026 15:44:05 +0200 Subject: [PATCH] plan: 0102 project-as-crate load boundary Nine tasks: aura-core descriptor contract (two-tier repr(C) + build.rs stamps), node_name namespace strip, RunManifest.project Tier-1 field (22-literal sweep), aura-cli project module (discover/load/charter/Env), the Env threading fan-out, provenance stamping, demo-project fixture + five e2e tests, docs/ledger alignment, full regression gate. refs #180 --- docs/plans/0102-project-load-boundary.md | 1449 ++++++++++++++++++++++ 1 file changed, 1449 insertions(+) create mode 100644 docs/plans/0102-project-load-boundary.md diff --git a/docs/plans/0102-project-load-boundary.md b/docs/plans/0102-project-load-boundary.md new file mode 100644 index 0000000..f1bf08e --- /dev/null +++ b/docs/plans/0102-project-load-boundary.md @@ -0,0 +1,1449 @@ +# The project-as-crate load boundary — Implementation Plan + +> **Parent spec:** `docs/specs/0102-project-load-boundary.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to +> run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** an `aura` invocation inside a project directory loads the project's +cdylib through a two-tier ABI-checked descriptor, merges its vocabulary with +`std_vocabulary`, anchors registry/data paths at the project root, and stamps +provenance into the manifest — while outside a project every byte of behaviour +is unchanged. + +**Architecture:** new `aura-core::project` (descriptor contract, both sides +compile it), new `aura-cli::project` (discovery/load/charter/Env), an +`&Env`-threading fan-out through the enumerated dispatch/helper chains, one +Tier-1 manifest field, one fixture project crate proving e2e. + +**Tech Stack:** aura-core (repr(C) descriptor, build.rs), aura-cli (libloading, +toml, cargo-metadata subprocess), aura-engine (RunManifest), docs/ledger. + +**Files this plan creates or modifies:** + +- Create: `crates/aura-core/build.rs` — bake `AURA_RUSTC_VERSION` +- Create: `crates/aura-core/src/project.rs` — descriptor + stamps + macro +- Create: `crates/aura-cli/src/project.rs` — discover/load/charter/`Env` +- Create: `crates/aura-cli/tests/fixtures/demo-project/{Cargo.toml,Aura.toml,.gitignore,src/lib.rs,demo_signal.json}` — fixture project +- Create: `crates/aura-cli/tests/project_load.rs` — e2e + refusal tests +- Modify: `crates/aura-core/src/lib.rs:31-38` — `pub mod project;` +- Modify: `crates/aura-core/src/node.rs:143-147` — namespace strip +- Modify: `crates/aura-engine/src/report.rs:58-60,270-332` — `project` field + struct + legacy test +- Modify: 21 further `RunManifest {` literals (enumerated in Task 3) +- Modify: `crates/aura-cli/Cargo.toml` — add `libloading`, `toml` +- Modify: `crates/aura-cli/src/main.rs` — threading fan-out (sites enumerated per task) +- Modify: `crates/aura-cli/src/graph_construct.rs:105-200` — resolver threading +- Modify: `docs/project-layout.md:33-34`, `docs/glossary.md:20-21`, `docs/design/INDEX.md` (C13 ~1055, C16 1148-1149, C17 1209-1210) + +--- + +### Task 1: aura-core descriptor contract + +**Files:** +- Create: `crates/aura-core/build.rs` +- Create: `crates/aura-core/src/project.rs` +- Modify: `crates/aura-core/src/lib.rs:31-38` + +- [ ] **Step 1: Create `crates/aura-core/build.rs`** + +```rust +//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION` so the +//! project descriptor (src/project.rs) can stamp it. Runs per consuming build: +//! the host binary and a project cdylib each recompile aura-core under their +//! own toolchain, which is exactly what makes the two stamps comparable. + +fn main() { + let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); + let out = std::process::Command::new(&rustc) + .arg("--version") + .output() + .expect("rustc --version"); + println!( + "cargo:rustc-env=AURA_RUSTC_VERSION={}", + String::from_utf8_lossy(&out.stdout).trim() + ); +} +``` + +- [ ] **Step 2: Create `crates/aura-core/src/project.rs`** + +```rust +//! The project-cdylib descriptor contract (C13/C16, cycle 0102). +//! +//! A research project compiles to a cdylib exporting ONE symbol, +//! `AURA_PROJECT`, a [`ProjectDescriptor`]. The descriptor has two ABI tiers: +//! a **C tier** (`magic`, `descriptor_version`, the version stamps, the +//! namespace — all C-compatible field types) the host validates BEFORE +//! trusting anything, and a **Rust tier** (the vocabulary resolver and the +//! enumerable type-id list) the host touches only after both stamps match. +//! The stamp cannot certify the channel it rides on, so it must not itself +//! depend on the Rust ABI it certifies. +//! +//! The stamps are baked at *aura-core compile time* — per consuming build — +//! so host and dylib each carry their own side's truth. + +use crate::node::PrimitiveBuilder; + +/// First 8 bytes of the C tier; ASCII "AURAPROJ". +pub const AURA_DESCRIPTOR_MAGIC: u64 = 0x4155_5241_5052_4F4A; +/// Bumped on any layout change of [`ProjectDescriptor`]. +pub const AURA_DESCRIPTOR_VERSION: u32 = 1; +/// The one exported symbol name (NUL-terminated for the symbol lookup). +pub const AURA_PROJECT_SYMBOL: &[u8] = b"AURA_PROJECT\0"; + +/// The compiling rustc's `--version` line (via build.rs). +pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION"); +/// This aura-core's crate version. +pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// C-ABI string slice: pointer + length over `'static` UTF-8 bytes. +/// No NUL convention needed; readable without trusting the Rust ABI. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct StrSlice { + pub ptr: *const u8, + pub len: usize, +} + +impl StrSlice { + pub const fn new(s: &'static str) -> Self { + Self { ptr: s.as_ptr(), len: s.len() } + } + + /// Read the slice back as UTF-8. + /// + /// # Safety + /// `ptr`/`len` must describe live UTF-8 bytes — true for any descriptor + /// built by [`aura_project!`], whose strings are `'static` and whose + /// library is loaded-and-held (never unloaded). + pub unsafe fn as_str(&self) -> Option<&str> { + if self.ptr.is_null() { + return None; + } + let bytes = unsafe { core::slice::from_raw_parts(self.ptr, self.len) }; + core::str::from_utf8(bytes).ok() + } +} + +/// The exported project descriptor. Field ORDER is ABI: C tier first, +/// Rust tier last; validated front-to-back. +#[repr(C)] +pub struct ProjectDescriptor { + // ---- C tier: validated BEFORE any Rust-ABI field is touched ---- + pub magic: u64, + pub descriptor_version: u32, + pub rustc_version: StrSlice, + pub aura_core_version: StrSlice, + pub namespace: StrSlice, + // ---- Rust tier: only after both stamps match ---- + pub vocabulary: fn(&str) -> Option, + pub type_ids: fn() -> &'static [&'static str], +} + +// The raw pointers are to 'static data; the fn pointers are Sync by nature. +unsafe impl Sync for ProjectDescriptor {} + +/// Declare a project crate's export in one place. Emits the `AURA_PROJECT` +/// static with this build's stamps baked in. +#[macro_export] +macro_rules! aura_project { + (namespace: $ns:literal, vocabulary: $vocab:path, type_ids: $ids:path $(,)?) => { + #[unsafe(no_mangle)] + pub static AURA_PROJECT: $crate::project::ProjectDescriptor = + $crate::project::ProjectDescriptor { + magic: $crate::project::AURA_DESCRIPTOR_MAGIC, + descriptor_version: $crate::project::AURA_DESCRIPTOR_VERSION, + rustc_version: $crate::project::StrSlice::new( + $crate::project::RUSTC_VERSION, + ), + aura_core_version: $crate::project::StrSlice::new( + $crate::project::CORE_VERSION, + ), + namespace: $crate::project::StrSlice::new($ns), + vocabulary: $vocab, + type_ids: $ids, + }; + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_vocab(type_id: &str) -> Option { + let _ = type_id; + None + } + fn fake_ids() -> &'static [&'static str] { + &["t::A"] + } + + aura_project! { + namespace: "t", + vocabulary: fake_vocab, + type_ids: fake_ids, + } + + #[test] + fn stamps_are_baked_and_nonempty() { + assert!(RUSTC_VERSION.starts_with("rustc ")); + assert!(!CORE_VERSION.is_empty()); + } + + #[test] + fn str_slice_round_trips() { + let s = StrSlice::new("hello"); + assert_eq!(unsafe { s.as_str() }, Some("hello")); + } + + #[test] + fn macro_emits_a_valid_descriptor() { + let d = &AURA_PROJECT; + assert_eq!(d.magic, AURA_DESCRIPTOR_MAGIC); + assert_eq!(d.descriptor_version, AURA_DESCRIPTOR_VERSION); + assert_eq!(unsafe { d.namespace.as_str() }, Some("t")); + assert_eq!(unsafe { d.rustc_version.as_str() }, Some(RUSTC_VERSION)); + assert_eq!(unsafe { d.aura_core_version.as_str() }, Some(CORE_VERSION)); + assert!((d.vocabulary)("nope").is_none()); + assert_eq!((d.type_ids)(), &["t::A"]); + } +} +``` + +- [ ] **Step 3: Register the module in `crates/aura-core/src/lib.rs`** + +In the module list (lines 31-38), after the existing `pub mod node;` line add: + +```rust +pub mod project; +``` + +(No new crate-root re-exports: the descriptor surface is addressed as +`aura_core::project::...`; the macro is exported at crate root by +`#[macro_export]` automatically.) + +- [ ] **Step 4: Build and test aura-core** + +Run: `cargo test -p aura-core` +Expected: PASS, including `stamps_are_baked_and_nonempty`, +`str_slice_round_trips`, `macro_emits_a_valid_descriptor`. + +--- + +### Task 2: `node_name()` strips the namespace prefix + +**Files:** +- Modify: `crates/aura-core/src/node.rs:143-147` +- Test: `crates/aura-core/src/node.rs` (tests module, near line 414) + +- [ ] **Step 1: Write the failing test** + +In the existing `#[cfg(test)]` tests module of `node.rs` (beside +`node_name_defaults_to_lowercased_type_label`, line 414), add: + +```rust +#[test] +fn node_name_strips_namespace_prefix() { + let b = PrimitiveBuilder::new( + "demo::Identity", + NodeSchema { inputs: vec![], output: vec![], params: vec![] }, + |_| unreachable!("never built in this test"), + ); + assert_eq!(b.node_name(), "identity"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p aura-core node_name_strips_namespace_prefix` +Expected: FAIL with `assertion ... left: "demo::identity" ... right: "identity"`. + +- [ ] **Step 3: Implement the strip** + +`crates/aura-core/src/node.rs:143-147`, before: + +```rust + pub fn node_name(&self) -> String { + self.instance_name + .clone() + .unwrap_or_else(|| self.name.to_ascii_lowercase()) + } +``` + +After (byte-identical for `::`-free std names): + +```rust + pub fn node_name(&self) -> String { + self.instance_name.clone().unwrap_or_else(|| { + let bare = self.name.rsplit("::").next().unwrap_or(self.name); + bare.to_ascii_lowercase() + }) + } +``` + +- [ ] **Step 4: Run tests to verify green + unchanged pins** + +Run: `cargo test -p aura-core` +Expected: PASS — the new test plus the existing +`node_name_defaults_to_lowercased_type_label` (still `"simbroker"`/`"fast"`). + +--- + +### Task 3: `RunManifest.project` provenance field (Tier-1 additive) + +**Files:** +- Modify: `crates/aura-engine/src/report.rs` (struct at 29-60, tests at 270-332) +- Modify: the 21 other `RunManifest {` struct literals (enumerated in Step 4) + +- [ ] **Step 1: Write the failing legacy-load test** + +In `crates/aura-engine/src/report.rs`, beside the existing +`runmanifest_instrument_round_trips_and_omits_when_none` (lines 288-314), add: + +```rust +#[test] +fn runmanifest_project_field_round_trips_and_omits_when_none() { + // A pre-0102 line (no "project" key) must load with project: None. + let legacy = r#"{"commit":"c","params":[],"window":[0,1],"seed":7,"broker":"b"}"#; + let m: RunManifest = serde_json::from_str(legacy).expect("legacy line loads"); + assert_eq!(m.project, None); + // None must serialize with the key absent (byte-stability of old paths). + let out = serde_json::to_string(&m).expect("serializes"); + assert!(!out.contains("\"project\"")); + // Some(..) must round-trip. + let p = ProjectProvenance { + namespace: "demo".into(), + dylib_sha256: "ab".repeat(32), + commit: None, + }; + let m2 = RunManifest { project: Some(p.clone()), ..m }; + let s = serde_json::to_string(&m2).expect("serializes"); + let back: RunManifest = serde_json::from_str(&s).expect("round-trips"); + assert_eq!(back.project, Some(p)); +} +``` + +- [ ] **Step 2: Run test to verify it fails to compile** + +Run: `cargo test -p aura-engine runmanifest_project_field` +Expected: COMPILE FAIL — `no field 'project' on type RunManifest` / +`cannot find type ProjectProvenance`. + +- [ ] **Step 3: Add the struct and the field** + +In `crates/aura-engine/src/report.rs`, directly after the `topology_hash` +field (lines 58-59), add: + +```rust + /// Identity of the loaded project dylib, when the run used one (cycle + /// 0102). Pre-0102 records read as `None`; `None` serializes as an absent + /// key (the same one-directional widening as `instrument`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, +``` + +Below the `RunManifest` struct (after its closing brace), add: + +```rust +/// Provenance of the project cdylib a run was resolved through (C16/C18). +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ProjectProvenance { + /// The project's vocabulary namespace (from the descriptor). + pub namespace: String, + /// SHA-256 over the loaded dylib file's bytes. + pub dylib_sha256: String, + /// The project repo's HEAD (+ `-dirty`), when derivable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commit: Option, +} +``` + +Re-export it beside the other report types: in +`crates/aura-engine/src/lib.rs`, extend the existing `report::` re-export +list (line 74 area) with `ProjectProvenance`. + +- [ ] **Step 4: Thread `project: None` through every struct literal** + +Run: `cargo build --workspace 2>&1 | grep -c 'missing field .project'` +Expected: a nonzero count naming exactly these literals (E0063). Add +`project: None,` after the `topology_hash: None,` (or last) field in each: + +- `crates/aura-cli/src/main.rs:174` (`sim_optimal_manifest` — covers all 20 CLI call sites) +- `crates/aura-engine/src/blueprint.rs:997` +- `crates/aura-engine/src/mc.rs:239, 265` +- `crates/aura-engine/src/report.rs:279, 297, 307, 325, 456, 561, 592, 615` +- `crates/aura-engine/src/sweep.rs:664` +- `crates/aura-engine/src/walkforward.rs:288` +- `crates/aura-ingest/examples/ger40_breakout_sweep.rs:63` +- `crates/aura-ingest/examples/ger40_breakout_walkforward.rs:73` +- `crates/aura-ingest/examples/shared/breakout_real.rs:390` +- `crates/aura-ingest/tests/ger40_breakout_world.rs:65` +- `crates/aura-ingest/tests/real_bars.rs:82` +- `crates/aura-ingest/tests/streaming_seam.rs:82` +- `crates/aura-registry/src/compat.rs:74` +- `crates/aura-registry/src/lib.rs:583, 935` +- `crates/aura-registry/src/trace_store.rs:250` + +- [ ] **Step 5: Verify green, workspace-wide** + +Run: `cargo test --workspace` +Expected: PASS — the new test green; every pre-existing test (incl. the +manifest-shape pins in `cli_run.rs:34-39` and the goldens) unchanged, since +`None` serializes as an absent key. + +--- + +### Task 4: aura-cli project module (discover / load / charter / Env) + +**Files:** +- Modify: `crates/aura-cli/Cargo.toml` +- Create: `crates/aura-cli/src/project.rs` +- Modify: `crates/aura-cli/src/main.rs` (only the `mod project;` line this task) + +- [ ] **Step 1: Add the two dependencies** + +In `crates/aura-cli/Cargo.toml` `[dependencies]` (near `sha2`, line 42), add: + +```toml +libloading = "0.8" +toml = "0.8" +``` + +(Per-case admission recorded in the spec: research-side leaf binary, the clap +precedent; the frozen deploy artifact cannot pick either up.) + +- [ ] **Step 2: Create `crates/aura-cli/src/project.rs`** + +```rust +//! Project discovery + the cdylib load boundary (cycle 0102, C13/C16). +//! +//! `discover_from` walks up to the nearest `Aura.toml` (the way cargo finds +//! `Cargo.toml`); `load` locates the project's cdylib via `cargo metadata`, +//! loads it **load-and-hold** (leaked, never unloaded), validates the C tier +//! of the descriptor BEFORE touching any Rust-ABI field, then applies the +//! vocabulary charter. `Env` is the per-invocation context every verb reads: +//! merged resolver, runs root, data path, provenance. + +use aura_core::PrimitiveBuilder; +use aura_core::project::{ + AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL, + CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, +}; +use aura_engine::ProjectProvenance; +use aura_registry::{Registry, TraceStore}; +use aura_std::{std_vocabulary, std_vocabulary_types}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::path::{Path, PathBuf}; + +/// Parsed `Aura.toml` — static project context only (C17): paths, nothing else. +#[derive(Debug, Default, PartialEq, serde::Deserialize)] +pub struct AuraToml { + #[serde(default)] + pub paths: AuraPaths, +} + +#[derive(Debug, Default, PartialEq, serde::Deserialize)] +pub struct AuraPaths { + /// Data archive root; default: the data-server default. + #[serde(default)] + pub data: Option, + /// Registry root, relative to the project root; default `"runs"`. + #[serde(default)] + pub runs: Option, +} + +#[derive(Debug)] +pub enum ProjectError { + Toml(PathBuf, String), + CargoMetadata(String), + ArtifactMissing(PathBuf), + Load(PathBuf, String), + NotAProjectDylib(PathBuf), + Incompatible { what: &'static str, dylib: String, host: String }, + Charter(String), +} + +impl fmt::Display for ProjectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Toml(p, e) => write!(f, "failed to parse {}: {e}", p.display()), + Self::CargoMetadata(e) => write!(f, "cargo metadata failed: {e}"), + Self::ArtifactMissing(p) => write!( + f, + "project dylib not found at {} — run `cargo build` in the \ + project first (or pass --release to load the release build)", + p.display() + ), + Self::Load(p, e) => write!(f, "failed to load {}: {e}", p.display()), + Self::NotAProjectDylib(p) => write!( + f, + "{} exports no valid AURA_PROJECT descriptor (not an aura \ + project dylib, or an incompatible descriptor layout)", + p.display() + ), + Self::Incompatible { what, dylib, host } => write!( + f, + "project dylib is ABI-incompatible: {what} mismatch \ + (dylib: {dylib}, host: {host}) — rebuild the project with \ + the host's toolchain/aura-core" + ), + Self::Charter(e) => write!(f, "project vocabulary rejected: {e}"), + } + } +} + +/// A successfully loaded project. The `Library` behind `resolver`/`type_ids` +/// is leaked (load-and-hold): the fn pointers are valid for 'static. +pub struct ProjectEnv { + pub root: PathBuf, + pub toml: AuraToml, + pub namespace: String, + pub dylib_sha256: String, + pub commit: Option, + resolver: fn(&str) -> Option, + type_id_list: &'static [&'static str], +} + +impl ProjectEnv { + fn resolve(&self, type_id: &str) -> Option { + (self.resolver)(type_id) + } +} + +/// The per-invocation context every verb reads. Outside a project every +/// accessor collapses to today's literal defaults (byte-identical behaviour). +pub struct Env { + project: Option, +} + +impl Env { + pub fn std() -> Self { + Self { project: None } + } + pub fn with_project(p: ProjectEnv) -> Self { + Self { project: Some(p) } + } + + /// The one resolver every verb consumes: project first, then std. The + /// charter makes overlap impossible, so order is not load-bearing. + pub fn resolve(&self, type_id: &str) -> Option { + match &self.project { + Some(p) => p.resolve(type_id).or_else(|| std_vocabulary(type_id)), + None => std_vocabulary(type_id), + } + } + + /// project ∪ std type ids, project first (for `introspect --vocabulary`). + pub fn type_ids(&self) -> Vec<&'static str> { + let mut out: Vec<&'static str> = Vec::new(); + if let Some(p) = &self.project { + out.extend_from_slice(p.type_id_list); + } + out.extend_from_slice(std_vocabulary_types()); + out + } + + /// The registry root: `/` inside a project, + /// the literal `runs` outside (today's behaviour). + pub fn runs_root(&self) -> PathBuf { + match &self.project { + Some(p) => { + let runs = p.toml.paths.runs.clone().unwrap_or_else(|| "runs".into()); + p.root.join(runs) + } + None => PathBuf::from("runs"), + } + } + + pub fn registry(&self) -> Registry { + Registry::open(self.runs_root().join("runs.jsonl")) + } + + pub fn trace_store(&self) -> TraceStore { + TraceStore::open(self.runs_root()) + } + + /// The data archive root: `paths.data` inside a project when set, + /// the data-server default otherwise. + pub fn data_path(&self) -> String { + self.project + .as_ref() + .and_then(|p| p.toml.paths.data.clone()) + .unwrap_or_else(|| data_server::DEFAULT_DATA_PATH.to_string()) + } + + pub fn provenance(&self) -> Option { + self.project.as_ref().map(|p| ProjectProvenance { + namespace: p.namespace.clone(), + dylib_sha256: p.dylib_sha256.clone(), + commit: p.commit.clone(), + }) + } +} + +/// Walk up from `start` to the nearest directory containing `Aura.toml`. +pub fn discover_from(start: &Path) -> Option { + let mut dir = Some(start); + while let Some(d) = dir { + if d.join("Aura.toml").is_file() { + return Some(d.to_path_buf()); + } + dir = d.parent(); + } + None +} + +fn parse_aura_toml(root: &Path) -> Result { + let path = root.join("Aura.toml"); + let text = std::fs::read_to_string(&path) + .map_err(|e| ProjectError::Toml(path.clone(), e.to_string()))?; + toml::from_str(&text).map_err(|e| ProjectError::Toml(path, e.to_string())) +} + +/// Derive the cdylib artifact path from `cargo metadata` output (pure — +/// unit-testable on a canned JSON document). +fn artifact_from_metadata( + metadata_json: &str, + release: bool, +) -> Result { + let v: serde_json::Value = serde_json::from_str(metadata_json) + .map_err(|e| ProjectError::CargoMetadata(e.to_string()))?; + let target_dir = v["target_directory"] + .as_str() + .ok_or_else(|| ProjectError::CargoMetadata("no target_directory".into()))?; + let packages = v["packages"] + .as_array() + .ok_or_else(|| ProjectError::CargoMetadata("no packages".into()))?; + let lib_name = packages + .iter() + .flat_map(|p| p["targets"].as_array().into_iter().flatten()) + .find(|t| { + t["kind"] + .as_array() + .is_some_and(|k| k.iter().any(|s| s.as_str() == Some("cdylib"))) + }) + .and_then(|t| t["name"].as_str()) + .ok_or_else(|| { + ProjectError::CargoMetadata( + "no cdylib target in the project crate (is `crate-type = \ + [\"cdylib\"]` set?)" + .into(), + ) + })?; + let profile = if release { "release" } else { "debug" }; + let file = format!( + "{}{}{}", + std::env::consts::DLL_PREFIX, + lib_name.replace('-', "_"), + std::env::consts::DLL_SUFFIX + ); + Ok(Path::new(target_dir).join(profile).join(file)) +} + +fn artifact_path(root: &Path, release: bool) -> Result { + let out = std::process::Command::new("cargo") + .args(["metadata", "--format-version", "1", "--no-deps"]) + .current_dir(root) + .output() + .map_err(|e| ProjectError::CargoMetadata(e.to_string()))?; + if !out.status.success() { + return Err(ProjectError::CargoMetadata( + String::from_utf8_lossy(&out.stderr).trim().to_string(), + )); + } + artifact_from_metadata(&String::from_utf8_lossy(&out.stdout), release) +} + +/// The vocabulary charter (decision log on the milestone reference issue): +/// non-empty namespace; every listed id `\::`-prefixed; no duplicate in +/// the merged project ∪ std set; every listed id resolves (list↔resolver +/// cross-check). Pure — unit-testable without a dylib. +fn check_charter( + namespace: &str, + ids: &[&str], + resolve: &dyn Fn(&str) -> Option, +) -> Result<(), ProjectError> { + if namespace.is_empty() { + return Err(ProjectError::Charter("empty namespace".into())); + } + let prefix = format!("{namespace}::"); + let mut seen = std::collections::BTreeSet::new(); + for id in ids { + if !id.starts_with(&prefix) { + return Err(ProjectError::Charter(format!( + "type id `{id}` lacks the project prefix `{prefix}`" + ))); + } + if !seen.insert(*id) { + return Err(ProjectError::Charter(format!("duplicate type id `{id}`"))); + } + if std_vocabulary(id).is_some() || std_vocabulary_types().contains(id) { + return Err(ProjectError::Charter(format!( + "type id `{id}` collides with the std vocabulary" + ))); + } + if resolve(id).is_none() { + return Err(ProjectError::Charter(format!( + "listed type id `{id}` does not resolve through the project \ + resolver (list/resolver drift)" + ))); + } + } + Ok(()) +} + +fn project_commit(root: &Path) -> Option { + let head = std::process::Command::new("git") + .args(["-C"]) + .arg(root) + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success())?; + let mut sha = String::from_utf8_lossy(&head.stdout).trim().to_string(); + let dirty = std::process::Command::new("git") + .args(["-C"]) + .arg(root) + .args(["status", "--porcelain"]) + .output() + .ok() + .filter(|o| o.status.success())?; + if !dirty.stdout.is_empty() { + sha.push_str("-dirty"); + } + Some(sha) +} + +/// Locate, load (load-and-hold), verify, and charter-check the project dylib. +pub fn load(root: &Path, release: bool) -> Result { + let toml = parse_aura_toml(root)?; + let dylib_path = artifact_path(root, release)?; + if !dylib_path.is_file() { + return Err(ProjectError::ArtifactMissing(dylib_path)); + } + let bytes = std::fs::read(&dylib_path) + .map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?; + let dylib_sha256 = format!("{:x}", Sha256::digest(&bytes)); + + // Load-and-hold: leak the Library so every pointer read from the + // descriptor is valid for 'static. There is deliberately no unload path + // (one-shot process; ledger C13 note). + let lib = unsafe { libloading::Library::new(&dylib_path) } + .map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?; + let lib: &'static libloading::Library = Box::leak(Box::new(lib)); + + let sym = unsafe { lib.get::<*const ProjectDescriptor>(AURA_PROJECT_SYMBOL) } + .map_err(|_| ProjectError::NotAProjectDylib(dylib_path.clone()))?; + let desc_ptr: *const ProjectDescriptor = *sym; + if desc_ptr.is_null() { + return Err(ProjectError::NotAProjectDylib(dylib_path)); + } + + // ---- C tier, validated front-to-back; no Rust-ABI field touched yet ---- + let magic = unsafe { (*desc_ptr).magic }; + if magic != AURA_DESCRIPTOR_MAGIC { + return Err(ProjectError::NotAProjectDylib(dylib_path)); + } + let version = unsafe { (*desc_ptr).descriptor_version }; + if version != AURA_DESCRIPTOR_VERSION { + return Err(ProjectError::Incompatible { + what: "descriptor version", + dylib: version.to_string(), + host: AURA_DESCRIPTOR_VERSION.to_string(), + }); + } + let dylib_rustc = unsafe { (*desc_ptr).rustc_version.as_str() } + .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.clone()))?; + if dylib_rustc != RUSTC_VERSION { + return Err(ProjectError::Incompatible { + what: "rustc version", + dylib: dylib_rustc.to_string(), + host: RUSTC_VERSION.to_string(), + }); + } + let dylib_core = unsafe { (*desc_ptr).aura_core_version.as_str() } + .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.clone()))?; + if dylib_core != CORE_VERSION { + return Err(ProjectError::Incompatible { + what: "aura-core version", + dylib: dylib_core.to_string(), + host: CORE_VERSION.to_string(), + }); + } + let namespace = unsafe { (*desc_ptr).namespace.as_str() } + .ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.clone()))? + .to_string(); + + // ---- Rust tier: stamps match, the ABI is trusted from here on ---- + let desc: &'static ProjectDescriptor = unsafe { &*desc_ptr }; + let resolver = desc.vocabulary; + let type_id_list = (desc.type_ids)(); + check_charter(&namespace, type_id_list, &|t| resolver(t))?; + + Ok(ProjectEnv { + root: root.to_path_buf(), + toml, + namespace, + dylib_sha256, + commit: project_commit(root), + resolver, + type_id_list, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discover_walks_up_to_aura_toml() { + let tmp = std::env::temp_dir().join(format!("aura-disc-{}", std::process::id())); + let nested = tmp.join("a/b/c"); + std::fs::create_dir_all(&nested).unwrap(); + assert_eq!(discover_from(&nested), None); + std::fs::write(tmp.join("Aura.toml"), "").unwrap(); + assert_eq!(discover_from(&nested), Some(tmp.clone())); + assert_eq!(discover_from(&tmp), Some(tmp.clone())); + std::fs::remove_dir_all(&tmp).unwrap(); + } + + #[test] + fn aura_toml_parses_empty_partial_and_unknown_keys() { + let t: AuraToml = toml::from_str("").unwrap(); + assert_eq!(t, AuraToml::default()); + let t: AuraToml = toml::from_str("[paths]\nruns = \"r\"").unwrap(); + assert_eq!(t.paths.runs, Some(PathBuf::from("r"))); + assert_eq!(t.paths.data, None); + // unknown keys tolerated (forward-compat; serde default is lenient) + let t: AuraToml = toml::from_str("[future]\nx = 1").unwrap(); + assert_eq!(t, AuraToml::default()); + } + + #[test] + fn artifact_path_derives_from_metadata_json() { + let json = r#"{ + "target_directory": "/tmp/proj/target", + "packages": [{"targets": [ + {"kind": ["cdylib"], "name": "demo-project"} + ]}] + }"#; + let p = artifact_from_metadata(json, false).unwrap(); + let expect = format!( + "/tmp/proj/target/debug/{}demo_project{}", + std::env::consts::DLL_PREFIX, + std::env::consts::DLL_SUFFIX + ); + assert_eq!(p, PathBuf::from(expect)); + let p = artifact_from_metadata(json, true).unwrap(); + assert!(p.to_string_lossy().contains("/release/")); + // no cdylib target -> named error + let bad = r#"{"target_directory":"/t","packages":[{"targets":[{"kind":["lib"],"name":"x"}]}]}"#; + assert!(matches!( + artifact_from_metadata(bad, false), + Err(ProjectError::CargoMetadata(_)) + )); + } + + fn none_resolver(_: &str) -> Option { + None + } + + #[test] + fn charter_rejects_each_violation_and_accepts_valid() { + let ok_resolver = |t: &str| { + if t == "p::A" || t == "p::B" { std_vocabulary("SMA") } else { None } + }; + // valid + assert!(check_charter("p", &["p::A", "p::B"], &ok_resolver).is_ok()); + // empty namespace + assert!(matches!( + check_charter("", &[], &none_resolver), + Err(ProjectError::Charter(_)) + )); + // unprefixed id + let e = check_charter("p", &["A"], &ok_resolver).unwrap_err(); + assert!(e.to_string().contains("lacks the project prefix")); + // duplicate id + let e = check_charter("p", &["p::A", "p::A"], &ok_resolver).unwrap_err(); + assert!(e.to_string().contains("duplicate")); + // list/resolver drift + let e = check_charter("p", &["p::C"], &ok_resolver).unwrap_err(); + assert!(e.to_string().contains("does not resolve")); + } + + #[test] + fn env_std_collapses_to_current_defaults() { + let env = Env::std(); + assert_eq!(env.runs_root(), PathBuf::from("runs")); + assert_eq!(env.data_path(), data_server::DEFAULT_DATA_PATH.to_string()); + assert!(env.provenance().is_none()); + assert!(env.resolve("SMA").is_some()); + assert!(env.resolve("demo::Identity").is_none()); + assert!(env.type_ids().contains(&"SMA")); + } +} +``` + +- [ ] **Step 3: Register the module** + +In `crates/aura-cli/src/main.rs`, beside the existing `mod graph_construct;` +line near the top of the file, add: + +```rust +mod project; +``` + +- [ ] **Step 4: Build and unit-test the module** + +Run: `cargo test -p aura-cli project::` +Expected: PASS — `discover_walks_up_to_aura_toml`, +`aura_toml_parses_empty_partial_and_unknown_keys`, +`artifact_path_derives_from_metadata_json`, +`charter_rejects_each_violation_and_accepts_valid`, +`env_std_collapses_to_current_defaults`. + +--- + +### Task 5: threading fan-out (`&Env` through the verb chains) + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` (sites enumerated per step) +- Modify: `crates/aura-cli/src/graph_construct.rs:105-200` + +All edits in this one task — the compile gate at the end is unsatisfiable +until every caller is threaded (self-review check 7). + +- [ ] **Step 1: global `--release` flag + Env construction in `main()`** + +`Cli` struct (main.rs:3669-3674) gains one field after `command`: + +```rust + /// Load the project dylib from target/release instead of target/debug. + #[arg(long, global = true)] + release: bool, +``` + +In `fn main()` (main.rs:4561), after `Cli::parse()` and before the dispatch +match (4571), insert: + +```rust + let env = match std::env::current_dir() + .ok() + .and_then(|d| project::discover_from(&d)) + { + Some(root) => match project::load(&root, cli.release) { + Ok(p) => project::Env::with_project(p), + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(1); + } + }, + None => project::Env::std(), + }; +``` + +and thread `&env` into every arm of the dispatch match: each +`dispatch_X(cmd)` becomes `dispatch_X(cmd, &env)`. + +- [ ] **Step 2: dispatch signatures** + +Each of the nine dispatch fns gains a trailing `env: &project::Env` +parameter (signature edit only, one line each): + +`dispatch_run` (4170), `dispatch_chart` (4241), `dispatch_graph` (4249), +`dispatch_generalize` (4257), `dispatch_runs` (4265), `dispatch_reproduce` +(4279), `dispatch_sweep` (4285), `dispatch_walkforward` (4383), +`dispatch_mc` (4462). + +- [ ] **Step 3: thread the resolver chains** + +Each fn below gains a trailing `env: &project::Env` parameter, and every +`&|t| std_vocabulary(t)` inside it becomes `&|t| env.resolve(t)`; callers +pass `env` down: + +- `reproduce_family_in` (fn 2199; site 2233) ← `reproduce_family` (2305) ← + `dispatch_reproduce` +- `blueprint_axis_probe` (fn 2880; site 2881) ← `list_blueprint_axes` (2893), + `dispatch_run` (4201), sweep/wf/mc probes +- `blueprint_sweep_family` (fn 2914; site 2923), `blueprint_sweep_over` + (fn 2971; site 2975), `run_oos_blueprint` (fn 2999; site 3003), + `blueprint_walkforward_family` (fn 3018; site 3031), `blueprint_mc_family` + (fn 3082; site 3084), `run_blueprint_sweep` (fn 3153; site 3170), + `run_blueprint_walkforward` (fn 3196; site 3207), `run_blueprint_mc` + (fn 3226; site 3240) +- direct dispatch sites: 4194 (`dispatch_run`), 4294 (`dispatch_sweep`), + 4391 (`dispatch_walkforward`), 4470 (`dispatch_mc`) + +In `graph_construct.rs`: `build_from_str` (109), `introspect_node` (150), +`introspect_unwired` (168) gain `env: &crate::project::Env`; sites 113/151/170 +swap `std_vocabulary` → `|t| env.resolve(t)` (matching each site's existing +call form); the `--vocabulary` listing at 196 iterates `env.type_ids()` +instead of `std_vocabulary_types()`; `build_cmd` (127) and `introspect_cmd` +(184) thread `env` from `dispatch_graph`. + +Test-code and fieldtest callers of these fns (e.g. `main.rs:5592-5848`) pass +`&project::Env::std()`. + +- [ ] **Step 4: registry, trace-store, and data-path sites** + +- Delete `fn default_registry()` (main.rs:1513-1515). Replace its 10 call + sites (1618, 1673, 1695, 2014, 2100, 2120, 2306, 3159, 3198, 3231) with + `env.registry()`, threading `env` into each enclosing fn that lacks it. +- Replace the 10 `TraceStore::open("runs")` sites (201, 503, 559, 598, 1613, + 1690, 2009, 2408, 3488, 3588) with `env.trace_store()`, threading `env` + likewise (the two write-side helpers around 201/3588 gain an `env` param). +- Replace the 4 production `data_server::DEFAULT_DATA_PATH` uses (660, 677, + 718, 742) with `env.data_path()` (the `DataServer::new(...)` constructions + at 718/742 take `env.data_path()`); test-only uses stay. + +- [ ] **Step 5: compile gate + suite** + +Run: `cargo build --workspace` +Expected: 0 errors (every threaded caller updated in this task). + +Run: `cargo test --workspace` +Expected: PASS, all pre-existing tests unchanged — outside a project every +`Env::std()` accessor returns today's literals, so goldens and manifest-shape +pins (`cli_run.rs:34-39`) stay byte-identical. + +--- + +### Task 6: provenance stamping on the blueprint-run paths + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` (three stamp spots) + +- [ ] **Step 1: stamp beside `topology_hash`** + +At each spot where `topology_hash` is stamped today, add the project stamp +directly below it: + +- `run_signal_r` (stamps at 2821): after + `report.manifest.topology_hash = Some(...);` add + `report.manifest.project = env.provenance();` +- `run_blueprint_member` (manifest built 2853, stamped 2855): after the + `topology_hash` stamp add `manifest.project = env.provenance();` +- `run_r_sma` (stamps at 3554): after the stamp add + `report.manifest.project = env.provenance();` + +Thread `env` into these fns if Task 5 did not already reach them +(`run_blueprint_member` at 2834 receives it from its reload-closure callers). + +- [ ] **Step 2: verify outside-project bytes unchanged** + +Run: `cargo test -p aura-cli --test cli_run` +Expected: PASS — `env.provenance()` is `None` outside a project, the key is +skipped, stdout pins unchanged. + +--- + +### Task 7: fixture project + e2e proof + +**Files:** +- Create: `crates/aura-cli/tests/fixtures/demo-project/Cargo.toml` +- Create: `crates/aura-cli/tests/fixtures/demo-project/Aura.toml` +- Create: `crates/aura-cli/tests/fixtures/demo-project/.gitignore` +- Create: `crates/aura-cli/tests/fixtures/demo-project/src/lib.rs` +- Create: `crates/aura-cli/tests/fixtures/demo-project/demo_signal.json` +- Create: `crates/aura-cli/tests/project_load.rs` + +- [ ] **Step 1: fixture `Cargo.toml`** + +```toml +[package] +name = "demo-project" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +aura-core = { path = "../../../../aura-core" } + +# Standalone workspace root: never a member of the engine workspace +# (docs/project-layout.md, the empty-[workspace] note). +[workspace] +``` + +- [ ] **Step 2: fixture `Aura.toml` and `.gitignore`** + +`Aura.toml`: + +```toml +# demo fixture — static project context only (C17); paths only (cycle 0102). +[paths] +runs = "runs" +``` + +`.gitignore`: + +```gitignore +/target +Cargo.lock +/runs +``` + +- [ ] **Step 3: fixture `src/lib.rs`** + +```rust +//! Fixture project for the load-boundary e2e (cycle 0102): one pass-through +//! node under the `demo` namespace, exported via the descriptor macro. + +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, + ScalarKind, +}; + +/// One-input f64 pass-through. Emits `None` until its input has a value. +pub struct Identity { + out: [Cell; 1], +} + +impl Identity { + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "demo::Identity", + NodeSchema { + inputs: vec![PortSpec { + kind: ScalarKind::F64, + firing: Firing::Any, + name: "value".into(), + }], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(Identity::new()), + ) + } +} + +impl Default for Identity { + fn default() -> Self { + Self::new() + } +} + +impl Node for Identity { + fn lookbacks(&self) -> Vec { + vec![1] + } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let w = ctx.f64_in(0); + if w.is_empty() { + return None; + } + self.out[0] = Cell::from_f64(w[0]); + Some(&self.out) + } + fn label(&self) -> String { + "demo::Identity".to_string() + } +} + +fn vocabulary(type_id: &str) -> Option { + match type_id { + "demo::Identity" => Some(Identity::builder()), + _ => None, + } +} + +fn type_ids() -> &'static [&'static str] { + &["demo::Identity"] +} + +aura_core::aura_project! { + namespace: "demo", + vocabulary: vocabulary, + type_ids: type_ids, +} +``` + +- [ ] **Step 4: fixture `demo_signal.json`** + +`tests/fixtures/sma_signal.json` extended by one `demo::Identity` stage on the +bias output (the graph genuinely needs the project vocabulary to load): + +```json +{"format_version":1,"blueprint":{"name":"demo_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"demo::Identity"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}} +``` + +- [ ] **Step 5: e2e tests `crates/aura-cli/tests/project_load.rs`** + +```rust +//! E2E: the project-as-crate load boundary (cycle 0102). Builds the fixture +//! project once (cargo, same toolchain, path-dep on this workspace's +//! aura-core), then drives the aura binary from inside the fixture dir. +//! `aura run` persists nothing without --trace, so the tracked fixture stays +//! clean (target/, Cargo.lock, runs/ are fixture-gitignored). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::OnceLock; + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project") +} + +fn built_fixture() -> &'static PathBuf { + static BUILT: OnceLock = OnceLock::new(); + BUILT.get_or_init(|| { + let dir = fixture_dir(); + let out = Command::new("cargo") + .arg("build") + .current_dir(&dir) + .output() + .expect("spawn cargo build for the fixture project"); + assert!( + out.status.success(), + "fixture build failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + dir + }) +} + +fn aura(args: &[&str], cwd: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_aura")) + .args(args) + .current_dir(cwd) + .output() + .expect("run aura") +} + +#[test] +fn project_run_resolves_demo_node_and_is_bit_identical() { + let dir = built_fixture(); + let a = aura(&["run", "demo_signal.json"], dir); + assert!( + a.status.success(), + "run failed: {}", + String::from_utf8_lossy(&a.stderr) + ); + let b = aura(&["run", "demo_signal.json"], dir); + assert!(b.status.success()); + assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)"); +} + +#[test] +fn project_run_manifest_carries_provenance() { + let dir = built_fixture(); + let out = aura(&["run", "demo_signal.json"], dir); + assert!(out.status.success()); + let v: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("run report is JSON"); + let p = &v["manifest"]["project"]; + assert_eq!(p["namespace"], "demo"); + let sha = p["dylib_sha256"].as_str().expect("hash present"); + assert_eq!(sha.len(), 64, "sha256 hex"); +} + +#[test] +fn introspect_vocabulary_lists_project_types() { + let dir = built_fixture(); + let out = aura(&["graph", "introspect", "--vocabulary"], dir); + assert!(out.status.success()); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("demo::Identity"), "project id listed"); + assert!(text.contains("SMA"), "std ids still listed"); +} + +#[test] +fn outside_a_project_the_demo_blueprint_is_unknown() { + // cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree. + let cwd = Path::new(env!("CARGO_MANIFEST_DIR")); + let bp = fixture_dir().join("demo_signal.json"); + let out = aura(&["run", bp.to_str().unwrap()], cwd); + assert!(!out.status.success()); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("UnknownNodeType"), "stderr: {err}"); +} + +#[test] +fn missing_artifact_refuses_with_build_hint() { + // A minimal never-built project: valid cargo metadata, no dylib on disk. + let tmp = std::env::temp_dir().join(format!("aura-nobuild-{}", std::process::id())); + std::fs::create_dir_all(tmp.join("src")).unwrap(); + std::fs::write(tmp.join("Aura.toml"), "").unwrap(); + std::fs::write(tmp.join("src/lib.rs"), "").unwrap(); + std::fs::write( + tmp.join("Cargo.toml"), + "[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n", + ) + .unwrap(); + let out = aura(&["run", "x.json"], &tmp); + assert_eq!(out.status.code(), Some(1), "runtime refusal"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("cargo build"), "build hint present: {err}"); + std::fs::remove_dir_all(&tmp).ok(); +} +``` + +- [ ] **Step 6: run the e2e suite** + +Run: `cargo test -p aura-cli --test project_load` +Expected: PASS, all five tests (first run pays the fixture cargo build). + +--- + +### Task 8: docs and ledger alignment + +**Files:** +- Modify: `docs/project-layout.md:33-34` +- Modify: `docs/glossary.md:20-21` +- Modify: `docs/design/INDEX.md` (C13 block ~1046-1055; C16 1148-1149; C17 1209-1210) + +- [ ] **Step 1: project-layout.md** + +Lines 33-34, before: + +``` +├── Aura.toml # STATIC context only: data paths, instrument/pip +│ # metadata, default broker & window, runs dir (no logic) +``` + +After: + +``` +├── Aura.toml # STATIC context only, paths only: data archive root, +│ # runs dir (no logic; instrument geometry is the +│ # recorded sidecar, C15 — never authored here) +``` + +- [ ] **Step 2: glossary** + +`docs/glossary.md:20-21` (the Aura.toml entry body), before: + +``` +The per-project declarative config holding only static context (data paths, instrument/pip metadata, default broker & window, runs dir), never logic. Its presence marks the project root, the way `Cargo.toml` marks a cargo crate. +``` + +After: + +``` +The per-project declarative config holding only static context — paths only: the data archive root and the runs dir — never logic, and never instrument geometry (that is the recorded sidecar, C15). Its presence marks the project root, the way `Cargo.toml` marks a cargo crate. +``` + +- [ ] **Step 3: ledger C13 realization note** + +In `docs/design/INDEX.md`, at the end of the C13 block (after its `**Why.**` +paragraph, ~line 1055), append: + +``` +**Realization (cycle 0102 — the load boundary; per-invocation reload).** The +authoring-loop half is realized: a project is an external cdylib crate loaded +per invocation through a two-tier `#[repr(C)]` descriptor (`AURA_PROJECT`, +`aura-core::project`) — a C-ABI stamp prefix (rustc version + aura-core +version, baked per consuming build) validated **before** any Rust-ABI field is +touched, then the vocabulary resolver + enumerable type-id list behind the +stamp gate. "Hot-reload" reads, in v1, as **per-invocation load of the +freshest build**: the author (Claude) runs `cargo build`, the next `aura` +invocation locates the artifact via `cargo metadata` (debug default, +`--release` opt-in) and loads it **load-and-hold** (leaked, never unloaded). +Scope boundary: load-and-hold is trivially sound only because the CLI is a +one-shot process — a future long-running host (the open local-server thread, +C22) must re-solve reload (host restart or subprocess isolation), never +in-process unload. Mismatch of either stamp refuses (exit 1) naming both +sides; the project vocabulary is charter-checked at load (`::`-namespaced ids, +no duplicates against std, list↔resolver cross-check) — the invariant-9 +data-plane discipline of the C24 enforcement-shift note, now enforced at the +one seam. +``` + +- [ ] **Step 4: C16 and C17 field lists** + +C16 (lines 1148-1149), before: + +``` +strategy / experiment blueprints — plus a static `Aura.toml` (project context: +data paths, instrument/pip metadata, default broker & window, runs dir). During +``` + +After: + +``` +strategy / experiment blueprints — plus a static `Aura.toml` (project context, +paths only: data archive root, runs dir — cycle 0102). During +``` + +C17 (lines 1209-1211), before: + +``` +and reports metrics. Declarative config (`Aura.toml`) carries only **static +project context** (data paths, instrument/pip metadata, defaults, runs dir), +never logic. aura ships **no embedded coding-LLM**. IONOS LLMs are used only as a +``` + +After: + +``` +and reports metrics. Declarative config (`Aura.toml`) carries only **static +project context** (paths only: data archive root, runs dir — cycle 0102), +never logic. aura ships **no embedded coding-LLM**. IONOS LLMs are used only as a +``` + +- [ ] **Step 5: open-threads entry (the schema is now designed)** + +`docs/design/INDEX.md` lines 2058-2063, before: + +``` +- **`aura new` scaffolder, the experiment-builder API, and `Aura.toml`'s + static-context schema** — `aura new` scaffolds a Rust project *crate* (node / + strategy / experiment blueprints) against the engine (C16/C20); the + experiment-builder API surface (harness wiring, structural axes, sweep + combinators) and `Aura.toml`'s schema (data paths, instrument/pip metadata, + default broker & window, runs dir) are not yet designed. +``` + +After: + +``` +- **`aura new` scaffolder and the experiment-builder API** — `aura new` + scaffolds a Rust project *crate* (node / strategy blueprints) against the + engine (C16/C20); the experiment-builder API surface (harness wiring, + structural axes, sweep combinators) is not yet designed. `Aura.toml`'s + schema was settled paths-only in cycle 0102 (data archive root, runs dir — + see the C13 realization note); the load boundary itself shipped there. +``` + +- [ ] **Step 6: verify the lockstep** + +Run: `grep -rn "instrument/pip" docs/project-layout.md docs/glossary.md docs/design/INDEX.md | grep -i "aura.toml" | wc -l` +Expected: `0` (no Aura.toml context still lists instrument/pip). + +--- + +### Task 9: full regression gate + +**Files:** none (verification only) + +- [ ] **Step 1: workspace build + suite** + +Run: `cargo build --workspace` +Expected: 0 errors, 0 warnings introduced. + +Run: `cargo test --workspace` +Expected: PASS — every pre-existing test green unchanged, plus the new +aura-core project tests, node_name strip test, manifest round-trip test, +aura-cli project unit tests, and the five project_load e2e tests. + +- [ ] **Step 2: lint + docs** + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean. + +Run: `cargo doc --workspace --no-deps 2>&1 | grep -c warning` +Expected: `0` (or the pre-existing count, unchanged). + +- [ ] **Step 3: no stray literals** + +Run: `grep -n 'TraceStore::open("runs")' crates/aura-cli/src/main.rs | wc -l` +Expected: `0` (all ten sites route through `env.trace_store()`). + +Run: `grep -n 'fn default_registry' crates/aura-cli/src/main.rs | wc -l` +Expected: `0` (retired in favour of `env.registry()`).