668 lines
23 KiB
Rust
668 lines
23 KiB
Rust
//! Pipeline executor — `load → pre_validate → exec → apply → render → post_validate → emit_witness`.
|
|
//!
|
|
//! Phase 1 wires the skeleton: every step is instrumented with
|
|
//! microsecond-resolution metrics (ADR-026 SLA observability). Concrete
|
|
//! precondition loaders, validator dispatch, effect application, and
|
|
//! NCL render paths plug in alongside their consuming ops (O3 onward).
|
|
|
|
use std::time::Instant;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::context::OpContext;
|
|
use crate::error::OpError;
|
|
use crate::output::OpOutput;
|
|
use crate::registry::OperationEntry;
|
|
use crate::validation::{dispatch as validation_dispatch, ValidationCtx, Verdict};
|
|
use crate::witness::{sign_witness, PipelineWitness};
|
|
|
|
/// Microsecond-resolution metrics emitted alongside every pipeline
|
|
/// invocation. ADR-026 SLA tracking consumes these once the runtime
|
|
/// learns to publish them.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct PipelineMetrics {
|
|
/// Time spent loading the precondition slice.
|
|
pub precondition_load_us: u64,
|
|
/// Time spent running pre-validators.
|
|
pub pre_validate_us: u64,
|
|
/// Time spent in the op body.
|
|
pub exec_us: u64,
|
|
/// Time spent applying effects to the substrate.
|
|
pub apply_us: u64,
|
|
/// Time spent rendering NCL paths.
|
|
pub render_us: u64,
|
|
/// Time spent running post-validators.
|
|
pub post_validate_us: u64,
|
|
/// Time spent computing the state root + signing the witness.
|
|
pub witness_us: u64,
|
|
}
|
|
|
|
/// Aggregate result of one pipeline invocation.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PipelineResult {
|
|
/// Signed witness — the only object that crosses the trust boundary.
|
|
pub witness: PipelineWitness,
|
|
/// Effects the op declared (already folded into the substrate).
|
|
pub effects: Vec<crate::output::EffectRecord>,
|
|
/// Per-step latencies.
|
|
pub metrics: PipelineMetrics,
|
|
/// Oplog id the witness was persisted under (`substrate` feature).
|
|
/// `None` when the context carried no oplog — the witness was signed
|
|
/// and returned but not persisted. This is the witness's DAG position
|
|
/// (bl-022): the id covers the body's `parents` + `timestamp`.
|
|
#[cfg(feature = "substrate")]
|
|
pub oplog_op_id: Option<ontoref_types::OpId>,
|
|
}
|
|
|
|
/// Execute one operation through the full pipeline.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Any pipeline step may abort with [`OpError`]; in particular,
|
|
/// [`OpError::ValidatorRejected`] indicates a Reject verdict and
|
|
/// [`OpError::ActorPolicyDenied`] indicates the calling actor is not
|
|
/// permitted for the op.
|
|
pub fn execute_op(
|
|
entry: &OperationEntry,
|
|
inputs: &serde_json::Value,
|
|
ctx: &mut OpContext,
|
|
) -> Result<PipelineResult, OpError> {
|
|
let mut metrics = PipelineMetrics::default();
|
|
|
|
enforce_actor_policy(entry, &ctx.actor_id)?;
|
|
// Stamp the op id onto the context so validators and the body can
|
|
// reference it without an extra parameter.
|
|
ctx.op_id = entry.id.to_owned();
|
|
|
|
// 1. Precondition load — dispatched by entry.precondition tag.
|
|
// Bundles the loaded slice with the op's inputs so validators
|
|
// see both without an extra parameter.
|
|
let t0 = Instant::now();
|
|
let slice = load_precondition(entry, inputs, ctx)?;
|
|
metrics.precondition_load_us = elapsed_us(t0);
|
|
|
|
// 2. Pre-validators — block the op when any non-Accept verdict.
|
|
let t0 = Instant::now();
|
|
let validation_ctx = ValidationCtx::synthetic(&ctx.actor_id, entry.id);
|
|
run_validators(entry.id, entry.pre_validators, &slice, &validation_ctx)?;
|
|
metrics.pre_validate_us = elapsed_us(t0);
|
|
|
|
// 3. Op body.
|
|
let t0 = Instant::now();
|
|
let output = (entry.execute)(inputs, ctx)?;
|
|
metrics.exec_us = elapsed_us(t0);
|
|
|
|
// 4. Apply effects (declarative hook — currently a no-op; ops mutate
|
|
// ctx.commit_layer directly inside the body).
|
|
let t0 = Instant::now();
|
|
apply_effects(entry, &output, ctx);
|
|
metrics.apply_us = elapsed_us(t0);
|
|
|
|
// 5. Render NCL paths — Phase 1 supports state.ncl when
|
|
// ctx.state + ctx.project_root are set.
|
|
let t0 = Instant::now();
|
|
render_paths(entry, ctx)?;
|
|
metrics.render_us = elapsed_us(t0);
|
|
|
|
// 6. Post-validators — re-load the slice (state has mutated) and run.
|
|
let t0 = Instant::now();
|
|
let post_slice = load_precondition(entry, inputs, ctx)?;
|
|
run_validators(entry.id, entry.post_validators, &post_slice, &validation_ctx)?;
|
|
metrics.post_validate_us = elapsed_us(t0);
|
|
|
|
// 7. Emit witness.
|
|
let t0 = Instant::now();
|
|
let state_root = ctx.commit_layer_root();
|
|
let witness = sign_witness(
|
|
&ctx.signing_key,
|
|
entry.id,
|
|
state_root,
|
|
&output,
|
|
entry.witness_shape,
|
|
);
|
|
metrics.witness_us = elapsed_us(t0);
|
|
|
|
// 8. Persist the witness to the oplog as the final stage (substrate
|
|
// feature). Append-first semantics: the oplog is the source of
|
|
// truth, the rendered NCL is a projection. Done here, after the
|
|
// in-memory render, the only divergence window is render-ok /
|
|
// append-fail, surfaced as OpError::WitnessPersist for the caller
|
|
// to reconcile.
|
|
#[cfg(feature = "substrate")]
|
|
let oplog_op_id = persist_witness(&witness, ctx)?;
|
|
|
|
Ok(PipelineResult {
|
|
witness,
|
|
effects: output.effects,
|
|
metrics,
|
|
#[cfg(feature = "substrate")]
|
|
oplog_op_id,
|
|
})
|
|
}
|
|
|
|
/// Persist the signed pipeline witness to the oplog as an
|
|
/// [`OpPayload::Witness`] operation. Returns the resulting [`OpId`]
|
|
/// (the witness's DAG position) or `None` when the context carries no
|
|
/// oplog. The op's parents are the current DAG heads, so each witness
|
|
/// extends every head; its HLC is ticked strictly past every parent's,
|
|
/// satisfying the oplog's monotonicity invariant.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// [`OpError::WitnessPersist`] when reading heads, loading a parent, or
|
|
/// the append itself fails.
|
|
#[cfg(feature = "substrate")]
|
|
fn persist_witness(
|
|
witness: &PipelineWitness,
|
|
ctx: &OpContext,
|
|
) -> Result<Option<ontoref_types::OpId>, OpError> {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use ontoref_types::operation::{OpBody, OpPayload, Operation};
|
|
use ontoref_types::Hlc;
|
|
|
|
let Some(oplog) = ctx.oplog.as_ref() else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let persist_err = |reason: String| OpError::WitnessPersist {
|
|
op_id: witness.op_id.clone(),
|
|
reason,
|
|
};
|
|
|
|
// Parents = current DAG heads; the witness extends every head.
|
|
let parents = oplog
|
|
.heads()
|
|
.map_err(|e| persist_err(format!("reading oplog heads: {e}")))?;
|
|
|
|
// HLC must be strictly greater than every parent's timestamp.
|
|
let mut max_parent_ts = Hlc::genesis();
|
|
for parent in &parents {
|
|
let parent_op = oplog
|
|
.get(parent)
|
|
.map_err(|e| persist_err(format!("loading parent {parent:?}: {e}")))?
|
|
.ok_or_else(|| persist_err(format!("head {parent:?} absent from oplog")))?;
|
|
if parent_op.body.timestamp > max_parent_ts {
|
|
max_parent_ts = parent_op.body.timestamp;
|
|
}
|
|
}
|
|
let now_physical = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
|
|
.unwrap_or(0);
|
|
let timestamp = Hlc::tick(max_parent_ts, now_physical);
|
|
|
|
let body = OpBody {
|
|
actor: witness.actor,
|
|
parents,
|
|
payload: OpPayload::Witness {
|
|
effects_hash: witness.effects_hash,
|
|
extra_payload: witness.extra_payload.clone(),
|
|
op_id: witness.op_id.clone(),
|
|
state_root: witness.state_root,
|
|
witness_shape: witness.witness_shape.clone(),
|
|
witness_sig: witness.signature,
|
|
},
|
|
timestamp,
|
|
};
|
|
let op = Operation::sign(body, &ctx.signing_key);
|
|
let id = oplog
|
|
.append(&op)
|
|
.map_err(|e| persist_err(format!("oplog append: {e}")))?;
|
|
Ok(Some(id))
|
|
}
|
|
|
|
fn enforce_actor_policy(entry: &OperationEntry, actor_id: &str) -> Result<(), OpError> {
|
|
if entry.actor_policy.is_empty() {
|
|
return Ok(());
|
|
}
|
|
if entry.actor_policy.contains(&actor_id) {
|
|
return Ok(());
|
|
}
|
|
Err(OpError::ActorPolicyDenied {
|
|
actor_id: actor_id.to_owned(),
|
|
op_id: entry.id.to_owned(),
|
|
allowed: entry.actor_policy.iter().map(|s| s.to_string()).collect(),
|
|
})
|
|
}
|
|
|
|
fn load_precondition(
|
|
entry: &OperationEntry,
|
|
inputs: &serde_json::Value,
|
|
ctx: &OpContext,
|
|
) -> Result<serde_json::Value, OpError> {
|
|
match entry.precondition {
|
|
"" => Ok(serde_json::Value::Null),
|
|
"fsm_dimension_by_id" => load_fsm_dimension_slice(entry, inputs, ctx),
|
|
"ondaod" => load_ondaod_slice(entry, inputs, ctx),
|
|
"ontology_node_by_id" => load_ontology_node_slice(entry, inputs, ctx),
|
|
"ontology_edge_dag_check" => load_ontology_edge_dag_check_slice(entry, inputs, ctx),
|
|
"backlog_open_items" => load_backlog_slice(entry, inputs, ctx),
|
|
other => Err(OpError::PreconditionLoad {
|
|
op_id: entry.id.to_owned(),
|
|
reason: format!("no loader registered for precondition tag '{other}'"),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn load_ondaod_slice(
|
|
entry: &OperationEntry,
|
|
inputs: &serde_json::Value,
|
|
ctx: &OpContext,
|
|
) -> Result<serde_json::Value, OpError> {
|
|
// Phase 1: the ondaod evaluation comes in the inputs alongside the
|
|
// op-specific fields. The precondition load surface confirms its
|
|
// shape so pre-validators can rely on it; full integration with
|
|
// crate::precondition::ondaod::load arrives when the runtime takes
|
|
// ownership of the qa.ncl + core.ncl JSON snapshots.
|
|
let mut slice = serde_json::Map::new();
|
|
let ondaod = inputs.get("ondaod_evaluation").cloned().unwrap_or_else(|| {
|
|
serde_json::json!({ "engaged_tensions": [], "synthesis_state": "" })
|
|
});
|
|
slice.insert("ondaod".to_owned(), ondaod);
|
|
slice.insert("inputs".to_owned(), inputs.clone());
|
|
slice.insert(
|
|
"actor".to_owned(),
|
|
serde_json::Value::String(ctx.actor_id.clone()),
|
|
);
|
|
let _ = entry;
|
|
Ok(serde_json::Value::Object(slice))
|
|
}
|
|
|
|
fn load_ontology_node_slice(
|
|
entry: &OperationEntry,
|
|
inputs: &serde_json::Value,
|
|
ctx: &OpContext,
|
|
) -> Result<serde_json::Value, OpError> {
|
|
let node_id = inputs
|
|
.get("node_id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| OpError::PreconditionLoad {
|
|
op_id: entry.id.to_owned(),
|
|
reason: "missing required input 'node_id'".to_owned(),
|
|
})?;
|
|
let core = ctx.core.as_ref().ok_or_else(|| OpError::PreconditionLoad {
|
|
op_id: entry.id.to_owned(),
|
|
reason: "ctx.core is None — ontology_node_by_id requires a loaded core snapshot".to_owned(),
|
|
})?;
|
|
let existing_node = core
|
|
.get("nodes")
|
|
.and_then(|n| n.as_array())
|
|
.and_then(|arr| {
|
|
arr.iter()
|
|
.find(|n| n.get("id").and_then(|v| v.as_str()) == Some(node_id))
|
|
})
|
|
.cloned();
|
|
let mut slice = serde_json::Map::new();
|
|
slice.insert(
|
|
"existing_node".to_owned(),
|
|
existing_node.unwrap_or(serde_json::Value::Null),
|
|
);
|
|
slice.insert("inputs".to_owned(), inputs.clone());
|
|
Ok(serde_json::Value::Object(slice))
|
|
}
|
|
|
|
fn load_backlog_slice(
|
|
entry: &OperationEntry,
|
|
inputs: &serde_json::Value,
|
|
_ctx: &OpContext,
|
|
) -> Result<serde_json::Value, OpError> {
|
|
let _ = entry;
|
|
let mut slice = serde_json::Map::new();
|
|
slice.insert("inputs".to_owned(), inputs.clone());
|
|
Ok(serde_json::Value::Object(slice))
|
|
}
|
|
|
|
fn load_ontology_edge_dag_check_slice(
|
|
entry: &OperationEntry,
|
|
inputs: &serde_json::Value,
|
|
ctx: &OpContext,
|
|
) -> Result<serde_json::Value, OpError> {
|
|
let from = inputs
|
|
.get("from")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| OpError::PreconditionLoad {
|
|
op_id: entry.id.to_owned(),
|
|
reason: "missing 'from' input".to_owned(),
|
|
})?;
|
|
let to = inputs
|
|
.get("to")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| OpError::PreconditionLoad {
|
|
op_id: entry.id.to_owned(),
|
|
reason: "missing 'to' input".to_owned(),
|
|
})?;
|
|
// The reverse edge (to → from) is what would close a 1-step cycle.
|
|
let existing_reverse = ctx
|
|
.core
|
|
.as_ref()
|
|
.and_then(|c| c.get("edges"))
|
|
.and_then(|e| e.as_array())
|
|
.and_then(|arr| {
|
|
arr.iter()
|
|
.find(|e| {
|
|
e.get("from").and_then(|v| v.as_str()) == Some(to)
|
|
&& e.get("to").and_then(|v| v.as_str()) == Some(from)
|
|
})
|
|
.cloned()
|
|
})
|
|
.unwrap_or(serde_json::Value::Null);
|
|
let mut slice = serde_json::Map::new();
|
|
slice.insert("existing_reverse".to_owned(), existing_reverse);
|
|
slice.insert("inputs".to_owned(), inputs.clone());
|
|
Ok(serde_json::Value::Object(slice))
|
|
}
|
|
|
|
fn load_fsm_dimension_slice(
|
|
entry: &OperationEntry,
|
|
inputs: &serde_json::Value,
|
|
ctx: &OpContext,
|
|
) -> Result<serde_json::Value, OpError> {
|
|
let dim_id = inputs
|
|
.get("dimension")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| OpError::PreconditionLoad {
|
|
op_id: entry.id.to_owned(),
|
|
reason: "missing required input 'dimension'".to_owned(),
|
|
})?;
|
|
let state = ctx
|
|
.state
|
|
.as_ref()
|
|
.ok_or_else(|| OpError::PreconditionLoad {
|
|
op_id: entry.id.to_owned(),
|
|
reason: "ctx.state is None — fsm_dimension_by_id requires a loaded StateConfig".to_owned(),
|
|
})?;
|
|
let dimension = state
|
|
.dimensions
|
|
.iter()
|
|
.find(|d| d.id == dim_id)
|
|
.ok_or_else(|| OpError::PreconditionLoad {
|
|
op_id: entry.id.to_owned(),
|
|
reason: format!("dimension '{dim_id}' not present in ctx.state"),
|
|
})?;
|
|
let mut slice = serde_json::Map::new();
|
|
slice.insert(
|
|
"dimension".to_owned(),
|
|
serde_json::to_value(dimension).map_err(|e| OpError::PreconditionLoad {
|
|
op_id: entry.id.to_owned(),
|
|
reason: format!("serialising dimension: {e}"),
|
|
})?,
|
|
);
|
|
slice.insert("inputs".to_owned(), inputs.clone());
|
|
Ok(serde_json::Value::Object(slice))
|
|
}
|
|
|
|
fn run_validators(
|
|
op_id: &str,
|
|
validator_ids: &[&str],
|
|
slice: &serde_json::Value,
|
|
ctx: &ValidationCtx,
|
|
) -> Result<(), OpError> {
|
|
for vid in validator_ids {
|
|
if vid.is_empty() {
|
|
continue;
|
|
}
|
|
match validation_dispatch::dispatch(vid, slice, ctx) {
|
|
Some(Verdict::Accept) => {}
|
|
Some(other) => {
|
|
return Err(OpError::ValidatorRejected {
|
|
op_id: op_id.to_owned(),
|
|
verdict: other,
|
|
});
|
|
}
|
|
None => {
|
|
// Unknown validator id — fail loud rather than silently
|
|
// accept. Catalog/Rust coherence checks (O6) will catch
|
|
// this earlier in CI.
|
|
return Err(OpError::ValidatorRejected {
|
|
op_id: op_id.to_owned(),
|
|
verdict: Verdict::Unknown,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_effects(_entry: &OperationEntry, _output: &OpOutput, _ctx: &mut OpContext) {
|
|
// O2 stub: the op body is currently the only path that mutates the
|
|
// substrate. Declarative effect application lands when concrete
|
|
// `effects` strings are parsed in a later gate.
|
|
}
|
|
|
|
fn render_paths(entry: &OperationEntry, ctx: &mut OpContext) -> Result<(), OpError> {
|
|
let Some(root) = ctx.project_root.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
let Some(state) = ctx.state.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
for rel in entry.render_paths {
|
|
if rel.is_empty() {
|
|
continue;
|
|
}
|
|
let target = root.join(rel);
|
|
// Phase 1: only state.ncl renders are supported. Other render
|
|
// paths surface as OpError::Render with the NotImplemented
|
|
// reason — visible to consumers, no silent skip.
|
|
match ontoref_ontology::render_pipeline::render_and_write(state, &target) {
|
|
Ok(()) => {}
|
|
Err(e) => {
|
|
return Err(OpError::Render {
|
|
path: target.display().to_string(),
|
|
reason: e.to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
trait CommitLayerRoot {
|
|
fn commit_layer_root(&self) -> ontoref_commit::StateRoot;
|
|
}
|
|
|
|
impl CommitLayerRoot for OpContext {
|
|
fn commit_layer_root(&self) -> ontoref_commit::StateRoot {
|
|
self.commit_layer.root()
|
|
}
|
|
}
|
|
|
|
fn elapsed_us(t0: Instant) -> u64 {
|
|
let elapsed = t0.elapsed();
|
|
elapsed.as_micros().min(u64::MAX as u128) as u64
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[test]
|
|
fn pipeline() {
|
|
use ed25519_dalek::SigningKey;
|
|
use ontoref_derive::onto_operation;
|
|
|
|
// Noop op — empty inputs, no validators, no effects, no render
|
|
// paths. Exercises every pipeline step in skeleton form.
|
|
#[onto_operation(
|
|
id = "noop_pipeline_smoke",
|
|
description = "Noop op used by the O2 runtime smoke test — no precondition, no validators, no effects, no render paths.",
|
|
validation_sla = "Synchronous",
|
|
witness_shape = "noop"
|
|
)]
|
|
fn noop_pipeline_smoke(
|
|
_inputs: &serde_json::Value,
|
|
_ctx: &mut OpContext,
|
|
) -> Result<OpOutput, OpError> {
|
|
Ok(OpOutput::default())
|
|
}
|
|
|
|
let entry = inventory::iter::<OperationEntry>()
|
|
.find(|e| e.id == "noop_pipeline_smoke")
|
|
.expect("noop op must be registered");
|
|
|
|
let signing_key = SigningKey::from_bytes(&[3u8; 32]);
|
|
let mut ctx = OpContext::new_in_memory("test-actor", "", signing_key);
|
|
|
|
let inputs = serde_json::json!({});
|
|
let result = execute_op(entry, &inputs, &mut ctx).expect("pipeline succeeds");
|
|
|
|
// Witness signature verifies under the actor's public key.
|
|
assert_eq!(result.witness.op_id, "noop_pipeline_smoke");
|
|
assert_eq!(result.witness.witness_shape, "noop");
|
|
result
|
|
.witness
|
|
.verify()
|
|
.expect("witness must verify under signer's public key");
|
|
|
|
// No effects, no render paths — but metrics are populated.
|
|
assert!(result.effects.is_empty());
|
|
// The witness step always runs and records a non-zero (or at least
|
|
// observed) measurement.
|
|
let total_us = result.metrics.precondition_load_us
|
|
+ result.metrics.pre_validate_us
|
|
+ result.metrics.exec_us
|
|
+ result.metrics.apply_us
|
|
+ result.metrics.render_us
|
|
+ result.metrics.post_validate_us
|
|
+ result.metrics.witness_us;
|
|
let _ = total_us; // metrics emitted; the value itself is wall-clock dependent.
|
|
|
|
// Negative: tampering with the state root must fail verification.
|
|
let mut tampered = result.witness.clone();
|
|
tampered.state_root = [0xFFu8; 32];
|
|
assert!(
|
|
tampered.verify().is_err(),
|
|
"tampered state_root must not verify"
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[test]
|
|
fn pipeline_rejects_disallowed_actor() {
|
|
use ed25519_dalek::SigningKey;
|
|
use ontoref_derive::onto_operation;
|
|
|
|
/// Restricted op for actor-policy negative coverage.
|
|
#[onto_operation(
|
|
id = "restricted_op_smoke",
|
|
description = "Restricted to the admin role — used by O2 to verify actor policy enforcement.",
|
|
validation_sla = "Synchronous",
|
|
actor_policy = "admin",
|
|
witness_shape = "noop"
|
|
)]
|
|
fn restricted_op_smoke(
|
|
_inputs: &serde_json::Value,
|
|
_ctx: &mut OpContext,
|
|
) -> Result<OpOutput, OpError> {
|
|
Ok(OpOutput::default())
|
|
}
|
|
|
|
let entry = inventory::iter::<OperationEntry>()
|
|
.find(|e| e.id == "restricted_op_smoke")
|
|
.expect("op must be registered");
|
|
|
|
let signing_key = SigningKey::from_bytes(&[5u8; 32]);
|
|
let mut ctx = OpContext::new_in_memory("viewer", "", signing_key);
|
|
let err = execute_op(entry, &serde_json::json!({}), &mut ctx)
|
|
.expect_err("viewer must be rejected when admin is the only allowed role");
|
|
assert!(matches!(err, OpError::ActorPolicyDenied { .. }));
|
|
}
|
|
|
|
#[cfg(all(test, feature = "substrate"))]
|
|
#[test]
|
|
fn witness_persists_to_oplog_and_verifies_in_fresh_process() {
|
|
use std::sync::Arc;
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use ontoref_derive::onto_operation;
|
|
use ontoref_oplog::OpLog;
|
|
use ontoref_types::operation::OpPayload;
|
|
|
|
/// Noop op exercising the full pipeline including witness persistence.
|
|
#[onto_operation(
|
|
id = "substrate_persist_smoke",
|
|
description = "Noop op used by the substrate witness-persistence test — dispatches, signs, and appends to the oplog.",
|
|
validation_sla = "Synchronous",
|
|
witness_shape = "noop"
|
|
)]
|
|
fn substrate_persist_smoke(
|
|
_inputs: &serde_json::Value,
|
|
_ctx: &mut OpContext,
|
|
) -> Result<OpOutput, OpError> {
|
|
Ok(OpOutput::default())
|
|
}
|
|
|
|
let entry = inventory::iter::<OperationEntry>()
|
|
.find(|e| e.id == "substrate_persist_smoke")
|
|
.expect("op must be registered");
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let log_path = dir.path().join("oplog.redb");
|
|
let signing_key = SigningKey::from_bytes(&[7u8; 32]);
|
|
|
|
// Two dispatches share one oplog; the second witness must chain onto
|
|
// the first. Scoped so the redb handle closes before we reopen it,
|
|
// simulating a fresh process.
|
|
let (id1, id2) = {
|
|
let oplog = Arc::new(OpLog::open(&log_path).expect("open log"));
|
|
let mut ctx =
|
|
OpContext::new_in_memory("ci", "", signing_key).with_oplog(Arc::clone(&oplog));
|
|
let r1 = execute_op(entry, &serde_json::json!({}), &mut ctx).expect("dispatch 1");
|
|
let r2 = execute_op(entry, &serde_json::json!({}), &mut ctx).expect("dispatch 2");
|
|
let id1 = r1.oplog_op_id.expect("witness 1 persisted");
|
|
let id2 = r2.oplog_op_id.expect("witness 2 persisted");
|
|
assert_ne!(id1, id2, "distinct dispatches yield distinct oplog ids");
|
|
(id1, id2)
|
|
};
|
|
|
|
// Fresh process: reopen the durable log and verify every witness
|
|
// against the actor's public key — no trust in the producer.
|
|
let reopened = OpLog::open(&log_path).expect("reopen log");
|
|
assert_eq!(reopened.len().expect("len"), 2, "both witnesses durable");
|
|
|
|
let op2 = reopened.get(&id2).expect("get op2").expect("op2 present");
|
|
op2.verify_signature()
|
|
.expect("oplog op must verify in a fresh process");
|
|
assert_eq!(
|
|
op2.body.parents,
|
|
vec![id1],
|
|
"second witness chains onto the first — DAG position is bound"
|
|
);
|
|
|
|
// The persisted payload reconstructs the PipelineWitness, whose own
|
|
// Ed25519 signature also verifies.
|
|
assert!(
|
|
matches!(op2.body.payload, OpPayload::Witness { .. }),
|
|
"witness op must carry a Witness payload"
|
|
);
|
|
if let OpPayload::Witness {
|
|
effects_hash,
|
|
extra_payload,
|
|
op_id,
|
|
state_root,
|
|
witness_shape,
|
|
witness_sig,
|
|
} = op2.body.payload.clone()
|
|
{
|
|
let reconstructed = PipelineWitness {
|
|
op_id,
|
|
state_root,
|
|
effects_hash,
|
|
extra_payload,
|
|
witness_shape,
|
|
actor: op2.body.actor,
|
|
signature: witness_sig,
|
|
};
|
|
reconstructed
|
|
.verify()
|
|
.expect("reconstructed pipeline witness must verify");
|
|
}
|
|
|
|
// bl-022: tampering with the DAG position invalidates the operation
|
|
// signature — reorder/replay of effects is detectable from the
|
|
// witness alone.
|
|
let mut tampered = op2.clone();
|
|
tampered.body.parents.clear();
|
|
assert!(
|
|
tampered.verify_signature().is_err(),
|
|
"clearing parents must invalidate the witness signature"
|
|
);
|
|
}
|