Grounding-check PASS as the autonomous signature: 7 load-bearing current-behaviour assumptions each ratified by a named green test; greenfield surfaces (descriptor, loader, Aura.toml) excluded per contract. Design decisions and provenance: the decision-log on the milestone reference issue. refs #180
19 KiB
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:
aura-core::project(new module + build.rs) — the shared descriptor contract both sides compile: a#[repr(C)]ProjectDescriptorwith 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. Anaura_project!macro generates the single exportedAURA_PROJECTstatic. 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.aura-cli::project(new module) — discovery (cargo-style walk-up toAura.toml), artifact location viacargo metadata(subprocess +serde_json, no new crate),libloadingload-and-hold (theLibraryis leaked; never unloaded), the two-tier stamp gate, the vocabulary charter checks, and the merged resolver.- Per-invocation project context —
main()resolves anOption<ProjectEnv>once (None outside a project) and threads it into thedispatch_*functions; the ~14 inline&|t| std_vocabulary(t)resolver sites, the ~10default_registry()sites, and the inlinedata_server::DEFAULT_DATA_PATHsites read from it. Outside a project the context isNoneand 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
(
<ns>::Type, separator::); std ids stay bare. The loader refuses (exit 1): a missing/empty namespace, a listed id without the<ns>::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_vocabularyscopes 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/):
# 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)
# 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"
// 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<PrimitiveBuilder> {
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,
}
$ 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)
// 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());
}
// 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<PrimitiveBuilder>,
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):
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):
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)
pub struct AuraToml { pub data: Option<PathBuf>, pub runs: Option<PathBuf> }
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<String>, // best-effort: git -C <root> 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<PrimitiveBuilder>,
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<PrimitiveBuilder> { ... }
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<PathBuf>;
/// Locate + load + verify. Every failure is a runtime refusal (exit 1).
pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError>;
load steps, in order:
- Parse
Aura.toml(tomlcrate — dependency admitted per-case: research-side leaf binary, the clap precedent; never linkable into the frozen artifact). cargo metadata --format-version 1 --no-depsinroot(subprocess, parsed with the existingserde_json): readtarget_directory+ the package's lib name →<target>/{debug|release}/<DLL_PREFIX><snake_name><DLL_SUFFIX>. Missing file →ProjectError::ArtifactMissingwith acargo buildhint.libloading::Library::new(path), thenBox::leak— load-and-hold: the library lives until process exit; there is no unload path.- Read the
AURA_PROJECTsymbol as*const ProjectDescriptor; validate the C tier in order:magic,descriptor_version, then both stamps against the host's ownaura_core::project::{RUSTC_VERSION, CORE_VERSION}. Any mismatch →ProjectError::Incompatiblenaming both sides' values. No Rust-tier field is dereferenced before this point. - Charter checks: non-empty namespace; every listed id starts with
<namespace>::; no duplicate withintype_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). - Compute
dylib_sha256over 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:
let project: Option<ProjectEnv> = 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)whereenvfalls 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<root>/<runs>(runsfrom 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_PATHsites (main.rs:660, 677, 718, 742) read[paths] datawhen 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:
/// 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<ProjectProvenance>,
#[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<String>,
}
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
- Unit (aura-core): descriptor constants;
node_namestrip (bare names byte-identical;ns::Type→type); macro expansion compiles in a doc-test. - 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 metadataJSON. - E2E (aura-cli integration test, the load-bearing proof): build the
fixture project (
cargo buildintests/fixtures/demo-project/, path-deps on this workspace's aura-core), then viaCARGO_BIN_EXE_aurawith cwd inside the fixture:aura run <fixture blueprint>resolvesdemo::..., runs, exits 0; run twice → byte-identical reports (C1); manifest carriesproject.namespace = "demo"and the sha256 of the loaded dylib;aura graph introspect --vocabularylistsdemo::*beside std. - Refusals (e2e): the same blueprint outside the fixture dir →
UnknownNodeType, exit 2 (existing contract); fixture with the dylib deleted → exit 1 with build hint. - 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: Noneassertion (report.rs 273-330 pattern).
Acceptance criteria
- A project crate authored as shown above (fixture twin) runs end-to-end
through
run,sweep --list-axes, andgraph introspect --vocabularyfrom inside its directory, bit-deterministically (two runs byte-identical). - 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.
- Outside a project directory, the entire existing test suite and all golden files are byte-identical to pre-cycle behaviour.
- A project run's manifest records namespace + dylib content hash (+ commit when available); pre-0102 registry lines load unchanged.
::cannot reach param paths through default node names (unit-pinned); project ids without the namespace prefix cannot load (charter-pinned).- 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.