use ferro_audit::{AuditActor, AuditEntry, AuditTarget};
use ferro_projections::ActionDef;
use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement};
use serde_json::Value;
use std::future::Future;
use std::pin::Pin;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum WriteError {
#[error("database error: {0}")]
Database(String),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("guard failed: {0}")]
GuardFailed(String),
#[error("validation error: {0}")]
Validation(String),
#[error("action not found: {0}")]
ActionNotFound(String),
#[cfg(feature = "confirmation")]
#[error("confirmation required for action: {0}")]
ConfirmationRequired(String),
}
pub type WriteResult<T> = Result<T, WriteError>;
pub type ExecutorFn = Box<
dyn Fn(
&str, // action_name
&Value, // validated inputs
i64, // tenant_id (from auth, never from payload)
&DatabaseConnection,
) -> Pin<Box<dyn Future<Output = WriteResult<Value>> + Send>>
+ Send
+ Sync,
>;
pub type GuardEvaluatorFn = Box<
dyn Fn(
&str, // guard_name
i64, // tenant_id
&Value, // validated inputs (for record-scoped guards)
&DatabaseConnection,
) -> Pin<Box<dyn Future<Output = WriteResult<bool>> + Send>>
+ Send
+ Sync,
>;
pub type OverrideFn = Box<
dyn Fn(
&str, // action_name
&Value, // validated inputs
i64, // tenant_id (from auth, never from payload)
&DatabaseConnection,
&Value, // base persist result
) -> Pin<Box<dyn Future<Output = WriteResult<()>> + Send>>
+ Send
+ Sync,
>;
pub struct WriteDispatcher {
pub executor: ExecutorFn,
pub guard_evaluator: GuardEvaluatorFn,
pub overrides: std::collections::HashMap<String, OverrideFn>,
}
impl WriteDispatcher {
pub fn new(executor: ExecutorFn, guard_evaluator: GuardEvaluatorFn) -> Self {
Self {
executor,
guard_evaluator,
overrides: std::collections::HashMap::new(),
}
}
pub fn with_override(mut self, action: impl Into<String>, hook: OverrideFn) -> Self {
self.overrides.insert(action.into(), hook);
self
}
}
pub fn merged_guards(preconditions: &[String], transition_guard: Option<&str>) -> Vec<String> {
let mut guards: Vec<String> = preconditions.to_vec();
if let Some(g) = transition_guard {
if !guards.iter().any(|existing| existing == g) {
guards.push(g.to_string());
}
}
guards
}
async fn lookup_idempotency(
tenant_id: i64,
key: &str,
db: &DatabaseConnection,
) -> WriteResult<Option<Value>> {
let backend = db.get_database_backend();
let (sql, values) = match backend {
DatabaseBackend::Postgres => (
"SELECT result FROM mcp_idempotency_keys WHERE tenant_id = $1 AND idempotency_key = $2"
.to_string(),
vec![
sea_orm::Value::BigInt(Some(tenant_id)),
sea_orm::Value::String(Some(Box::new(key.to_string()))),
],
),
_ => (
"SELECT result FROM mcp_idempotency_keys WHERE tenant_id = ? AND idempotency_key = ?"
.to_string(),
vec![
sea_orm::Value::BigInt(Some(tenant_id)),
sea_orm::Value::String(Some(Box::new(key.to_string()))),
],
),
};
let stmt = Statement::from_sql_and_values(backend, &sql, values);
match db
.query_one(stmt)
.await
.map_err(|e| WriteError::Database(e.to_string()))?
{
None => Ok(None),
Some(row) => {
let json_text: String = row
.try_get("", "result")
.map_err(|e| WriteError::Database(e.to_string()))?;
let value: Value = serde_json::from_str(&json_text)
.map_err(|e| WriteError::Database(e.to_string()))?;
Ok(Some(value))
}
}
}
async fn store_idempotency(
tenant_id: i64,
key: &str,
result: &Value,
db: &DatabaseConnection,
) -> WriteResult<()> {
let backend = db.get_database_backend();
let json_text = serde_json::to_string(result).map_err(WriteError::Serialization)?;
let (sql, values) = match backend {
DatabaseBackend::Postgres => (
"INSERT INTO mcp_idempotency_keys (tenant_id, idempotency_key, result, created_at) \
VALUES ($1, $2, $3, NOW()) ON CONFLICT (tenant_id, idempotency_key) DO NOTHING"
.to_string(),
vec![
sea_orm::Value::BigInt(Some(tenant_id)),
sea_orm::Value::String(Some(Box::new(key.to_string()))),
sea_orm::Value::String(Some(Box::new(json_text))),
],
),
_ => (
"INSERT OR IGNORE INTO mcp_idempotency_keys \
(tenant_id, idempotency_key, result) VALUES (?, ?, ?)"
.to_string(),
vec![
sea_orm::Value::BigInt(Some(tenant_id)),
sea_orm::Value::String(Some(Box::new(key.to_string()))),
sea_orm::Value::String(Some(Box::new(json_text))),
],
),
};
let stmt = Statement::from_sql_and_values(backend, &sql, values);
db.execute(stmt)
.await
.map_err(|e| WriteError::Database(e.to_string()))?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn dispatch_write(
action: &ActionDef,
inputs: &Value,
tenant_id: i64,
db: &DatabaseConnection,
dispatcher: &WriteDispatcher,
transition_guard: Option<&str>,
channel: &str,
#[cfg(feature = "confirmation")] is_confirmed: bool,
) -> WriteResult<Value> {
let guards = merged_guards(&action.preconditions, transition_guard);
for guard_name in &guards {
let passes = (dispatcher.guard_evaluator)(guard_name, tenant_id, inputs, db)
.await
.map_err(|e| WriteError::GuardFailed(format!("{guard_name}: {e}")))?;
if !passes {
return Err(WriteError::GuardFailed(format!(
"precondition '{guard_name}' not met"
)));
}
}
if let Some(key) = inputs.get("idempotency_key").and_then(|v| v.as_str()) {
if key.len() > 128 {
return Err(WriteError::Validation(
"idempotency_key must not exceed 128 characters".into(),
));
}
}
let idempotency_key = inputs.get("idempotency_key").and_then(|v| v.as_str());
if let Some(key) = idempotency_key {
if let Some(stored_result) = lookup_idempotency(tenant_id, key, db).await? {
return Ok(stored_result);
}
}
#[cfg(feature = "confirmation")]
if action.transition_trigger.is_some() && !is_confirmed {
return Err(WriteError::ConfirmationRequired(action.name.clone()));
}
#[cfg(not(feature = "confirmation"))]
let _ = &action.transition_trigger;
let result = (dispatcher.executor)(&action.name, inputs, tenant_id, db).await?;
if let Some(key) = idempotency_key {
store_idempotency(tenant_id, key, &result, db).await?;
}
let record_id = inputs.get("id").map(|v| v.to_string()).unwrap_or_default();
AuditEntry::record(format!("{channel}.action.{}", &action.name))
.tenant(tenant_id.to_string())
.actor(AuditActor::User(tenant_id.to_string()))
.target(AuditTarget::new(&action.name, record_id))
.after(result.clone())
.reason(&action.name)
.write(db)
.await
.map_err(|e| WriteError::Database(e.to_string()))?;
if let Some(hook) = dispatcher.overrides.get(&action.name) {
(hook)(&action.name, inputs, tenant_id, db, &result).await?;
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use ferro_projections::ActionDef;
use sea_orm::{ConnectionTrait, Database, DatabaseBackend, Statement};
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
async fn setup_db() -> sea_orm::DatabaseConnection {
let db = Database::connect("sqlite::memory:")
.await
.expect("in-memory SQLite connect failed");
db.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"CREATE TABLE IF NOT EXISTS mcp_idempotency_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
idempotency_key TEXT NOT NULL,
result TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (tenant_id, idempotency_key)
)"
.to_string(),
))
.await
.expect("create mcp_idempotency_keys table");
db.execute(Statement::from_string(
DatabaseBackend::Sqlite,
"CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT,
actor_kind TEXT NOT NULL,
actor_id TEXT,
action TEXT NOT NULL,
target_kind TEXT,
target_id TEXT,
before TEXT,
after TEXT,
reason TEXT,
correlation_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
.to_string(),
))
.await
.expect("create audit_log table");
db
}
fn approve_action() -> ActionDef {
ActionDef::new("approve")
.transition_trigger("approve")
.precondition("is_manager")
}
fn submit_action() -> ActionDef {
ActionDef::new("submit").transition_trigger("submit")
}
fn update_action() -> ActionDef {
ActionDef::new("update")
}
#[tokio::test]
async fn guard_denied_at_call_time() {
let db = setup_db().await;
let dispatcher = WriteDispatcher {
guard_evaluator: Box::new(|_, _, _, _| Box::pin(async { Ok(false) })),
executor: Box::new(|_, _, _, _| {
Box::pin(async { panic!("executor must not run when guard fails") })
}),
overrides: std::collections::HashMap::new(),
};
let result = dispatch_write(
&approve_action(),
&json!({"id": 1}),
1,
&db,
&dispatcher,
None,
"mcp",
#[cfg(feature = "confirmation")]
false,
)
.await;
assert!(
matches!(result, Err(WriteError::GuardFailed(_))),
"expected Err(GuardFailed(_)), got: {result:?}"
);
}
#[tokio::test]
async fn guard_rejects_illegal_transition() {
let db = setup_db().await;
let dispatcher = WriteDispatcher {
guard_evaluator: Box::new(|_, _, _, _| Box::pin(async { Ok(false) })),
executor: Box::new(|_, _, _, _| {
Box::pin(async { panic!("executor must not run when transition guard fails") })
}),
overrides: std::collections::HashMap::new(),
};
let result = dispatch_write(
&submit_action(),
&json!({"id": 1}),
1,
&db,
&dispatcher,
Some("is_manager"),
"mcp",
#[cfg(feature = "confirmation")]
false,
)
.await;
assert!(
matches!(result, Err(WriteError::GuardFailed(_))),
"expected Err(GuardFailed(_)) from the transition guard, got: {result:?}"
);
}
#[tokio::test]
async fn transition_guard_evaluated_at_call_time() {
let db = setup_db().await;
let saw_transition_guard = Arc::new(std::sync::atomic::AtomicBool::new(false));
let dispatcher = WriteDispatcher {
guard_evaluator: Box::new({
let flag = saw_transition_guard.clone();
move |name: &str, _, _, _| {
if name == "is_manager" {
flag.store(true, Ordering::SeqCst);
}
Box::pin(async { Ok(true) })
}
}),
executor: Box::new(|_, _, _, _| {
Box::pin(async { Ok(json!({ "status": "submitted" })) })
}),
overrides: std::collections::HashMap::new(),
};
let result = dispatch_write(
&submit_action(),
&json!({"id": 1}),
1,
&db,
&dispatcher,
Some("is_manager"),
"mcp",
#[cfg(feature = "confirmation")]
true,
)
.await;
assert!(
result.is_ok(),
"guard returns true, write must succeed: {result:?}"
);
assert!(
saw_transition_guard.load(Ordering::SeqCst),
"the transition-level guard 'is_manager' must be evaluated live"
);
}
#[tokio::test]
async fn guard_deduped_when_on_both() {
let db = setup_db().await;
let call_count = Arc::new(AtomicUsize::new(0));
let dispatcher = WriteDispatcher {
guard_evaluator: Box::new({
let count = call_count.clone();
move |name: &str, _, _, _| {
if name == "is_manager" {
count.fetch_add(1, Ordering::SeqCst);
}
Box::pin(async { Ok(true) })
}
}),
executor: Box::new(|_, _, _, _| {
Box::pin(async { Ok(json!({ "status": "approved" })) })
}),
overrides: std::collections::HashMap::new(),
};
let result = dispatch_write(
&approve_action(),
&json!({"id": 1}),
1,
&db,
&dispatcher,
Some("is_manager"),
"mcp",
#[cfg(feature = "confirmation")]
true,
)
.await;
assert!(
result.is_ok(),
"both guards pass; write must succeed: {result:?}"
);
assert_eq!(
call_count.load(Ordering::SeqCst),
1,
"is_manager must be evaluated exactly once (deduped), not twice"
);
}
#[tokio::test]
async fn override_hook_runs_post_persist() {
let db = setup_db().await;
let order = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new()));
let dispatcher = WriteDispatcher {
guard_evaluator: Box::new(|_, _, _, _| Box::pin(async { Ok(true) })),
executor: Box::new({
let order = order.clone();
move |_, _, _, _| {
order.lock().unwrap().push("persist");
Box::pin(async { Ok(json!({ "status": "submitted" })) })
}
}),
overrides: std::collections::HashMap::new(),
}
.with_override(
"submit",
Box::new({
let order = order.clone();
move |_action, _inputs, _tenant, _db, base_result: &Value| {
assert_eq!(base_result["status"], "submitted");
order.lock().unwrap().push("override");
Box::pin(async { Ok(()) })
}
}),
);
let result = dispatch_write(
&submit_action(),
&json!({"id": 1}),
1,
&db,
&dispatcher,
None,
"mcp",
#[cfg(feature = "confirmation")]
true,
)
.await;
assert!(result.is_ok(), "write must succeed: {result:?}");
assert_eq!(
result.unwrap(),
json!({ "status": "submitted" }),
"base result must be returned unchanged by the override"
);
assert_eq!(
*order.lock().unwrap(),
vec!["persist", "override"],
"override must run AFTER the base persist"
);
}
#[tokio::test]
async fn no_override_is_declaration_only() {
let db = setup_db().await;
let dispatcher = WriteDispatcher {
guard_evaluator: Box::new(|_, _, _, _| Box::pin(async { Ok(true) })),
executor: Box::new(|_, _, _, _| {
Box::pin(async { Ok(json!({ "status": "submitted" })) })
}),
overrides: std::collections::HashMap::new(),
};
let result = dispatch_write(
&submit_action(),
&json!({"id": 1}),
1,
&db,
&dispatcher,
None,
"mcp",
#[cfg(feature = "confirmation")]
true,
)
.await;
assert!(result.is_ok(), "no-override path must succeed: {result:?}");
assert_eq!(result.unwrap(), json!({ "status": "submitted" }));
}
#[tokio::test]
async fn override_error_surfaces() {
let db = setup_db().await;
let dispatcher = WriteDispatcher {
guard_evaluator: Box::new(|_, _, _, _| Box::pin(async { Ok(true) })),
executor: Box::new(|_, _, _, _| {
Box::pin(async { Ok(json!({ "status": "submitted" })) })
}),
overrides: std::collections::HashMap::new(),
}
.with_override(
"submit",
Box::new(|_action, _inputs, _tenant, _db, _base| {
Box::pin(async { Err(WriteError::Validation("override failed".into())) })
}),
);
let result = dispatch_write(
&submit_action(),
&json!({"id": 1, "idempotency_key": "ovr-fail-1"}),
1,
&db,
&dispatcher,
None,
"mcp",
#[cfg(feature = "confirmation")]
true,
)
.await;
assert!(
matches!(result, Err(WriteError::Validation(ref m)) if m == "override failed"),
"override error must propagate, got: {result:?}"
);
let audit_count: i64 = db
.query_one(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT COUNT(*) AS c FROM audit_log WHERE action = 'mcp.action.submit'"
.to_string(),
))
.await
.expect("audit query must succeed")
.expect("audit count row must exist")
.try_get::<i64>("", "c")
.expect("audit count column");
assert_eq!(
audit_count, 1,
"base persist must be audited even when the override fails (WR-01)"
);
let stored = lookup_idempotency(1, "ovr-fail-1", &db)
.await
.expect("idempotency lookup must succeed");
assert_eq!(
stored,
Some(json!({ "status": "submitted" })),
"base persist's idempotency key must be stored even when the override fails (WR-01)"
);
}
#[tokio::test]
async fn idempotent_replay_does_not_re_execute() {
let db = setup_db().await;
let exec_count = Arc::new(AtomicUsize::new(0));
let dispatcher = WriteDispatcher {
executor: Box::new({
let count = exec_count.clone();
move |_, _, _, _| {
count.fetch_add(1, Ordering::SeqCst);
Box::pin(async { Ok(json!({ "status": "submitted" })) })
}
}),
guard_evaluator: Box::new(|_, _, _, _| Box::pin(async { Ok(true) })),
overrides: std::collections::HashMap::new(),
};
let args = json!({ "id": 1, "idempotency_key": "k-abc" });
let result1 = dispatch_write(
&update_action(),
&args,
1,
&db,
&dispatcher,
None,
"mcp",
#[cfg(feature = "confirmation")]
false,
)
.await;
let result2 = dispatch_write(
&update_action(),
&args,
1,
&db,
&dispatcher,
None,
"mcp",
#[cfg(feature = "confirmation")]
false,
)
.await;
assert!(result1.is_ok(), "first call must succeed; got: {result1:?}");
assert!(
result2.is_ok(),
"second call must succeed (replay); got: {result2:?}"
);
assert_eq!(
result1.unwrap(),
result2.unwrap(),
"idempotent replay must return the same result"
);
assert_eq!(
exec_count.load(Ordering::SeqCst),
1,
"executor must fire exactly once despite two identical calls"
);
}
#[tokio::test]
async fn audit_channel_is_parameterized() {
let db = setup_db().await;
let dispatcher = WriteDispatcher {
guard_evaluator: Box::new(|_, _, _, _| Box::pin(async { Ok(true) })),
executor: Box::new(|_, _, _, _| {
Box::pin(async { Ok(json!({ "status": "submitted" })) })
}),
overrides: std::collections::HashMap::new(),
};
let result = dispatch_write(
&submit_action(),
&json!({"id": 1}),
1,
&db,
&dispatcher,
None,
"web",
#[cfg(feature = "confirmation")]
true,
)
.await;
assert!(result.is_ok(), "write must succeed: {result:?}");
let web_count: i64 = db
.query_one(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT COUNT(*) AS c FROM audit_log WHERE action = 'web.action.submit'"
.to_string(),
))
.await
.expect("audit query must succeed")
.expect("audit count row must exist")
.try_get::<i64>("", "c")
.expect("audit count column");
assert_eq!(
web_count, 1,
"audit action must be prefixed by the channel argument ('web')"
);
let mcp_count: i64 = db
.query_one(Statement::from_string(
DatabaseBackend::Sqlite,
"SELECT COUNT(*) AS c FROM audit_log WHERE action = 'mcp.action.submit'"
.to_string(),
))
.await
.expect("audit query must succeed")
.expect("audit count row must exist")
.try_get::<i64>("", "c")
.expect("audit count column");
assert_eq!(
mcp_count, 0,
"no mcp-channel audit must be written when channel is 'web'"
);
}
}