pub const SESSION_COMMIT_AUTO_COMMIT_INVARIANT: &str = "session.commit.auto_commit_mode";
use std::sync::{Arc, Mutex, RwLock};
use chrono::Utc;
use cortex_store::{repo::MemoryRepo, Pool};
use tracing::warn;
use ulid::Ulid;
use crate::tool_handler::{GateId, ToolError, ToolHandler};
#[derive(Debug)]
pub struct CortexSessionCommitTool {
pub pool: Arc<Mutex<Pool>>,
pub session_token: Arc<RwLock<Option<String>>>,
pub auto_commit: bool,
}
impl CortexSessionCommitTool {
#[must_use]
pub fn new(
pool: Arc<Mutex<Pool>>,
session_token: Arc<RwLock<Option<String>>>,
auto_commit: bool,
) -> Self {
Self {
pool,
session_token,
auto_commit,
}
}
}
impl ToolHandler for CortexSessionCommitTool {
fn name(&self) -> &'static str {
"cortex_session_commit"
}
fn gate_set(&self) -> &'static [GateId] {
&[GateId::CommitWrite]
}
fn call(&self, params: serde_json::Value) -> Result<serde_json::Value, ToolError> {
let confirmation_token = params
.get("confirmation_token")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParams(
"required parameter `confirmation_token` is missing or not a string".into(),
)
})?
.to_owned();
if self.auto_commit {
warn!(
invariant = SESSION_COMMIT_AUTO_COMMIT_INVARIANT,
"cortex_session_commit: auto-commit mode — token check bypassed \
(CORTEX_MCP_AUTO_COMMIT=1) [{}]",
SESSION_COMMIT_AUTO_COMMIT_INVARIANT,
);
} else {
if confirmation_token.is_empty() {
return Err(ToolError::InvalidParams(
"confirmation_token must not be empty".into(),
));
}
let stored_token = self
.session_token
.read()
.map_err(|_| ToolError::Internal("session token lock poisoned".into()))?
.clone();
match stored_token {
None => {
warn!("cortex_session_commit: session token not initialised — rejecting");
return Err(ToolError::Internal(
"server session token not initialised".into(),
));
}
Some(ref server_token) => {
if !tokens_equal(&confirmation_token, server_token) {
return Err(ToolError::PolicyRejected(
"invalid confirmation token".into(),
));
}
}
}
}
let now = Utc::now();
let receipt_id = Ulid::new().to_string();
let pool_guard = self
.pool
.lock()
.map_err(|_| ToolError::Internal("pool lock poisoned".into()))?;
let repo = MemoryRepo::new(&pool_guard);
let committed = repo
.commit_pending_mcp(&receipt_id, now)
.map_err(|err| ToolError::Internal(err.to_string()))?;
Ok(serde_json::json!({
"committed": committed,
"receipt_id": receipt_id,
}))
}
}
fn tokens_equal(a: &str, b: &str) -> bool {
let a = a.as_bytes();
let b = b.as_bytes();
let max_len = a.len().max(b.len());
let diff = (0..max_len).fold(0u8, |acc, i| {
let x = if i < a.len() { a[i] } else { 0 };
let y = if i < b.len() { b[i] } else { 0 };
acc | (x ^ y)
});
let len_diff = (a.len() ^ b.len()) as u8; (diff | len_diff) == 0
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool(token: Option<&str>) -> CortexSessionCommitTool {
make_tool_with_auto_commit(token, false)
}
fn make_tool_with_auto_commit(
token: Option<&str>,
auto_commit: bool,
) -> CortexSessionCommitTool {
let pool = Arc::new(Mutex::new(
cortex_store::Pool::open_in_memory().expect("in-memory sqlite"),
));
let session_token = Arc::new(RwLock::new(token.map(str::to_owned)));
CortexSessionCommitTool::new(pool, session_token, auto_commit)
}
fn make_tool_migrated(token: Option<&str>, auto_commit: bool) -> CortexSessionCommitTool {
let raw = cortex_store::Pool::open_in_memory().expect("in-memory sqlite");
cortex_store::migrate::apply_pending(&raw).expect("migrations must apply");
let pool = Arc::new(Mutex::new(raw));
let session_token = Arc::new(RwLock::new(token.map(str::to_owned)));
CortexSessionCommitTool::new(pool, session_token, auto_commit)
}
#[test]
fn missing_confirmation_token_returns_invalid_params() {
let tool = make_tool(Some("correct-token"));
let err = tool
.call(serde_json::json!({}))
.expect_err("must reject missing token");
assert!(
matches!(err, ToolError::InvalidParams(_)),
"expected InvalidParams, got: {err:?}"
);
}
#[test]
fn empty_confirmation_token_returns_invalid_params() {
let tool = make_tool(Some("correct-token"));
let err = tool
.call(serde_json::json!({ "confirmation_token": "" }))
.expect_err("must reject empty token");
assert!(
matches!(err, ToolError::InvalidParams(_)),
"expected InvalidParams, got: {err:?}"
);
}
#[test]
fn wrong_token_returns_policy_rejected() {
let tool = make_tool(Some("correct-token"));
let err = tool
.call(serde_json::json!({ "confirmation_token": "wrong-token" }))
.expect_err("must reject wrong token");
assert!(
matches!(err, ToolError::PolicyRejected(_)),
"expected PolicyRejected, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("invalid confirmation token"),
"error must cite ADR 0047 §3 message: {msg}"
);
}
#[test]
fn uninitialised_server_token_returns_internal() {
let tool = make_tool(None);
let err = tool
.call(serde_json::json!({ "confirmation_token": "anything" }))
.expect_err("must fail when server token is uninitialised");
assert!(
matches!(err, ToolError::Internal(_)),
"expected Internal, got: {err:?}"
);
}
#[test]
fn gate_set_declares_commit_write() {
let tool = make_tool(Some("tok"));
assert!(
tool.gate_set().contains(&GateId::CommitWrite),
"gate_set must include CommitWrite"
);
}
#[test]
fn tool_name_matches_schema_contract() {
let tool = make_tool(Some("tok"));
assert_eq!(tool.name(), "cortex_session_commit");
}
#[test]
fn auto_commit_accepts_arbitrary_token() {
let tool = make_tool_migrated(Some("server-tok"), true);
let result = tool.call(serde_json::json!({ "confirmation_token": "totally-wrong" }));
assert!(
result.is_ok(),
"auto_commit must accept any non-empty token: {result:?}"
);
let val = result.unwrap();
assert_eq!(val["committed"], 0, "no pending rows → committed must be 0");
}
#[test]
fn auto_commit_accepts_empty_token() {
let tool = make_tool_migrated(Some("server-tok"), true);
let result = tool.call(serde_json::json!({ "confirmation_token": "" }));
assert!(
result.is_ok(),
"auto_commit must accept empty token: {result:?}"
);
}
#[test]
fn auto_commit_rejects_missing_token_param() {
let tool = make_tool_with_auto_commit(Some("server-tok"), true);
let err = tool
.call(serde_json::json!({}))
.expect_err("missing param must still be rejected in auto_commit mode");
assert!(
matches!(err, ToolError::InvalidParams(_)),
"expected InvalidParams, got: {err:?}"
);
}
#[test]
fn auto_commit_invariant_constant_value() {
assert_eq!(
SESSION_COMMIT_AUTO_COMMIT_INVARIANT,
"session.commit.auto_commit_mode"
);
}
}