From b672a37903a07b046146e214d0fc7599688b239d Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 2 Jul 2026 18:20:23 +0200 Subject: [PATCH] audit: cycle 0102 tidy (drift-clean after two ledger-mirror alignments) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architect review of d7c935d..HEAD. What holds: C13/C16 separation verified by diff (descriptor in aura-core::project, loader entirely in aura-cli::project, aura-engine gains only the additive provenance field); the C-tier-before-Rust-tier discipline is enforced in load() (magic -> version -> stamps, fn pointers touched only after Ok); C17/C24/invariant-9 hold (paths-only Aura.toml, injected merged resolver, charter at the one seam); outside-a-project byte-identity confirmed by the full green suite incl. every golden-bearing test. Regression: cargo build --workspace rc=0; cargo test --workspace 862 passed / 0 failed (cli_run 133, project_load 7 e2e); clippy --workspace --all-targets -D warnings rc=0; cargo doc --workspace --no-deps 0 warnings. No baselines exist to move, so nothing to ratify. Drift resolved in this commit: two stale project-as-crate ledger mirrors — the C24 status paragraph still calling the layer sequencing-coupled/unbuilt, and the #109 open-thread bullet naming it an unbuilt half — rewritten to record the landed load boundary (the aura new scaffolder and the experiment-builder API stay open). Carried holds (plan-prescribed bytes, cosmetic): the unreachable rsplit fallback in node_name, the unreachable! build closure in one aura-core test. Ephemeral cycle artifacts removed per convention: spec 0102, plan 0102. refs #180 --- docs/design/INDEX.md | 21 +- docs/plans/0102-project-load-boundary.md | 1449 ---------------------- docs/specs/0102-project-load-boundary.md | 427 ------- 3 files changed, 13 insertions(+), 1884 deletions(-) delete mode 100644 docs/plans/0102-project-load-boundary.md delete mode 100644 docs/specs/0102-project-load-boundary.md diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 5531a84..57eefd5 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1983,10 +1983,13 @@ the project-as-crate layer lands (#159, paired with #157's data-authoring surfac (deliberate; fails clean as `UnknownNodeType`, never a silent wrong graph): recording sinks (capture an `mpsc::Sender` — runtime identity, not param-generic data, C19) and construction-arg builders (`LinComb` / `CostSum` / `SimBroker` / `Session` — -structural-axis args, a C20 concern), additively addable later (#156). Still -sequencing-coupled to the **project-as-crate authoring layer** (`aura new` / -`Aura.toml` / `cdylib` loading, C16/C17) and the **composable-orchestration** thread -(#109): topology-as-data is the substrate both stand on. +structural-axis args, a C20 concern), additively addable later (#156). The +**project-as-crate load boundary landed in cycle 0102** (`Aura.toml` discovery + +`cdylib` loading + merged project ∪ std vocabulary — see the C13 realization +note); of the two layers this paragraph used to name as sequencing-coupled, +what remains open is the `aura new` scaffolder (the milestone's next cycle) and +the **composable-orchestration** thread (#109): topology-as-data is the +substrate both stand on. **Op-script grammar (`aura graph build`, the #157 wire surface).** The construction op-script is a JSON **array of ops**, each object internally tagged by `"op"`, @@ -2080,12 +2083,14 @@ artifact, and any run is deterministic once instantiated" (C1). schema was settled paths-only in cycle 0102 (data archive root, runs dir — see the C13 realization note); the load boundary itself shipped there. - **The analysis meta-level — composable orchestration + project-as-crate authoring - (tracked: #109).** aura's differentiator (C21) has two unbuilt halves: (1) the + (tracked: #109).** aura's differentiator (C21) had two unbuilt halves: (1) the orchestration axes (sweep / Monte-Carlo / walk-forward) becoming *composable tools* a user wires into an analysis workflow, rather than today's hard-wired CLI verbs - (`sweep_family` / `walkforward_family` in `aura-cli`); and (2) the project-as-crate - authoring layer above (the `aura new` / experiment-builder API / `Aura.toml` thread - + cdylib loading, C16/C17). On the meta-level, authoring a strategy is *wiring + (`sweep_family` / `walkforward_family` in `aura-cli`) — still open; and (2) the + project-as-crate authoring layer above — its **load boundary landed in cycle 0102** + (`Aura.toml` discovery + cdylib loading + merged vocabulary, C13 realization note; + the `aura new` scaffolder and the experiment-builder API remain open, C16/C17). + On the meta-level, authoring a strategy is *wiring existing nodes* (C9 composition) + *defining the analysis framework* (C21), not writing new nodes; the user wants it driven via the CLI, later the interactive server / playground (C14/C22). **Resolved (2026-06-29) → C24.** The fork was diff --git a/docs/plans/0102-project-load-boundary.md b/docs/plans/0102-project-load-boundary.md deleted file mode 100644 index f1bf08e..0000000 --- a/docs/plans/0102-project-load-boundary.md +++ /dev/null @@ -1,1449 +0,0 @@ -# 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()`). diff --git a/docs/specs/0102-project-load-boundary.md b/docs/specs/0102-project-load-boundary.md deleted file mode 100644 index de53fa1..0000000 --- a/docs/specs/0102-project-load-boundary.md +++ /dev/null @@ -1,427 +0,0 @@ -# The project-as-crate load boundary — Design Spec - -**Date:** 2026-07-02 -**Status:** Draft — awaiting sign-off (boss run: grounding-check gate) -**Authors:** orchestrator + Claude -**Cycle:** 0102 — first implementation cycle of milestone "Project environment — the project-as-crate authoring loop" (#180; the full fork-decision log with grounds is the decision-log comment on #180) - -## Goal - -Make a research project a loadable external Rust crate: an `aura` invocation -run inside a project directory discovers the project root (`Aura.toml`), -locates and loads the project's cdylib, verifies ABI compatibility -(refuse-don't-guess), merges the project's node vocabulary with -`std_vocabulary`, and runs every blueprint verb against the merged vocabulary — -bit-deterministically, with the loaded dylib's identity recorded in the run -manifest. Outside a project directory, behaviour stays byte-identical to -today. The `aura new` scaffolder is the next cycle, not this one. - -## Architecture - -Two new surfaces and one threading change: - -1. **`aura-core::project` (new module + build.rs)** — the shared descriptor - contract both sides compile: a `#[repr(C)]` `ProjectDescriptor` with a - **C-ABI tier** (magic, descriptor version, rustc/aura-core version stamps, - namespace) that the host reads **before** trusting anything, and a - **Rust-ABI tier** (vocabulary resolver fn, enumerable type-id list) touched - only after the stamps match. An `aura_project!` macro generates the single - exported `AURA_PROJECT` static. Because the project build recompiles - aura-core under the project's own toolchain, the stamps baked into - aura-core's constants are automatically per-side-correct. -2. **`aura-cli::project` (new module)** — discovery (cargo-style walk-up to - `Aura.toml`), artifact location via `cargo metadata` (subprocess + - `serde_json`, no new crate), `libloading` load-and-hold (the `Library` is - leaked; never unloaded), the two-tier stamp gate, the vocabulary charter - checks, and the merged resolver. -3. **Per-invocation project context** — `main()` resolves an - `Option` once (None outside a project) and threads it into the - `dispatch_*` functions; the ~14 inline `&|t| std_vocabulary(t)` resolver - sites, the ~10 `default_registry()` sites, and the inline - `data_server::DEFAULT_DATA_PATH` sites read from it. Outside a project the - context is `None` and every path collapses to the current literal. - -Design constraints (decided on #180, restated where load-bearing): - -- **Stamp before trust.** The stamp cannot certify the channel it rides on; - the C tier uses only C-compatible field types (integers, ptr+len string - slices) and is validated before any Rust-ABI field is dereferenced. -- **Vocabulary charter.** Project type-ids MUST be namespace-prefixed - (`::Type`, separator `::`); std ids stay bare. The loader refuses (exit - 1): a missing/empty namespace, a listed id without the `::` prefix, a - duplicate id within the merged set, and a listed id the resolver does not - resolve (list↔resolver cross-check). The default node name strips the - namespace, so `::` never enters the param-path address space. -- **Zero-arg inheritance.** Project vocabularies resolve zero-argument - builders only, exactly as `std_vocabulary` scopes itself; construction-arg - widening remains the queued additive extension (#156). -- **Aura.toml is paths only.** `[paths] data`, `[paths] runs` — both optional. - No instrument/pip metadata (the recorded geometry sidecar stays the only - source — C15 realization, #124), no default symbol/window/broker, no dylib - path, no namespace (the descriptor is the namespace's single source; one - refinement over the #180 log's item 7, same anti-drift rationale). -- **Author builds, aura loads.** No cargo-build orchestration inside aura; - a missing artifact refuses with a build hint. Per-invocation load of the - freshest build IS the v1 "hot-reload" (C13 reading to be recorded in the - ledger, with the one-shot-process scope boundary of load-and-hold). - -## Concrete code shapes - -### The user-facing project (the acceptance-criterion evidence) - -A project crate, in its own directory outside this repo (fixture twin: -`crates/aura-cli/tests/fixtures/demo-project/`): - -```toml -# ger40-lab/Cargo.toml -[package] -name = "ger40-lab" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -aura-core = { path = "../aura/crates/aura-core" } # git dep in real projects - -[workspace] # standalone workspace root (docs/project-layout.md note) -``` - -```toml -# ger40-lab/Aura.toml — static project context only (C17); every key optional -[paths] -data = "/mnt/tickdata/Pepperstone" # data archive root; default: data-server default -runs = "runs" # registry root, relative to the project root; default: "runs" -``` - -```rust -// ger40-lab/src/lib.rs -use aura_core::PrimitiveBuilder; -mod third_candle; // an ordinary Node + PrimitiveBuilder factory, as in aura-std - -fn vocabulary(type_id: &str) -> Option { - match type_id { - "ger40::ThirdCandle" => Some(third_candle::builder()), - _ => None, - } -} - -fn type_ids() -> &'static [&'static str] { - &["ger40::ThirdCandle"] -} - -aura_core::aura_project! { - namespace: "ger40", - vocabulary: vocabulary, - type_ids: type_ids, -} -``` - -```console -$ cd ger40-lab && cargo build -$ aura run blueprints/third-candle.json # blueprint's "type": "ger40::ThirdCandle" -$ aura sweep blueprints/third-candle.json --list-axes -$ aura graph introspect --vocabulary # lists std AND ger40::* ids -``` - -### aura-core: descriptor + stamps (new `src/project.rs`, new `build.rs`) - -```rust -// aura-core/build.rs (new; the workspace's second build.rs after aura-cli's) -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()); -} -``` - -```rust -// aura-core/src/project.rs (new) -pub const AURA_DESCRIPTOR_MAGIC: u64 = 0x4155_5241_5052_4F4A; // "AURAPROJ" -pub const AURA_DESCRIPTOR_VERSION: u32 = 1; -pub const AURA_PROJECT_SYMBOL: &[u8] = b"AURA_PROJECT\0"; - -/// Baked at aura-core compile time — i.e. per consuming build, which is what -/// makes the host/dylib comparison meaningful (each side recompiles aura-core). -pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION"); -pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION"); - -/// C-ABI string slice: ptr + len over 'static UTF-8 (no NUL convention needed). -#[repr(C)] -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() } - } -} - -#[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], -} -unsafe impl Sync for ProjectDescriptor {} - -#[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, - }; - }; -} -``` - -### aura-core: default node name strips the namespace - -Before (`crates/aura-core/src/node.rs:143-147`): - -```rust -pub fn node_name(&self) -> String { - self.instance_name.clone() - .unwrap_or_else(|| self.name.to_ascii_lowercase()) -} -``` - -After (std ids carry no `::`, so existing behaviour is byte-identical): - -```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() - }) -} -``` - -Consequence: param paths (built from node names, `blueprint.rs` `collect_params`) -never contain `::`; the member_key sanitizer (`main.rs:215-219`, -charset `[A-Za-z0-9._-]`) is reached only by names, so no new -filesystem-portability class opens. An explicit `.named("a::b")` sanitizes to -`a__b` as any out-of-charset byte does today. - -### aura-cli: loader (new `src/project.rs`) - -```rust -pub struct AuraToml { pub data: Option, pub runs: Option } - -pub struct ProjectEnv { - pub root: PathBuf, // directory containing Aura.toml - pub toml: AuraToml, - pub namespace: String, - pub dylib_path: PathBuf, - pub dylib_sha256: String, // provenance (sha2 is already a dependency) - pub commit: Option, // best-effort: git -C rev-parse HEAD (+ -dirty) - /// fn pointer copied out of the descriptor — plain `fn`, and valid for - /// 'static because the Library is leaked (load-and-hold). - resolver: fn(&str) -> Option, - type_id_list: &'static [&'static str], -} - -impl ProjectEnv { - /// 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 { ... } - pub fn type_ids(&self) -> Vec<&str> { ... } // project ∪ std, for introspection -} - -/// Walk up from cwd to the nearest Aura.toml (the way cargo finds Cargo.toml). -pub fn discover() -> Option; - -/// Locate + load + verify. Every failure is a runtime refusal (exit 1). -pub fn load(root: &Path, release: bool) -> Result; -``` - -`load` steps, in order: - -1. Parse `Aura.toml` (`toml` crate — dependency admitted per-case: research-side - leaf binary, the clap precedent; never linkable into the frozen artifact). -2. `cargo metadata --format-version 1 --no-deps` in `root` (subprocess, parsed - with the existing `serde_json`): read `target_directory` + the package's lib - name → `/{debug|release}/`. - Missing file → `ProjectError::ArtifactMissing` with a `cargo build` hint. -3. `libloading::Library::new(path)`, then `Box::leak` — **load-and-hold**: the - library lives until process exit; there is no unload path. -4. Read the `AURA_PROJECT` symbol as `*const ProjectDescriptor`; validate the - C tier in order: `magic`, `descriptor_version`, then both stamps against - the host's own `aura_core::project::{RUSTC_VERSION, CORE_VERSION}`. - Any mismatch → `ProjectError::Incompatible` naming both sides' values. - **No Rust-tier field is dereferenced before this point.** -5. Charter checks: non-empty namespace; every listed id starts with - `::`; no duplicate within `type_ids() ∪ std_vocabulary_types()`; - every listed id resolves through the descriptor's own resolver fn - (list↔resolver cross-check — the introspection surface can then never - silently omit a project type). -6. Compute `dylib_sha256` over the loaded file's bytes; read the project git - commit best-effort (absent on failure, never an error). - -### aura-cli: threading (the fan-out edit) - -`main()` (`main.rs:4561`, dispatch at `4571-4581`), before dispatch: - -```rust -let project: Option = match project::discover() { - Some(root) => Some(project::load(&root, cli.release)?), // Err => exit 1 - None => None, -}; -``` - -- A new global flag on `Cli` (`main.rs:3669`): `#[arg(long, global = true)] - release: bool` — selects the artifact profile (debug is the default, cargo's - own default build). -- The ~14 `blueprint_from_json(..., &|t| std_vocabulary(t))` call sites - (`main.rs:2233 ... 4470`) and the op-script sites - (`graph_construct.rs:113/151/170/196`) take the merged resolver: - `&|t| env.resolve(t)` where `env` falls back to a std-only resolver when no - project is loaded. -- `default_registry()` (`main.rs:1513-1515`) gains the project form: inside a - project the registry root is `/` (`runs` from Aura.toml, default - `"runs"`); outside, the literal `"runs/runs.jsonl"` as today. Every - registry-family path (runs store, `families.jsonl`, `runs/traces/`, - `runs/blueprints/`) anchors at that same runs root. -- The inline `data_server::DEFAULT_DATA_PATH` sites (`main.rs:660, 677, 718, - 742`) read `[paths] data` when set, the current default otherwise. - -### aura-engine: manifest provenance (Tier-1 additive) - -After `topology_hash` (`crates/aura-engine/src/report.rs:58-59`), same -one-directional-widening idiom as `selection`/`instrument`/`topology_hash`: - -```rust -/// Identity of the loaded project dylib, when the run used one (cycle 0102). -/// Pre-0102 records read as `None`. -#[serde(default, skip_serializing_if = "Option::is_none")] -pub project: Option, -``` - -```rust -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ProjectProvenance { - pub namespace: String, - pub dylib_sha256: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub commit: Option, -} -``` - -`sim_optimal_manifest` (`main.rs:166-184`) sets `project: None`; project-aware -paths stamp it post-hoc exactly as `instrument` (`main.rs:1664`) and -`topology_hash` (`main.rs:2821/2855/3554`) are stamped today. - -### Docs / ledger alignment (rides along) - -- `docs/project-layout.md:33` — drop "instrument/pip metadata" from the - Aura.toml line (geometry is the recorded sidecar, C15 realization); align - the field list to paths-only. -- `docs/design/INDEX.md` — C13 realization note: the descriptor contract, the - per-invocation-reload reading of "hot-reload", and the load-and-hold - one-shot-process scope boundary (a future long-running host must re-solve - reload; never in-process dlclose). C16/C17 Aura.toml field lists align to - paths-only. -- `docs/glossary.md` — Aura.toml entry aligns to paths-only. - -## Components - -| Component | Crate | New/changed | -|---|---|---| -| `project::ProjectDescriptor`, `StrSlice`, stamps, `aura_project!` | aura-core | new module + build.rs | -| `node_name()` namespace strip | aura-core | 1 function | -| `project::{discover, load, ProjectEnv, ProjectError}` | aura-cli | new module | -| Resolver/registry/data-path threading + `--release` global flag | aura-cli | fan-out edit | -| `RunManifest.project` + `ProjectProvenance` | aura-engine | Tier-1 field | -| Fixture project crate | `crates/aura-cli/tests/fixtures/demo-project/` | new (not a workspace member; empty `[workspace]`) | -| Docs/ledger alignment | docs/ | edits | - -New dependencies (per-case review, all confined to the `aura-cli` leaf binary, -frozen artifact untouched — the clap precedent): `libloading`, `toml`. -Explicitly NOT added: `cargo_metadata` (subprocess + existing `serde_json`), -`rustc_version` (5-line build.rs instead). - -## Data flow - -``` -cwd → discover() → Aura.toml root ──none──→ std-only resolver (today's behaviour) - │yes - ▼ -cargo metadata ─→ dylib path ─→ libloading (leak) ─→ AURA_PROJECT symbol - ▼ -C tier: magic → version → rustc stamp → core stamp (mismatch → exit 1) - ▼ -charter: ns non-empty → ids prefixed → no duplicates → list↔resolver - ▼ -ProjectEnv { merged resolver, runs root, data root, provenance } - ▼ -dispatch_* → blueprint verbs resolve project ∪ std → run → manifest stamped -``` - -## Error handling - -All project-load failures are **runtime refusals, exit 1** (C14 partition: -well-formed command, environment not usable): Aura.toml parse error, cargo -metadata failure, missing artifact (with `cargo build` hint), missing/garbage -`AURA_PROJECT` symbol, magic/descriptor-version mismatch, stamp mismatch -(message names both sides: `project dylib built with rustc 1.XX, host expects -rustc 1.YY`), charter violations (each names the offending id). Absence of an -`Aura.toml` is not an error — it selects today's std-only behaviour. A panic -inside project node code propagates (one-shot process; loud authoring-bug -signal — decided on #180, item 3). - -## Testing strategy - -1. **Unit (aura-core):** descriptor constants; `node_name` strip (bare names - byte-identical; `ns::Type` → `type`); macro expansion compiles in a - doc-test. -2. **Unit (aura-cli, no dylib needed):** walk-up discovery (nested dirs, no - Aura.toml, cwd = root); Aura.toml parsing (empty file, partial keys, - unknown keys tolerated); charter checks + stamp comparison driven through - an in-process fake descriptor; artifact-path derivation from a canned - `cargo metadata` JSON. -3. **E2E (aura-cli integration test, the load-bearing proof):** build the - fixture project (`cargo build` in `tests/fixtures/demo-project/`, path-deps - on this workspace's aura-core), then via `CARGO_BIN_EXE_aura` with cwd - inside the fixture: `aura run ` resolves - `demo::...`, runs, exits 0; run twice → byte-identical reports (C1); - manifest carries `project.namespace = "demo"` and the sha256 of the loaded - dylib; `aura graph introspect --vocabulary` lists `demo::*` beside std. -4. **Refusals (e2e):** the same blueprint outside the fixture dir → - `UnknownNodeType`, exit 2 (existing contract); fixture with the dylib - deleted → exit 1 with build hint. -5. **Regression:** full workspace suite green unchanged; all existing goldens - byte-identical (outside-a-project paths must not move); the manifest - legacy-load round-trip gains the `project: None` assertion (report.rs - 273-330 pattern). - -## Acceptance criteria - -1. A project crate authored as shown above (fixture twin) runs end-to-end - through `run`, `sweep --list-axes`, and `graph introspect --vocabulary` - from inside its directory, bit-deterministically (two runs byte-identical). -2. Every load failure named in Error handling refuses with exit 1 and a - message naming the cause; no failure path panics or loads a wrong graph. -3. Outside a project directory, the entire existing test suite and all golden - files are byte-identical to pre-cycle behaviour. -4. A project run's manifest records namespace + dylib content hash (+ commit - when available); pre-0102 registry lines load unchanged. -5. `::` cannot reach param paths through default node names (unit-pinned); - project ids without the namespace prefix cannot load (charter-pinned). -6. The Aura.toml field lists in project-layout.md, C16/C17, and the glossary - agree on paths-only; the C13 realization note records the reload reading - and the load-and-hold scope boundary.