feat(project): the project-as-crate load boundary (cycle 0102)

A research project is now a loadable external cdylib crate. Inside a
directory whose ancestry holds an Aura.toml, aura discovers the project
root cargo-style, locates the compiled dylib via cargo metadata (debug
default, --release opt-in), loads it load-and-hold, and refuses
mismatches before trusting anything: the AURA_PROJECT descriptor
(aura-core::project, #[repr(C)]) carries a C-ABI stamp prefix (rustc +
aura-core version, baked per consuming build by the new aura-core
build.rs) validated before any Rust-ABI field is read. The vocabulary
charter gates the merged resolution: project type ids are ::-namespaced
(std stays bare), duplicates refuse, and the enumerable type-id list
must agree with the resolver, so introspection can never silently omit
a project type.

All blueprint verbs resolve through the merged project + std vocabulary
via a per-invocation Env threaded through the dispatch chains;
registry, trace-store, and data paths anchor at the project runs root
(Aura.toml [paths], paths-only by design — instrument geometry stays
the recorded sidecar, C15). RunManifest gains the Tier-1 project
provenance field (namespace + dylib sha256 + best-effort commit),
stamped beside topology_hash on the blueprint-run paths; pre-0102
registry lines load unchanged. Default node names strip the namespace,
so :: never reaches the param-path address space.

Proven by the demo-project fixture (built by the e2e via cargo,
path-dep on this workspace): run twice bit-identical, provenance
recorded, introspection lists demo::* beside std, registry anchors at
the discovered root from a subdirectory; the badcharter fixture proves
the charter refusal through the real libloading path; a never-built
project refuses with a cargo-build hint. Outside a project every path
collapses to the previous literals — goldens and manifest pins
byte-identical.

Verification: cargo build --workspace clean; cargo test --workspace 862
passed / 0 failed (incl. 7 project_load e2e); clippy -D warnings clean
(one precedent-matching allow(too_many_arguments) on run_oos_blueprint,
whose arity the Env threading raised to 8); doc build unchanged.
Docs/ledger aligned: Aura.toml field lists are paths-only in
project-layout.md, glossary, C16/C17; new C13 realization note records
the per-invocation-reload reading and the load-and-hold one-shot scope
boundary.

New deps, per-case review (aura-cli leaf binary only, never the frozen
artifact): libloading, toml.

refs #180
This commit is contained in:
2026-07-02 18:13:37 +02:00
parent af5f825d60
commit 4928e289f7
38 changed files with 1662 additions and 291 deletions
+29 -24
View File
@@ -7,9 +7,13 @@
use std::collections::BTreeMap;
use aura_engine::{blueprint_to_json, replay, BindOpError, GraphSession, Op, OpError, Scalar, ScalarKind};
use aura_std::{std_vocabulary, std_vocabulary_types};
use serde::Deserialize;
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
// production code; tests still exercise it directly.
#[cfg(test)]
use aura_std::std_vocabulary;
/// The wire DTO for one construction op — the document's by-identifier shape,
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
@@ -106,11 +110,11 @@ fn format_op_error(e: &OpError) -> String {
/// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op
/// fault, attributed by the retained op-kind list) / `finalize: cause` (a
/// holistic fault past the last op).
pub fn build_from_str(doc: &str) -> Result<String, String> {
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
match replay("graph", ops, &std_vocabulary) {
match replay("graph", ops, &|t| env.resolve(t)) {
Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")),
Err((idx, err)) => {
let cause = format_op_error(&err);
@@ -124,14 +128,14 @@ pub fn build_from_str(doc: &str) -> Result<String, String> {
/// `aura graph build`: read the op-list document from stdin, build, and print the
/// blueprint to stdout — or the cause to stderr and exit non-zero.
pub fn build_cmd() {
pub fn build_cmd(env: &crate::project::Env) {
use std::io::Read;
let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(1);
}
match build_from_str(&doc) {
match build_from_str(&doc, env) {
// `print!`, not `println!`: the canonical artifact is exactly the library
// `blueprint_to_json` bytes (the form #158 content-addresses) — a JSON value
// with no trailing newline. The CLI is a transport; it must not frame the
@@ -147,8 +151,8 @@ pub fn build_cmd() {
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
/// closed vocabulary.
pub fn introspect_node(type_id: &str) -> Result<String, String> {
let builder = std_vocabulary(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<String, String> {
let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
let schema = builder.schema();
let mut out = format!("{}\n", builder.label());
for port in &schema.inputs {
@@ -165,9 +169,10 @@ pub fn introspect_node(type_id: &str) -> Result<String, String> {
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
/// op-list document, by-identifier (applies the ops, does NOT finalize).
pub fn introspect_unwired(doc: &str) -> Result<String, String> {
pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let mut session = GraphSession::new("introspect", &std_vocabulary);
let resolver = |t: &str| env.resolve(t);
let mut session = GraphSession::new("introspect", &resolver);
for (i, d) in docs.into_iter().enumerate() {
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
}
@@ -181,7 +186,7 @@ pub fn introspect_unwired(doc: &str) -> Result<String, String> {
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--content-id` must be set;
/// zero or more than one is the usage error (exit 2).
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize
+ cmd.unwired as usize
@@ -193,11 +198,11 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
std::process::exit(2);
}
if cmd.vocabulary {
for t in std_vocabulary_types() {
for t in env.type_ids() {
println!("{t}");
}
} else if let Some(type_id) = cmd.node.as_deref() {
match introspect_node(type_id) {
match introspect_node(type_id, env) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
@@ -211,7 +216,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(1);
}
match introspect_unwired(&doc) {
match introspect_unwired(&doc, env) {
Ok(s) => print!("{s}"),
Err(m) => {
eprintln!("aura: {m}");
@@ -229,7 +234,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
eprintln!("aura: reading stdin: {e}");
std::process::exit(1);
}
match build_from_str(&doc) {
match build_from_str(&doc, env) {
Ok(json) => println!("{}", crate::content_id(&json)),
Err(m) => {
eprintln!("aura: {m}");
@@ -283,7 +288,7 @@ mod tests {
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
let json = super::build_from_str(doc).expect("valid document builds");
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds");
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
}
@@ -299,14 +304,14 @@ mod tests {
{"op":"feed","role":"price","into":["fast.series"]},
{"op":"expose","from":"fast.value","as":"out"}
]"#;
let json = super::build_from_str(doc).expect("name-keyed add builds");
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds");
assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
}
#[test]
fn build_from_str_reports_unknown_node_at_its_op_index() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
let err = super::build_from_str(doc).unwrap_err();
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
}
@@ -321,7 +326,7 @@ mod tests {
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
let err = super::build_from_str(doc).unwrap_err();
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
"names the unconnected slot by-identifier, got: {err}");
@@ -330,11 +335,11 @@ mod tests {
#[test]
fn introspect_node_lists_ports_kinds_and_params() {
let out = super::introspect_node("SMA").expect("SMA is in the vocabulary");
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
assert!(out.contains("series"), "lists the input port: {out}");
assert!(out.contains("value"), "lists the output field: {out}");
assert!(out.contains("length"), "lists the param path: {out}");
assert!(super::introspect_node("Nope").is_err(), "rejects an unknown type");
assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type");
}
/// A bind whose value-kind mismatches the param reads as prose, like the connect
@@ -342,7 +347,7 @@ mod tests {
#[test]
fn build_from_str_bad_bind_kind_reads_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
let err = super::build_from_str(doc).unwrap_err();
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
}
@@ -351,7 +356,7 @@ mod tests {
#[test]
fn build_from_str_unknown_bind_param_reads_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
let err = super::build_from_str(doc).unwrap_err();
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
}
@@ -372,7 +377,7 @@ mod tests {
/// does not have to read source to learn the `{"I64": <v>}` wrapping.
#[test]
fn introspect_node_shows_the_bind_form() {
let out = super::introspect_node("SMA").expect("SMA is in the vocabulary");
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
}
@@ -383,7 +388,7 @@ mod tests {
{"op":"add","type":"Sub"},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
let out = super::introspect_unwired(doc).expect("partial document introspects");
let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects");
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");