use std::path::Path;
use std::sync::Arc;
use anyhow::Context as _;
use crate::bootstrap::load_config_or_default;
use zeph_core::config::{Config, DurableConfig};
use zeph_core::durable::XChaCha20Poly1305Cipher;
use zeph_core::vault::AgeVaultProvider;
use zeph_durable::{CancelOutcome, ExecutionId, Journal, LocalBackend};
use crate::cli::DurableCommand;
const REVEAL_WARNING: &str =
"WARNING: --reveal decrypts and prints payload bytes in cleartext. Do not share this output.";
const CURRENT_KEY_VAULT_NAME: &str = "ZEPH_DURABLE_KEY";
const PREVIOUS_KEY_VAULT_NAME: &str = "ZEPH_DURABLE_KEY_PREVIOUS";
pub(crate) fn resolve_durable_db_url(config: &Config) -> String {
let main = config.memory.sqlite_path.as_str();
let Some(dir) = Path::new(main)
.parent()
.filter(|d| !d.as_os_str().is_empty())
else {
return "durable.db".to_owned();
};
let legacy = dir.join("durable.db");
if legacy.exists() {
return legacy.to_string_lossy().into_owned();
}
let file_name = Path::new(main).file_name().map_or_else(
|| "zeph.db".to_owned(),
|s| s.to_string_lossy().into_owned(),
);
dir.join(format!("{file_name}.durable.db"))
.to_string_lossy()
.into_owned()
}
fn fmt_ts(ms: i64) -> String {
chrono::DateTime::from_timestamp_millis(ms).map_or_else(
|| ms.to_string(),
|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string(),
)
}
fn is_shared_db(config: &zeph_core::config::DurableConfig, url: &str) -> bool {
config.shared_db || url.starts_with("postgres://") || url.starts_with("postgresql://")
}
pub(crate) fn enforce_encryption_gate(
config: &zeph_core::config::DurableConfig,
url: &str,
) -> anyhow::Result<()> {
let shared_db = is_shared_db(config, url);
match zeph_durable::encryption_gate(config, shared_db) {
Ok(zeph_durable::EncryptionGate::Enabled) => Ok(()),
Ok(zeph_durable::EncryptionGate::DisabledLocalWarn) => {
tracing::warn!(
"durable: AEAD payload encryption is disabled (encrypt_payload = false); \
journal payloads are stored in plaintext. This is a development-only override, \
permitted only for a single-user local, non-shared database (INV-8)."
);
Ok(())
}
Err(e) => Err(anyhow::Error::new(e).context("durable execution security policy")),
}
}
fn load_durable_cipher(
config: &DurableConfig,
key_path: &Path,
vault_path: &Path,
) -> anyhow::Result<XChaCha20Poly1305Cipher> {
let provider = AgeVaultProvider::load(key_path, vault_path)
.map_err(|e| anyhow::anyhow!("failed to load vault: {e}"))?;
let key = provider.get(CURRENT_KEY_VAULT_NAME).ok_or_else(|| {
anyhow::anyhow!("{CURRENT_KEY_VAULT_NAME} not found in vault; cannot --reveal payloads")
})?;
let mut cipher = XChaCha20Poly1305Cipher::from_vault_b64_with_id(config.key_id, key)
.map_err(|e| anyhow::anyhow!("invalid {CURRENT_KEY_VAULT_NAME}: {e}"))?;
if let Some(prev_id) = config.previous_key_id {
let prev_key = provider.get(PREVIOUS_KEY_VAULT_NAME).ok_or_else(|| {
anyhow::anyhow!(
"durable config declares previous_key_id = {prev_id} but \
{PREVIOUS_KEY_VAULT_NAME} is missing from the vault; refusing to build a \
cipher with an inconsistent rotation state (this can happen if a previous \
`zeph durable rotate-key` run crashed mid-write) — reconcile config and vault \
to a consistent pair, or re-run `zeph durable rotate-key` to see recovery \
guidance, before retrying"
)
})?;
let prev_bytes = zeph_core::durable::decode_vault_key_bytes(prev_key)
.map_err(|e| anyhow::anyhow!("invalid {PREVIOUS_KEY_VAULT_NAME}: {e}"))?;
cipher = cipher.with_previous(prev_id, prev_bytes);
}
Ok(cipher)
}
#[derive(Default)]
pub(crate) struct ControlHmacKeys {
pub(crate) current: Option<[u8; 32]>,
pub(crate) previous: Option<[u8; 32]>,
}
fn load_control_hmac_key(
config: &zeph_core::config::DurableConfig,
url: &str,
key_path: &Path,
vault_path: &Path,
) -> anyhow::Result<ControlHmacKeys> {
if !is_shared_db(config, url) {
return Ok(ControlHmacKeys::default());
}
let provider = AgeVaultProvider::load(key_path, vault_path)
.map_err(|e| anyhow::anyhow!("failed to load vault: {e}"))?;
let key = provider.get(CURRENT_KEY_VAULT_NAME).ok_or_else(|| {
anyhow::anyhow!(
"{CURRENT_KEY_VAULT_NAME} not found in vault; required to compute the control-entry \
row HMAC on a shared database (INV-8)"
)
})?;
let current = zeph_core::durable::derive_control_hmac_key_b64(key).map_err(|e| {
anyhow::anyhow!("invalid {CURRENT_KEY_VAULT_NAME} for control-entry HMAC derivation: {e}")
})?;
let previous = if let Some(prev_id) = config.previous_key_id {
let prev_key = provider.get(PREVIOUS_KEY_VAULT_NAME).ok_or_else(|| {
anyhow::anyhow!(
"durable config declares previous_key_id = {prev_id} but \
{PREVIOUS_KEY_VAULT_NAME} is missing from the vault; refusing to build \
control-entry HMAC keys with an inconsistent rotation state (this can happen if \
a previous `zeph durable rotate-key` run crashed mid-write) — reconcile config \
and vault to a consistent pair, or re-run `zeph durable rotate-key` to see \
recovery guidance, before retrying"
)
})?;
Some(
zeph_core::durable::derive_control_hmac_key_b64(prev_key).map_err(|e| {
anyhow::anyhow!(
"invalid {PREVIOUS_KEY_VAULT_NAME} for control-entry HMAC derivation: {e}"
)
})?,
)
} else {
None
};
Ok(ControlHmacKeys {
current: Some(current),
previous,
})
}
pub(crate) fn load_write_hmac_key(
config: &Config,
key_path: &Path,
vault_path: &Path,
) -> anyhow::Result<ControlHmacKeys> {
let url = resolve_durable_db_url(config);
load_control_hmac_key(&config.durable, &url, key_path, vault_path)
}
pub(crate) struct HwmSlot {
pub(crate) epoch: u32,
pub(crate) key: [u8; 32],
}
#[derive(Default)]
pub(crate) struct HwmKeys {
pub(crate) current: Option<HwmSlot>,
pub(crate) previous: Option<HwmSlot>,
}
pub(crate) fn load_write_hwm_key(
config: &Config,
key_path: &Path,
vault_path: &Path,
) -> anyhow::Result<HwmKeys> {
let Ok(provider) = AgeVaultProvider::load(key_path, vault_path) else {
return Ok(HwmKeys::default());
};
let Some(key) = provider.get(CURRENT_KEY_VAULT_NAME) else {
return Ok(HwmKeys::default());
};
let hwm_key = zeph_core::durable::derive_hwm_key_b64(key).map_err(|e| {
anyhow::anyhow!("invalid {CURRENT_KEY_VAULT_NAME} for high-water-mark derivation: {e}")
})?;
let current = Some(HwmSlot {
epoch: u32::from(config.durable.key_id),
key: hwm_key,
});
let previous = if let Some(prev_id) = config.durable.previous_key_id {
let prev_key = provider.get(PREVIOUS_KEY_VAULT_NAME).ok_or_else(|| {
anyhow::anyhow!(
"durable config declares previous_key_id = {prev_id} but \
{PREVIOUS_KEY_VAULT_NAME} is missing from the vault; refusing to build \
high-water-mark keys with an inconsistent rotation state (this can happen if \
a previous `zeph durable rotate-key` run crashed mid-write) — reconcile config \
and vault to a consistent pair, or re-run `zeph durable rotate-key` to see \
recovery guidance, before retrying"
)
})?;
Some(HwmSlot {
epoch: u32::from(prev_id),
key: zeph_core::durable::derive_hwm_key_b64(prev_key).map_err(|e| {
anyhow::anyhow!(
"invalid {PREVIOUS_KEY_VAULT_NAME} for high-water-mark derivation: {e}"
)
})?,
})
} else {
None
};
Ok(HwmKeys { current, previous })
}
pub(crate) fn load_integrity_seal(
_config: &Config,
key_path: &Path,
vault_path: &Path,
) -> (bool, std::collections::HashSet<zeph_durable::ExecutionId>) {
let Ok(provider) = AgeVaultProvider::load(key_path, vault_path) else {
return (false, std::collections::HashSet::new());
};
zeph_core::anchor_store::load_durable_integrity_seal(&provider)
}
pub(crate) fn load_write_cipher(
config: &Config,
key_path: &Path,
vault_path: &Path,
) -> anyhow::Result<Option<Arc<dyn zeph_durable::PayloadCipher>>> {
let url = resolve_durable_db_url(config);
enforce_encryption_gate(&config.durable, &url)?;
if !config.durable.encrypt_payload {
return Ok(None);
}
let cipher = load_durable_cipher(&config.durable, key_path, vault_path)?;
Ok(Some(Arc::new(cipher)))
}
async fn open_backend(
config: &Config,
reveal: bool,
key_path: &Path,
vault_path: &Path,
) -> anyhow::Result<Option<LocalBackend>> {
let url = resolve_durable_db_url(config);
enforce_encryption_gate(&config.durable, &url)?;
let hmac_keys = load_control_hmac_key(&config.durable, &url, key_path, vault_path)?;
let hwm_keys = load_write_hwm_key(config, key_path, vault_path)?;
if url != ":memory:" && !Path::new(&url).exists() {
println!(
"No durable journal at {url}.\n\
Durable execution may be disabled; enable it with `[durable] enabled = true`."
);
return Ok(None);
}
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.map_err(|e| anyhow::anyhow!("failed to open durable journal: {e}"))?;
backend
.init()
.await
.map_err(|e| anyhow::anyhow!("failed to initialize durable schema: {e}"))?;
let backend = if let Some(key) = hmac_keys.current {
backend.with_hmac_key(key)
} else {
backend
};
let backend = if let Some(key) = hmac_keys.previous {
backend.with_previous_hmac_key(key)
} else {
backend
};
let backend = if let Some(slot) = hwm_keys.current {
backend.with_hwm_key(slot.epoch, slot.key)
} else {
backend
};
let backend = if let Some(slot) = hwm_keys.previous {
backend.with_previous_hwm_key(slot.epoch, slot.key)
} else {
backend
};
if reveal && config.durable.encrypt_payload {
let cipher = load_durable_cipher(&config.durable, key_path, vault_path)?;
Ok(Some(backend.with_cipher(Arc::new(cipher))))
} else {
Ok(Some(backend))
}
}
pub(crate) async fn handle_durable_command(
cmd: DurableCommand,
config_path: Option<&Path>,
vault_override: Option<&str>,
vault_key_override: Option<&Path>,
vault_path_override: Option<&Path>,
) -> anyhow::Result<()> {
Box::pin(handle_durable_command_inner(
cmd,
config_path,
vault_override,
vault_key_override,
vault_path_override,
))
.await
}
#[allow(clippy::too_many_lines)]
async fn handle_durable_command_inner(
cmd: DurableCommand,
config_path: Option<&Path>,
vault_override: Option<&str>,
vault_key_override: Option<&Path>,
vault_path_override: Option<&Path>,
) -> anyhow::Result<()> {
let config_file = crate::bootstrap::resolve_config_path(config_path);
let config = load_config_or_default(&config_file)?;
let (vault_key_path, vault_secrets_path) = crate::bootstrap::resolve_vault_paths(
&config,
vault_override,
vault_key_override,
vault_path_override,
);
match cmd {
DurableCommand::List {
status,
kind,
limit,
json,
} => {
let Some(backend) =
open_backend(&config, false, &vault_key_path, &vault_secrets_path).await?
else {
return Ok(());
};
let rows = backend
.list_executions(status.as_deref(), kind.as_deref(), limit)
.await
.map_err(|e| anyhow::anyhow!("failed to list executions: {e}"))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&rows)
.context("failed to serialize execution list")?
);
} else {
if rows.is_empty() {
println!("No durable executions match.");
return Ok(());
}
println!(
"{:<36} {:<18} {:<10} {:>6} CREATED",
"EXECUTION ID", "KIND", "STATUS", "STEPS"
);
println!("{}", "-".repeat(96));
for row in &rows {
println!(
"{:<36} {:<18} {:<10} {:>6} {}",
row.execution_id.as_uuid(),
row.kind,
row.status.as_str(),
row.step_count,
fmt_ts(row.created_at_ms),
);
}
}
}
DurableCommand::Show { id, reveal, json } => {
let exec = ExecutionId::parse_str(&id)
.map_err(|e| anyhow::anyhow!("invalid execution id '{id}': {e}"))?;
let Some(backend) =
open_backend(&config, reveal, &vault_key_path, &vault_secrets_path).await?
else {
return Ok(());
};
show_entries(&backend, exec, reveal, None, json).await?;
}
DurableCommand::Inspect {
id,
step,
reveal,
json,
} => {
let exec = ExecutionId::parse_str(&id)
.map_err(|e| anyhow::anyhow!("invalid execution id '{id}': {e}"))?;
let Some(backend) =
open_backend(&config, reveal, &vault_key_path, &vault_secrets_path).await?
else {
return Ok(());
};
show_entries(&backend, exec, reveal, Some(step), json).await?;
}
DurableCommand::Prune { dry_run } => {
let Some(backend) =
open_backend(&config, false, &vault_key_path, &vault_secrets_path).await?
else {
return Ok(());
};
let policy = &config.durable.retention;
if dry_run {
let orphans = backend
.count_orphans(policy)
.await
.map_err(|e| anyhow::anyhow!("failed to count orphaned executions: {e}"))?;
println!("Dry run: {orphans} orphaned execution(s) would be aborted.");
let n = backend
.count_prunable(policy)
.await
.map_err(|e| anyhow::anyhow!("failed to count prunable executions: {e}"))?;
println!("Dry run: {n} terminal execution(s) past TTL would be pruned.");
} else {
let aborted = backend
.sweep_orphans(policy)
.await
.map_err(|e| anyhow::anyhow!("failed to sweep orphaned executions: {e}"))?;
println!("Aborted {aborted} orphaned execution(s).");
let n = backend
.prune(policy)
.await
.map_err(|e| anyhow::anyhow!("failed to prune journal: {e}"))?;
println!("Pruned {n} execution(s).");
}
}
DurableCommand::Resume { id } => {
let exec = ExecutionId::parse_str(&id)
.map_err(|e| anyhow::anyhow!("invalid execution id '{id}': {e}"))?;
let Some(backend) =
open_backend(&config, false, &vault_key_path, &vault_secrets_path).await?
else {
return Ok(());
};
if backend
.execution_status(exec)
.await
.map_err(|e| anyhow::anyhow!("failed to look up execution status: {e}"))?
== Some(zeph_durable::ExecutionStatus::Canceled)
{
println!("Execution {id} was intentionally canceled and will not be resumed.");
return Ok(());
}
let entries = backend
.read_execution_redacted(exec)
.await
.map_err(|e| anyhow::anyhow!("failed to read execution: {e}"))?;
if entries.is_empty() {
println!("No journal entries found for execution {id}.");
return Ok(());
}
println!(
"Execution {id} has {} journaled step(s).\n\
Automatic resume is performed by the agent process for supported execution kinds; \
standalone CLI replay is not available in this build (durable adapters A1-A4).",
entries.len()
);
}
DurableCommand::Cancel { id } => {
let exec = ExecutionId::parse_str(&id)
.map_err(|e| anyhow::anyhow!("invalid execution id '{id}': {e}"))?;
let Some(backend) =
open_backend(&config, false, &vault_key_path, &vault_secrets_path).await?
else {
return Ok(());
};
let outcome = backend
.cancel_execution(exec)
.await
.map_err(|e| anyhow::anyhow!("failed to cancel execution: {e}"))?;
match outcome {
CancelOutcome::Canceled => {
println!("Execution {id} canceled; it will not be resumed.");
}
CancelOutcome::AlreadyTerminal { status } => {
println!(
"Execution {id} is already {}; nothing to cancel.",
status.as_str()
);
}
CancelOutcome::NotFound => {
anyhow::bail!("No durable execution {id} found.");
}
CancelOutcome::LiveOwner { pid } => {
anyhow::bail!(
"Execution {id} is currently locked by pid {pid} (an active owner, or a \
maintenance sweep/prune); live cancellation is not yet supported. If \
that process is the owner, stop it (or wait for it to exit) then re-run \
cancel; if it was a transient sweep, just retry shortly."
);
}
CancelOutcome::LivenessUnverifiable => {
anyhow::bail!(
"Cannot verify whether execution {id} has a live owner on this backend \
(no advisory lock available); refusing to cancel to avoid stopping a \
running execution unsafely."
);
}
}
}
DurableCommand::RotateKey {
dry_run,
drop_previous,
force,
} => {
handle_rotate_key(
&config_file,
&config,
RotateKeyOptions {
dry_run,
drop_previous,
force,
},
&vault_key_path,
&vault_secrets_path,
)
.await?;
}
DurableCommand::SealIntegrity {
grandfather,
dry_run,
} => {
handle_seal_integrity(
&config,
&grandfather,
dry_run,
&vault_key_path,
&vault_secrets_path,
)
.await?;
}
}
Ok(())
}
async fn handle_seal_integrity(
config: &Config,
grandfather: &[String],
dry_run: bool,
key_path: &Path,
vault_path: &Path,
) -> anyhow::Result<()> {
let Some(backend) = open_backend(config, false, key_path, vault_path).await? else {
anyhow::bail!("no durable journal found; nothing to seal");
};
let grandfather_ids: std::collections::HashSet<ExecutionId> = grandfather
.iter()
.map(|s| {
ExecutionId::parse_str(s.trim())
.map_err(|e| anyhow::anyhow!("invalid --grandfather execution id {s:?}: {e}"))
})
.collect::<Result<_, _>>()?;
let offending = backend
.find_unsealed_resumable_executions()
.await
.map_err(|e| anyhow::anyhow!("drain-precondition scan failed: {e}"))?;
let still_blocking: Vec<ExecutionId> = offending
.into_iter()
.filter(|id| !grandfather_ids.contains(id))
.collect();
if !still_blocking.is_empty() {
let ids: Vec<String> = still_blocking
.iter()
.map(|id| id.as_uuid().to_string())
.collect();
anyhow::bail!(
"refusing to seal: {} resumable execution(s) have committed StepResults but no \
integrity row:\n {}\n\
Let them drain to a terminal status, or pass --grandfather <id,...> to explicitly \
opt them out (a permanent, documented per-execution downgrade-resistance waiver — \
prefer draining where practical).",
ids.len(),
ids.join("\n ")
);
}
if dry_run {
println!(
"Drain precondition satisfied — {} execution(s) would be grandfathered. \
Dry run: vault not modified.",
grandfather_ids.len()
);
return Ok(());
}
if !key_path.exists() || !vault_path.exists() {
anyhow::bail!(
"no age vault found (key: {}, vault: {}); run `zeph --init` first",
key_path.display(),
vault_path.display()
);
}
let mut provider = AgeVaultProvider::load(key_path, vault_path)
.map_err(|e| anyhow::anyhow!("failed to load vault: {e}"))?;
if !grandfather_ids.is_empty() {
let existing = provider
.get(zeph_core::anchor_store::DURABLE_INTEGRITY_GRANDFATHER_KEY)
.unwrap_or("");
let rendered = zeph_core::anchor_store::render_grandfather_set(existing, &grandfather_ids);
provider
.set_secret_mut(
zeph_core::anchor_store::DURABLE_INTEGRITY_GRANDFATHER_KEY.to_owned(),
rendered,
true,
)
.map_err(|e| anyhow::anyhow!("failed to record grandfather set: {e}"))?;
}
let sealed_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string();
provider
.set_secret_mut(
zeph_core::anchor_store::DURABLE_INTEGRITY_SEALED_KEY.to_owned(),
sealed_at,
true,
)
.map_err(|e| anyhow::anyhow!("failed to record seal marker: {e}"))?;
provider
.save()
.map_err(|e| anyhow::anyhow!("failed to save vault: {e}"))?;
println!(
"Durable backend sealed against pre-feature integrity-row absence (issue #6449).{}",
if grandfather_ids.is_empty() {
String::new()
} else {
format!(" {} execution(s) grandfathered.", grandfather_ids.len())
}
);
Ok(())
}
#[allow(clippy::struct_excessive_bools)]
struct RotateKeyOptions {
dry_run: bool,
drop_previous: bool,
force: bool,
}
async fn handle_rotate_key(
config_file: &Path,
config: &Config,
opts: RotateKeyOptions,
key_path: &Path,
vault_path: &Path,
) -> anyhow::Result<()> {
if !key_path.exists() || !vault_path.exists() {
anyhow::bail!(
"no age vault found (key: {}, vault: {}); run `zeph --init` first to generate \
{CURRENT_KEY_VAULT_NAME}",
key_path.display(),
vault_path.display()
);
}
let mut provider = AgeVaultProvider::load(key_path, vault_path)
.map_err(|e| anyhow::anyhow!("failed to load vault: {e}"))?;
let window_declared = config.durable.previous_key_id.is_some();
let prev_secret_present = provider.get(PREVIOUS_KEY_VAULT_NAME).is_some();
if window_declared != prev_secret_present {
anyhow::bail!(
"inconsistent rotation state detected -- refusing to proceed.\n\
config: [durable] previous_key_id = {:?}\n\
vault: {PREVIOUS_KEY_VAULT_NAME} is {}\n\
\n\
This usually means a crash interrupted a previous `zeph durable rotate-key` run \
between its config write and its vault write, or one side was hand-edited. Manual \
recovery is required before retrying -- never derive the old key from whatever \
{CURRENT_KEY_VAULT_NAME} currently holds:\n\
- If {PREVIOUS_KEY_VAULT_NAME} is absent, {CURRENT_KEY_VAULT_NAME} in the vault \
still holds the pre-rotation key: edit [durable] in {} and remove \
`previous_key_id` (restoring `key_id` to its prior value), then retry.\n\
- If {PREVIOUS_KEY_VAULT_NAME} is present but config has no `previous_key_id`, \
either restore `previous_key_id` in the config to match, or remove the orphaned \
vault secret (`zeph vault rm {PREVIOUS_KEY_VAULT_NAME}`) once you have confirmed \
it is not needed.",
config.durable.previous_key_id,
if prev_secret_present {
"present"
} else {
"absent"
},
config_file.display(),
);
}
if opts.drop_previous {
handle_drop_previous(
config_file,
config,
&mut provider,
opts.dry_run,
opts.force,
vault_path,
)
.await
} else {
handle_open_window(config_file, config, &mut provider, opts.dry_run, vault_path)
}
}
fn handle_open_window(
config_file: &Path,
config: &Config,
provider: &mut AgeVaultProvider,
dry_run: bool,
vault_path: &Path,
) -> anyhow::Result<()> {
if let Some(prev_id) = config.durable.previous_key_id {
anyhow::bail!(
"a rotation window is already open (previous_key_id = {prev_id}); close it with \
`zeph durable rotate-key --drop-previous` after the retention window has elapsed, \
then rotate again"
);
}
let Some(old_key_b64) = provider.get(CURRENT_KEY_VAULT_NAME).map(str::to_owned) else {
anyhow::bail!("{CURRENT_KEY_VAULT_NAME} not found in vault; nothing to rotate");
};
let old_key_id = config.durable.key_id;
let new_key_id = old_key_id.wrapping_add(1);
if dry_run {
println!(
"DRY RUN -- no changes written.\n\
Would rotate ZEPH_DURABLE_KEY: key_id {old_key_id} -> {new_key_id}\n\
Would set [durable] key_id = {new_key_id}, previous_key_id = {old_key_id} in {}\n\
Would generate a new {CURRENT_KEY_VAULT_NAME} and stash the old key under \
{PREVIOUS_KEY_VAULT_NAME} in {}\n\
A process restart is required to pick up the rotated key (no hot-reload).",
config_file.display(),
vault_path.display(),
);
return Ok(());
}
let new_key_b64 = zeph_core::durable::generate_durable_key_b64();
write_durable_config_fields(config_file, new_key_id, Some(old_key_id))?;
provider
.set_secret_mut(PREVIOUS_KEY_VAULT_NAME.to_owned(), old_key_b64, true)
.map_err(|e| anyhow::anyhow!("failed to stash previous key in vault: {e}"))?;
provider
.set_secret_mut(CURRENT_KEY_VAULT_NAME.to_owned(), new_key_b64, true)
.map_err(|e| anyhow::anyhow!("failed to set new {CURRENT_KEY_VAULT_NAME}: {e}"))?;
provider
.save()
.map_err(|e| anyhow::anyhow!("failed to save vault: {e}"))?;
println!(
"Rotated ZEPH_DURABLE_KEY: key_id {old_key_id} -> {new_key_id}.\n\
The previous key (id {old_key_id}) remains readable via the rotation window until you \
run `zeph durable rotate-key --drop-previous`.\n\
Config updated: {}\n\
Vault updated: {}\n\
A process restart is required to pick up the rotated key -- the cipher is built once \
at startup and does not hot-reload.",
config_file.display(),
vault_path.display(),
);
Ok(())
}
fn resolve_hmac_keys_for_drop_scan(
provider: &AgeVaultProvider,
previous_key_id: u8,
) -> anyhow::Result<([u8; 32], [u8; 32])> {
let current_key_b64 = provider.get(CURRENT_KEY_VAULT_NAME).ok_or_else(|| {
anyhow::anyhow!(
"{CURRENT_KEY_VAULT_NAME} not found in vault; cannot verify control-entry HMACs \
before dropping the previous key"
)
})?;
let current_hmac_key = zeph_core::durable::derive_control_hmac_key_b64(current_key_b64)
.map_err(|e| {
anyhow::anyhow!(
"invalid {CURRENT_KEY_VAULT_NAME} for control-entry HMAC derivation: {e}"
)
})?;
let previous_key_b64 = provider.get(PREVIOUS_KEY_VAULT_NAME).ok_or_else(|| {
anyhow::anyhow!(
"durable config declares previous_key_id = {previous_key_id} but \
{PREVIOUS_KEY_VAULT_NAME} is missing from the vault; refusing to verify \
control-entry HMACs with an inconsistent rotation state"
)
})?;
let previous_hmac_key = zeph_core::durable::derive_control_hmac_key_b64(previous_key_b64)
.map_err(|e| {
anyhow::anyhow!(
"invalid {PREVIOUS_KEY_VAULT_NAME} for control-entry HMAC derivation: {e}"
)
})?;
Ok((current_hmac_key, previous_hmac_key))
}
async fn handle_drop_previous(
config_file: &Path,
config: &Config,
provider: &mut AgeVaultProvider,
dry_run: bool,
force: bool,
vault_path: &Path,
) -> anyhow::Result<()> {
let Some(previous_key_id) = config.durable.previous_key_id else {
println!("No rotation window is open; nothing to drop.");
return Ok(());
};
if !force {
let url = resolve_durable_db_url(config);
if url != ":memory:" && Path::new(&url).exists() {
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.map_err(|e| anyhow::anyhow!("failed to open durable journal: {e}"))?;
let matches = backend
.count_sealed_under_key_id(previous_key_id)
.await
.map_err(|e| {
anyhow::anyhow!("failed to scan journal for key-id {previous_key_id}: {e}")
})?;
if matches > 0 {
anyhow::bail!(
"refusing to drop: {matches} sealed payload(s) in {url} still tagged \
key_id = {previous_key_id}. Dropping the previous key now would make them \
permanently unreadable (UnknownKeyId). Wait for retention to prune them, or \
pass --force to skip this scan if you have independently confirmed pruning \
is complete (note: a deployment with plaintext-mode rows -- \
encrypt_payload = false -- can occasionally over-count a coincidental \
leading byte match; that is fail-safe, never a missed match)."
);
}
let hwm_matches = backend
.count_integrity_rows_under_epoch(u32::from(previous_key_id))
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to scan high-water-mark rows for epoch {previous_key_id}: {e}"
)
})?;
if hwm_matches > 0 {
anyhow::bail!(
"refusing to drop: {hwm_matches} execution(s) in {url} still carry a \
high-water-mark signed under the previous key epoch ({previous_key_id}). \
Dropping the previous key now would make them unresumable \
(HighWaterMarkIntegrity: key_epoch_unresolvable) — this includes \
checkpoint-folded executions that the payload and control-entry scans \
cannot see. Wait for retention to prune the owning executions, or pass \
--force to skip this scan if you have independently confirmed pruning is \
complete."
);
}
let (current_hmac_key, previous_hmac_key) =
resolve_hmac_keys_for_drop_scan(provider, previous_key_id)?;
let hmac_backend = backend
.with_hmac_key(current_hmac_key)
.with_previous_hmac_key(previous_hmac_key);
let hmac_matches = hmac_backend
.count_control_entries_under_previous_hmac()
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to scan control entries for previous-key-only HMACs: {e}"
)
})?;
if hmac_matches > 0 {
anyhow::bail!(
"refusing to drop: {hmac_matches} control entry(ies) in {url} still verify \
only under the previous control-entry HMAC key. Dropping the previous key \
now would make them permanently unreadable (ControlIntegrity). Wait for \
retention to prune the owning executions, or pass --force to skip this scan \
if you have independently confirmed pruning is complete."
);
}
}
}
if dry_run {
println!(
"DRY RUN -- no changes written.\n\
Would remove {PREVIOUS_KEY_VAULT_NAME} from {} and clear previous_key_id \
(currently {previous_key_id}) from {}.",
vault_path.display(),
config_file.display(),
);
return Ok(());
}
write_durable_config_fields(config_file, config.durable.key_id, None)?;
provider.remove_secret_mut(PREVIOUS_KEY_VAULT_NAME);
provider
.save()
.map_err(|e| anyhow::anyhow!("failed to save vault: {e}"))?;
println!(
"Rotation window closed: removed {PREVIOUS_KEY_VAULT_NAME} and cleared previous_key_id \
(was {previous_key_id}). Payloads still sealed under key_id {previous_key_id} are now \
permanently unreadable.\n\
Config updated: {}\n\
Vault updated: {}",
config_file.display(),
vault_path.display(),
);
Ok(())
}
fn write_durable_config_fields(
config_file: &Path,
key_id: u8,
previous_key_id: Option<u8>,
) -> anyhow::Result<()> {
let raw = if config_file.exists() {
std::fs::read_to_string(config_file)
.with_context(|| format!("failed to read {}", config_file.display()))?
} else {
String::new()
};
let mut doc = raw
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("failed to parse {}", config_file.display()))?;
let durable_item = doc
.entry("durable")
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()));
let durable_table = durable_item
.as_table_mut()
.ok_or_else(|| anyhow::anyhow!("[durable] is not a table in {}", config_file.display()))?;
durable_table["key_id"] = toml_edit::value(i64::from(key_id));
match previous_key_id {
Some(id) => {
durable_table["previous_key_id"] = toml_edit::value(i64::from(id));
}
None => {
durable_table.remove("previous_key_id");
}
}
crate::commands::migrate::atomic_write(config_file, &doc.to_string())
}
fn describe_reveal_error(e: &zeph_durable::DurableError) -> String {
if matches!(
e,
zeph_durable::DurableError::Decode {
context: "unknown cipher key-id"
}
) {
format!(
"{e} -- this blob's key-id predates a key rotation the current cipher does not \
recognize. Restart the process to pick up the rotated ZEPH_DURABLE_KEY / \
ZEPH_DURABLE_KEY_PREVIOUS pair, or check `zeph durable rotate-key`'s rotation \
window is still open (`--drop-previous` closes it permanently)."
)
} else {
e.to_string()
}
}
async fn show_entries(
backend: &LocalBackend,
exec: ExecutionId,
reveal: bool,
step: Option<u32>,
json: bool,
) -> anyhow::Result<()> {
if reveal {
println!("{REVEAL_WARNING}\n");
let mut entries = backend.read_execution(exec).await.map_err(|e| {
anyhow::anyhow!("failed to read execution: {}", describe_reveal_error(&e))
})?;
if let Some(s) = step {
entries.retain(|e| e.step_id.value() == s);
}
if entries.is_empty() {
println!("No matching journal entry.");
} else {
print_revealed(&entries);
}
} else {
let mut entries = backend
.read_execution_redacted(exec)
.await
.map_err(|e| anyhow::anyhow!("failed to read execution: {e}"))?;
if let Some(s) = step {
entries.retain(|e| e.step_id.value() == s);
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&entries)
.context("failed to serialize journal entries")?
);
} else if entries.is_empty() {
println!("No matching journal entry.");
} else {
print_redacted(&entries);
}
}
Ok(())
}
fn print_redacted(entries: &[zeph_durable::RedactedEntry]) {
if entries.is_empty() {
println!("No journal entries.");
return;
}
println!(
"{:>6} {:>6} {:<14} {:<20} {:>10} {:<18} CREATED",
"SEQ", "STEP", "ENTRY KIND", "EFFECT CLASS", "BYTES", "IDEM KEY"
);
println!("{}", "-".repeat(96));
for e in entries {
println!(
"{:>6} {:>6} {:<14} {:<20} {:>10} {:<18} {}",
e.seq,
e.step_id.value(),
e.entry_kind,
e.effect_class.as_deref().unwrap_or("-"),
e.payload_len,
e.idem_key_prefix.as_deref().unwrap_or("-"),
fmt_ts(e.created_at_ms),
);
}
}
fn print_revealed(entries: &[zeph_durable::JournalEntry]) {
use zeph_durable::EntryKind;
if entries.is_empty() {
println!("No journal entries.");
return;
}
for e in entries {
let step = e.step_id.value();
match &e.entry {
EntryKind::StepResult {
payload, effect, ..
} => {
println!(
"step {step} [step_result, {effect:?}] {} bytes:",
payload.len()
);
println!(" {}", String::from_utf8_lossy(payload));
}
other => {
println!("step {step} [{}] (no payload)", other.tag());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn durable_db_is_filename_namespaced_sibling_of_sqlite_path() {
let mut config = Config::default();
config.memory.sqlite_path = "/data/zeph/zeph.db".to_owned();
let expected = Path::new("/data/zeph").join("zeph.db.durable.db");
assert_eq!(
resolve_durable_db_url(&config),
expected.to_string_lossy().into_owned()
);
}
#[test]
fn distinct_sqlite_stems_resolve_to_distinct_durable_urls() {
let dir = tempfile::tempdir().unwrap();
let mut config_a = Config::default();
config_a.memory.sqlite_path = dir.path().join("alpha.db").to_string_lossy().into_owned();
let mut config_b = Config::default();
config_b.memory.sqlite_path = dir.path().join("beta.db").to_string_lossy().into_owned();
assert_ne!(
resolve_durable_db_url(&config_a),
resolve_durable_db_url(&config_b)
);
}
#[test]
fn same_stem_different_extension_resolves_to_distinct_durable_urls() {
let dir = tempfile::tempdir().unwrap();
let mut config_a = Config::default();
config_a.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
let mut config_b = Config::default();
config_b.memory.sqlite_path = dir
.path()
.join("zeph.sqlite")
.to_string_lossy()
.into_owned();
assert_ne!(
resolve_durable_db_url(&config_a),
resolve_durable_db_url(&config_b)
);
}
#[test]
fn preexisting_legacy_durable_db_is_preferred() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("durable.db"), []).unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
assert_eq!(
resolve_durable_db_url(&config),
dir.path().join("durable.db").to_string_lossy()
);
}
#[tokio::test]
async fn distinct_databases_do_not_collide_on_shared_directory() {
let dir = tempfile::tempdir().unwrap();
let sqlite_a = dir.path().join("alpha.db").to_string_lossy().into_owned();
let sqlite_b = dir.path().join("beta.db").to_string_lossy().into_owned();
let mut config_a = Config::default();
config_a.memory.sqlite_path = sqlite_a.clone();
let mut config_b = Config::default();
config_b.memory.sqlite_path = sqlite_b.clone();
let url_a = resolve_durable_db_url(&config_a);
let url_b = resolve_durable_db_url(&config_b);
assert_ne!(
url_a, url_b,
"the two databases must resolve to distinct journal files"
);
let backend_a = LocalBackend::open(&url_a, 1_000_000).await.unwrap();
backend_a.init().await.unwrap();
let backend_b = LocalBackend::open(&url_b, 1_000_000).await.unwrap();
backend_b.init().await.unwrap();
assert!(
Path::new(&url_a).exists() && Path::new(&url_b).exists(),
"both journal files must actually exist on disk"
);
assert_ne!(
std::fs::canonicalize(&url_a).unwrap(),
std::fs::canonicalize(&url_b).unwrap(),
"the two journal files must be genuinely distinct files, not the same file via aliasing"
);
let conv_one = 1u64.to_le_bytes();
let fold = |sqlite_path: &str| {
let mut payload = conv_one.to_vec();
payload.extend_from_slice(sqlite_path.as_bytes());
ExecutionId::derive(b"zeph.agent_turn.v1", &payload)
};
let exec_a = fold(&sqlite_a);
let exec_b = fold(&sqlite_b);
assert_ne!(exec_a, exec_b);
let resumed_a = backend_a
.open_execution(exec_a, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap();
let resumed_b = backend_b
.open_execution(exec_b, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap();
assert!(
!resumed_a && !resumed_b,
"each database's first conversation must open a fresh execution, not resume the other's"
);
let executions_a = backend_a.list_executions(None, None, 10).await.unwrap();
let executions_b = backend_b.list_executions(None, None, 10).await.unwrap();
assert_eq!(executions_a.len(), 1);
assert_eq!(executions_b.len(), 1);
assert_eq!(executions_a[0].execution_id, exec_a);
assert_eq!(executions_b[0].execution_id, exec_b);
}
#[test]
fn durable_db_bare_filename_when_path_has_no_parent() {
let mut config = Config::default();
config.memory.sqlite_path = "zeph.db".to_owned();
assert_eq!(resolve_durable_db_url(&config), "durable.db");
}
#[test]
fn fmt_ts_formats_epoch_millis_as_utc() {
assert_eq!(fmt_ts(0), "1970-01-01 00:00:00");
}
#[tokio::test]
async fn open_backend_reveal_succeeds_without_key_when_encrypt_payload_disabled() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = false;
let url = resolve_durable_db_url(&config);
std::fs::write(&url, []).unwrap();
let vault_dir = zeph_core::vault::default_vault_dir();
let backend = open_backend(
&config,
true,
&vault_dir.join("vault-key.txt"),
&vault_dir.join("secrets.age"),
)
.await
.expect("--reveal must succeed without ZEPH_DURABLE_KEY when encrypt_payload=false");
assert!(backend.is_some());
}
#[tokio::test]
async fn open_backend_rejects_disabled_encryption_when_shared_db_declared() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = false;
config.durable.shared_db = true;
let url = resolve_durable_db_url(&config);
std::fs::write(&url, []).unwrap();
let vault_dir = zeph_core::vault::default_vault_dir();
let result = open_backend(
&config,
false,
&vault_dir.join("vault-key.txt"),
&vault_dir.join("secrets.age"),
)
.await;
assert!(
result.is_err(),
"open_backend must fail closed for encrypt_payload=false on a declared shared_db (INV-8)"
);
let reveal_result = open_backend(
&config,
true,
&vault_dir.join("vault-key.txt"),
&vault_dir.join("secrets.age"),
)
.await;
assert!(
reveal_result.is_err(),
"open_backend --reveal must fail closed for the same forbidden combination"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn open_backend_reveal_requires_key_when_encrypt_payload_enabled() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = true;
let url = resolve_durable_db_url(&config);
std::fs::write(&url, []).unwrap();
let vault_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", vault_dir.path());
}
let result = open_backend(
&config,
true,
&vault_dir.path().join("vault-key.txt"),
&vault_dir.path().join("secrets.age"),
)
.await;
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_err(),
"expected --reveal to fail without ZEPH_DURABLE_KEY when encrypt_payload=true"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn write_path_attaches_cipher_and_seals_payload_when_encrypt_payload_enabled() {
use zeph_durable::{
EffectClass, EntryKind, ExecutionKind, IdempotencyKey, JournalEntry, StepId,
};
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.encrypt_payload = true;
let vault_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", vault_dir.path());
}
let vault_root = zeph_core::vault::default_vault_dir();
zeph_core::vault::AgeVaultProvider::init_vault(&vault_root).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
provider
.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
zeph_core::durable::generate_durable_key_b64(),
false,
)
.unwrap();
provider.save().unwrap();
let cipher = load_write_cipher(
&config,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.expect("cipher load must succeed with a real vault key")
.expect("encrypt_payload=true must produce a cipher");
let url = resolve_durable_db_url(&config);
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.unwrap()
.with_cipher(cipher);
backend.init().await.unwrap();
let exec = ExecutionId::new();
backend
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let step_id = StepId::new(0);
let plaintext: &[u8] = br#"{"secret":"token-value"}"#;
backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::StepResult {
idempotency_key: IdempotencyKey::derive(exec, step_id, b"tool:test"),
payload: bytes::Bytes::copy_from_slice(plaintext),
effect: EffectClass::Idempotent,
payload_version: 1,
},
created_at_ms: 0,
})
.await
.unwrap();
let (stored,): (Option<Vec<u8>>,) = zeph_db::query_as(zeph_db::sql!(
"SELECT payload FROM durable_journal WHERE execution_id = ?"
))
.bind(exec.as_uuid().to_string())
.fetch_one(backend.pool())
.await
.unwrap();
let stored = stored.expect("payload present");
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert_ne!(
stored.as_slice(),
plaintext,
"payload must not be stored verbatim when encrypt_payload=true"
);
assert!(
serde_json::from_slice::<serde_json::Value>(&stored).is_err(),
"sealed payload must not parse as plaintext JSON"
);
let entries = backend.read_execution(exec).await.unwrap();
match &entries[0].entry {
EntryKind::StepResult { payload, .. } => assert_eq!(payload.as_ref(), plaintext),
other => panic!("unexpected entry kind: {other:?}"),
}
}
#[tokio::test]
async fn load_write_cipher_rejects_disabled_encryption_when_shared_db_declared() {
let mut config = Config::default();
config.durable.encrypt_payload = false;
config.durable.shared_db = true;
let vault_dir = zeph_core::vault::default_vault_dir();
assert!(
load_write_cipher(
&config,
&vault_dir.join("vault-key.txt"),
&vault_dir.join("secrets.age"),
)
.is_err(),
"encrypt_payload=false on a declared shared_db must fail closed (INV-8)"
);
}
#[tokio::test]
async fn load_write_cipher_warns_but_succeeds_for_undeclared_local_override() {
let mut config = Config::default();
config.durable.encrypt_payload = false;
let vault_dir = zeph_core::vault::default_vault_dir();
assert!(
load_write_cipher(
&config,
&vault_dir.join("vault-key.txt"),
&vault_dir.join("secrets.age"),
)
.unwrap()
.is_none()
);
}
#[test]
fn is_shared_db_detects_postgres_url_scheme_even_when_flag_unset() {
let config = zeph_core::config::DurableConfig::default();
assert!(!config.shared_db);
assert!(is_shared_db(&config, "postgres://user@host/db"));
assert!(is_shared_db(&config, "postgresql://user@host/db"));
assert!(!is_shared_db(&config, "/local/path/durable.db"));
}
#[tokio::test]
async fn load_write_cipher_rejects_disabled_encryption_for_postgres_url_scheme() {
let mut config = Config::default();
config.durable.encrypt_payload = false;
config.memory.sqlite_path = "postgres://user@host/db".to_owned();
let vault_dir = zeph_core::vault::default_vault_dir();
assert!(
load_write_cipher(
&config,
&vault_dir.join("vault-key.txt"),
&vault_dir.join("secrets.age"),
)
.is_err(),
"a postgres:// resolved URL must be treated as shared even without shared_db=true"
);
}
#[tokio::test]
async fn load_write_hmac_key_returns_none_for_single_user_local() {
let config = Config::default();
assert!(!config.durable.shared_db);
let vault_dir = zeph_core::vault::default_vault_dir();
let keys = load_write_hmac_key(
&config,
&vault_dir.join("vault-key.txt"),
&vault_dir.join("secrets.age"),
)
.unwrap();
assert!(
keys.current.is_none() && keys.previous.is_none(),
"single-user local, non-shared database must not resolve either HMAC key"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn load_write_hmac_key_fails_closed_when_shared_db_declared_and_key_missing() {
let mut config = Config::default();
config.durable.shared_db = true;
let vault_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", vault_dir.path());
}
let result = load_write_hmac_key(
&config,
&vault_dir.path().join("vault-key.txt"),
&vault_dir.path().join("secrets.age"),
);
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_err(),
"a declared shared database must fail closed without ZEPH_DURABLE_KEY (INV-8)"
);
}
fn write_config_toml(dir: &std::path::Path) -> std::path::PathBuf {
let mut config = Config::default();
config.memory.sqlite_path = dir.join("zeph.db").to_string_lossy().into_owned();
let toml = toml::to_string_pretty(&config).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path, toml).unwrap();
std::fs::write(resolve_durable_db_url(&config), []).unwrap();
path
}
#[tokio::test]
async fn handle_durable_command_cancel_marks_a_running_execution_canceled() {
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
let config = load_config_or_default(&config_path).unwrap();
let exec = ExecutionId::new();
{
let vault_dir = zeph_core::vault::default_vault_dir();
let backend = open_backend(
&config,
false,
&vault_dir.join("vault-key.txt"),
&vault_dir.join("secrets.age"),
)
.await
.unwrap()
.unwrap();
backend
.open_execution(exec, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap();
}
let result = handle_durable_command(
DurableCommand::Cancel {
id: exec.as_uuid().to_string(),
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
result.is_ok(),
"canceling a running execution must succeed: {result:?}"
);
let vault_dir = zeph_core::vault::default_vault_dir();
let backend = open_backend(
&config,
false,
&vault_dir.join("vault-key.txt"),
&vault_dir.join("secrets.age"),
)
.await
.unwrap()
.unwrap();
let status = backend.execution_status(exec).await.unwrap();
assert_eq!(status, Some(zeph_durable::ExecutionStatus::Canceled));
}
#[tokio::test]
async fn handle_durable_command_cancel_on_unknown_id_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
let result = handle_durable_command(
DurableCommand::Cancel {
id: ExecutionId::new().as_uuid().to_string(),
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
result.is_err(),
"canceling an unknown execution id must exit non-zero"
);
}
#[tokio::test]
async fn handle_durable_command_resume_on_canceled_execution_refuses_distinctly() {
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
let config = load_config_or_default(&config_path).unwrap();
let exec = ExecutionId::new();
{
let vault_dir = zeph_core::vault::default_vault_dir();
let backend = open_backend(
&config,
false,
&vault_dir.join("vault-key.txt"),
&vault_dir.join("secrets.age"),
)
.await
.unwrap()
.unwrap();
backend
.open_execution(exec, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap();
let outcome = backend.cancel_execution(exec).await.unwrap();
assert_eq!(outcome, CancelOutcome::Canceled);
}
let result = handle_durable_command(
DurableCommand::Resume {
id: exec.as_uuid().to_string(),
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
result.is_ok(),
"resume on a canceled execution must report a message and exit 0, not error: {result:?}"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn load_write_hmac_key_resolves_real_key_when_shared_db_declared() {
let mut config = Config::default();
config.durable.shared_db = true;
let vault_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", vault_dir.path());
}
let vault_root = zeph_core::vault::default_vault_dir();
zeph_core::vault::AgeVaultProvider::init_vault(&vault_root).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
provider
.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
zeph_core::durable::generate_durable_key_b64(),
false,
)
.unwrap();
provider.save().unwrap();
let result = load_write_hmac_key(
&config,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
);
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.unwrap().current.is_some(),
"a declared shared database with a real vault key must resolve an HMAC key"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn load_write_cipher_respects_explicit_override_path_not_default_vault_dir() {
let empty_default_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", empty_default_dir.path());
}
let override_dir = tempfile::tempdir().unwrap();
zeph_core::vault::AgeVaultProvider::init_vault(override_dir.path()).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&override_dir.path().join("vault-key.txt"),
&override_dir.path().join("secrets.age"),
)
.unwrap();
provider
.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
zeph_core::durable::generate_durable_key_b64(),
false,
)
.unwrap();
provider.save().unwrap();
let mut config = Config::default();
config.durable.encrypt_payload = true;
let result = load_write_cipher(
&config,
&override_dir.path().join("vault-key.txt"),
&override_dir.path().join("secrets.age"),
);
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_ok() && result.unwrap().is_some(),
"load_write_cipher must resolve ZEPH_DURABLE_KEY from the explicit override path, \
not default_vault_dir() (#6548)"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn load_write_hmac_key_respects_explicit_override_path_not_default_vault_dir() {
let empty_default_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", empty_default_dir.path());
}
let override_dir = tempfile::tempdir().unwrap();
zeph_core::vault::AgeVaultProvider::init_vault(override_dir.path()).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&override_dir.path().join("vault-key.txt"),
&override_dir.path().join("secrets.age"),
)
.unwrap();
provider
.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
zeph_core::durable::generate_durable_key_b64(),
false,
)
.unwrap();
provider.save().unwrap();
let mut config = Config::default();
config.durable.shared_db = true;
let result = load_write_hmac_key(
&config,
&override_dir.path().join("vault-key.txt"),
&override_dir.path().join("secrets.age"),
);
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_ok_and(|k| k.current.is_some()),
"load_write_hmac_key must resolve ZEPH_DURABLE_KEY from the explicit override path, \
not default_vault_dir() (#6548)"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn load_write_hwm_key_respects_explicit_override_path_not_default_vault_dir() {
let empty_default_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", empty_default_dir.path());
}
let override_dir = tempfile::tempdir().unwrap();
zeph_core::vault::AgeVaultProvider::init_vault(override_dir.path()).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&override_dir.path().join("vault-key.txt"),
&override_dir.path().join("secrets.age"),
)
.unwrap();
provider
.set_secret_mut(
"ZEPH_DURABLE_KEY".to_owned(),
zeph_core::durable::generate_durable_key_b64(),
false,
)
.unwrap();
provider.save().unwrap();
let config = Config::default();
let result = load_write_hwm_key(
&config,
&override_dir.path().join("vault-key.txt"),
&override_dir.path().join("secrets.age"),
);
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_ok_and(|k| k.current.is_some()),
"load_write_hwm_key must resolve ZEPH_DURABLE_KEY from the explicit override path, \
not default_vault_dir() (#6548)"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn load_integrity_seal_respects_explicit_override_path_not_default_vault_dir() {
let empty_default_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", empty_default_dir.path());
}
let override_dir = tempfile::tempdir().unwrap();
zeph_core::vault::AgeVaultProvider::init_vault(override_dir.path()).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&override_dir.path().join("vault-key.txt"),
&override_dir.path().join("secrets.age"),
)
.unwrap();
provider
.set_secret_mut(
zeph_core::anchor_store::DURABLE_INTEGRITY_SEALED_KEY.to_owned(),
"1".to_owned(),
true,
)
.unwrap();
provider.save().unwrap();
let config = Config::default();
let (sealed, _grandfather) = load_integrity_seal(
&config,
&override_dir.path().join("vault-key.txt"),
&override_dir.path().join("secrets.age"),
);
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
sealed,
"load_integrity_seal must resolve the seal marker from the explicit override path, \
not default_vault_dir() (#6548)"
);
}
#[allow(unsafe_code)]
#[tokio::test]
#[serial]
async fn rotate_key_respects_explicit_override_path_not_default_vault_dir() {
let empty_default_dir = tempfile::tempdir().unwrap();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", empty_default_dir.path());
}
let override_dir = tempfile::tempdir().unwrap();
zeph_core::vault::AgeVaultProvider::init_vault(override_dir.path()).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&override_dir.path().join("vault-key.txt"),
&override_dir.path().join("secrets.age"),
)
.unwrap();
provider
.set_secret_mut(
CURRENT_KEY_VAULT_NAME.to_owned(),
zeph_core::durable::generate_durable_key_b64(),
false,
)
.unwrap();
provider.save().unwrap();
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
let result = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
Some(&override_dir.path().join("vault-key.txt")),
Some(&override_dir.path().join("secrets.age")),
)
.await;
unsafe {
match &prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
assert!(
result.is_ok(),
"rotate-key must resolve the vault from the explicit --vault-key/--vault-path \
override, not default_vault_dir() (which is empty in this test): {result:?}"
);
assert_eq!(
load_config_or_default(&config_path).unwrap().durable.key_id,
1
);
let override_provider = zeph_core::vault::AgeVaultProvider::load(
&override_dir.path().join("vault-key.txt"),
&override_dir.path().join("secrets.age"),
)
.unwrap();
assert!(
override_provider.get(PREVIOUS_KEY_VAULT_NAME).is_some(),
"rotation must write ZEPH_DURABLE_KEY_PREVIOUS into the override vault"
);
}
#[allow(unsafe_code)]
struct VaultDirGuard {
_dir: tempfile::TempDir,
prev_xdg: Option<String>,
}
#[allow(unsafe_code)]
impl VaultDirGuard {
fn new() -> Self {
let dir = tempfile::tempdir().expect("tempdir");
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path());
}
Self {
_dir: dir,
prev_xdg,
}
}
}
#[allow(unsafe_code)]
impl Drop for VaultDirGuard {
fn drop(&mut self) {
unsafe {
match &self.prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}
}
fn seed_vault_with_durable_key() -> String {
let vault_root = zeph_core::vault::default_vault_dir();
zeph_core::vault::AgeVaultProvider::init_vault(&vault_root).unwrap();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
let key = zeph_core::durable::generate_durable_key_b64();
provider
.set_secret_mut(CURRENT_KEY_VAULT_NAME.to_owned(), key.clone(), false)
.unwrap();
provider.save().unwrap();
key
}
#[tokio::test]
#[serial]
async fn rotate_key_opens_a_window_and_updates_config_and_vault() {
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
let result = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(result.is_ok(), "rotate-key must succeed: {result:?}");
let config = load_config_or_default(&config_path).unwrap();
assert_eq!(config.durable.key_id, 1);
assert_eq!(config.durable.previous_key_id, Some(0));
let vault_root = zeph_core::vault::default_vault_dir();
let provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
assert!(
provider.get(PREVIOUS_KEY_VAULT_NAME).is_some(),
"the old key must be stashed under ZEPH_DURABLE_KEY_PREVIOUS"
);
}
#[tokio::test]
#[serial]
async fn rotate_key_refuses_a_second_window() {
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await
.unwrap();
let after_first = load_config_or_default(&config_path).unwrap();
assert_eq!(after_first.durable.previous_key_id, Some(0));
let result = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
result.is_err(),
"a second rotation while a window is open must be refused"
);
let after_second = load_config_or_default(&config_path).unwrap();
assert_eq!(
after_second.durable, after_first.durable,
"the refused second rotation must not mutate config"
);
}
#[tokio::test]
#[serial]
async fn rotate_key_dry_run_writes_nothing() {
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
let before = std::fs::read_to_string(&config_path).unwrap();
let result = handle_durable_command(
DurableCommand::RotateKey {
dry_run: true,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(result.is_ok());
let after = std::fs::read_to_string(&config_path).unwrap();
assert_eq!(before, after, "--dry-run must not modify the config file");
let vault_root = zeph_core::vault::default_vault_dir();
let provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
assert!(provider.get(PREVIOUS_KEY_VAULT_NAME).is_none());
}
#[tokio::test]
#[serial]
async fn rotate_key_drop_previous_is_noop_when_no_window_open() {
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
let before = std::fs::read_to_string(&config_path).unwrap();
let result = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: true,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
result.is_ok(),
"no-window drop-previous must succeed: {result:?}"
);
let after = std::fs::read_to_string(&config_path).unwrap();
assert_eq!(
before, after,
"a no-op drop-previous must not modify the config file"
);
}
#[tokio::test]
#[serial]
async fn rotate_key_drop_previous_refuses_with_surviving_blob_unless_forced() {
use zeph_durable::{
EffectClass, EntryKind, ExecutionKind, IdempotencyKey, JournalEntry, LocalBackend,
StepId,
};
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await
.unwrap();
let config = load_config_or_default(&config_path).unwrap();
let url = resolve_durable_db_url(&config);
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.unwrap();
backend.init().await.unwrap();
let exec = ExecutionId::new();
backend
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let step_id = StepId::new(0);
backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::StepResult {
idempotency_key: IdempotencyKey::derive(exec, step_id, b"tool:test"),
payload: bytes::Bytes::copy_from_slice(&[0u8, 1, 2, 3]),
effect: EffectClass::Idempotent,
payload_version: 1,
},
created_at_ms: 0,
})
.await
.unwrap();
let refused = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: true,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
refused.is_err(),
"drop-previous must refuse while a matching sealed payload remains"
);
assert_eq!(
load_config_or_default(&config_path)
.unwrap()
.durable
.previous_key_id,
Some(0),
"the refused drop must not clear previous_key_id"
);
let forced = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: true,
force: true,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(forced.is_ok(), "--force must skip the scan: {forced:?}");
assert_eq!(
load_config_or_default(&config_path)
.unwrap()
.durable
.previous_key_id,
None,
"--force must still clear previous_key_id once it proceeds"
);
}
#[tokio::test]
#[serial]
async fn rotate_key_refuses_on_partial_state() {
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
write_durable_config_fields(&config_path, 1, Some(0)).unwrap();
let result = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
result.is_err(),
"an inconsistent (config declares window, vault has no previous secret) state must \
refuse rather than guess"
);
}
#[tokio::test]
#[serial]
async fn rotate_key_succeeds_on_shared_db_without_any_acknowledgement() {
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.shared_db = true;
let toml = toml::to_string_pretty(&config).unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, toml).unwrap();
std::fs::write(resolve_durable_db_url(&config), []).unwrap();
let result = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
result.is_ok(),
"a shared database must rotate without any acknowledgement flag now that the \
control-entry HMAC key has its own rotation window: {result:?}"
);
assert_eq!(
load_config_or_default(&config_path).unwrap().durable.key_id,
1
);
assert_eq!(
load_config_or_default(&config_path)
.unwrap()
.durable
.previous_key_id,
Some(0)
);
}
#[tokio::test]
#[serial]
async fn load_write_cipher_fails_closed_when_previous_key_id_set_but_vault_secret_missing() {
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let mut config = Config::default();
config.durable.previous_key_id = Some(0);
let vault_root = zeph_core::vault::default_vault_dir();
let result = load_write_cipher(
&config,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
);
assert!(
result.is_err(),
"previous_key_id set but ZEPH_DURABLE_KEY_PREVIOUS missing must hard-error, not \
silently build a cipher with no previous slot"
);
}
#[tokio::test]
#[serial]
async fn load_write_cipher_succeeds_when_previous_key_id_and_vault_secret_are_consistent() {
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let vault_root = zeph_core::vault::default_vault_dir();
let mut provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
provider
.set_secret_mut(
PREVIOUS_KEY_VAULT_NAME.to_owned(),
zeph_core::durable::generate_durable_key_b64(),
false,
)
.unwrap();
provider.save().unwrap();
let mut config = Config::default();
config.durable.key_id = 1;
config.durable.previous_key_id = Some(0);
let result = load_write_cipher(
&config,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
);
assert!(
result.is_ok(),
"a consistent (config ∧ vault) previous-key pair must build a cipher: {}",
result.err().map(|e| e.to_string()).unwrap_or_default()
);
}
#[tokio::test]
#[serial]
async fn load_write_cipher_skips_previous_slot_cleanly_when_previous_key_id_is_none() {
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let config = Config::default();
assert_eq!(config.durable.previous_key_id, None);
let vault_root = zeph_core::vault::default_vault_dir();
let result = load_write_cipher(
&config,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
);
assert!(
result.is_ok(),
"no declared rotation window must build a cipher with no previous slot: {}",
result.err().map(|e| e.to_string()).unwrap_or_default()
);
}
#[tokio::test]
#[serial]
async fn reveal_after_drop_previous_surfaces_actionable_rotation_message() {
use zeph_durable::{
EffectClass, EntryKind, ExecutionKind, IdempotencyKey, JournalEntry, StepId,
};
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
let toml = toml::to_string_pretty(&config).unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, toml).unwrap();
let vault_root = zeph_core::vault::default_vault_dir();
let cipher = load_write_cipher(
&config,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap()
.unwrap();
let url = resolve_durable_db_url(&config);
let exec = ExecutionId::new();
{
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.unwrap()
.with_cipher(cipher);
backend.init().await.unwrap();
backend
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let step_id = StepId::new(0);
backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::StepResult {
idempotency_key: IdempotencyKey::derive(exec, step_id, b"tool:test"),
payload: bytes::Bytes::copy_from_slice(b"secret result"),
effect: EffectClass::Idempotent,
payload_version: 1,
},
created_at_ms: 0,
})
.await
.unwrap();
}
handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await
.unwrap();
handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: true,
force: true,
},
Some(&config_path),
None,
None,
None,
)
.await
.unwrap();
let final_config = load_config_or_default(&config_path).unwrap();
assert_eq!(final_config.durable.previous_key_id, None);
let backend = open_backend(
&final_config,
true,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.await
.unwrap()
.unwrap();
let result = show_entries(&backend, exec, true, None, false).await;
let err = result.unwrap_err().to_string();
assert!(
err.contains("predates a key rotation"),
"expected the actionable rotation-restart message, got: {err}"
);
}
#[tokio::test]
#[serial]
async fn rotate_key_drop_previous_dry_run_still_refuses_with_surviving_blob() {
use zeph_durable::{
EffectClass, EntryKind, ExecutionKind, IdempotencyKey, JournalEntry, StepId,
};
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await
.unwrap();
let config = load_config_or_default(&config_path).unwrap();
let url = resolve_durable_db_url(&config);
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.unwrap();
backend.init().await.unwrap();
let exec = ExecutionId::new();
backend
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let step_id = StepId::new(0);
backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::StepResult {
idempotency_key: IdempotencyKey::derive(exec, step_id, b"tool:test"),
payload: bytes::Bytes::copy_from_slice(&[0u8, 9, 9, 9]),
effect: EffectClass::Idempotent,
payload_version: 1,
},
created_at_ms: 0,
})
.await
.unwrap();
let before_vault = {
let vault_root = zeph_core::vault::default_vault_dir();
let provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
provider.get(PREVIOUS_KEY_VAULT_NAME).map(str::to_owned)
};
let result = handle_durable_command(
DurableCommand::RotateKey {
dry_run: true,
drop_previous: true,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
result.is_err(),
"--drop-previous --dry-run must still refuse when a matching sealed payload remains"
);
assert_eq!(
load_config_or_default(&config_path)
.unwrap()
.durable
.previous_key_id,
Some(0),
"the refused dry-run drop must not clear previous_key_id"
);
let after_vault = {
let vault_root = zeph_core::vault::default_vault_dir();
let provider = zeph_core::vault::AgeVaultProvider::load(
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
provider.get(PREVIOUS_KEY_VAULT_NAME).map(str::to_owned)
};
assert_eq!(
before_vault, after_vault,
"the refused dry-run drop must not mutate the vault"
);
}
#[tokio::test]
#[serial]
async fn rotate_key_round_trip_old_blob_still_decrypts_and_new_writes_use_new_key_id() {
use zeph_durable::{
EffectClass, EntryKind, ExecutionKind, IdempotencyKey, JournalEntry, StepId,
};
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
let toml = toml::to_string_pretty(&config).unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, toml).unwrap();
let vault_root = zeph_core::vault::default_vault_dir();
let old_cipher = load_write_cipher(
&config,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap()
.unwrap();
let url = resolve_durable_db_url(&config);
let exec = ExecutionId::new();
{
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.unwrap()
.with_cipher(old_cipher);
backend.init().await.unwrap();
backend
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let step_id = StepId::new(0);
backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::StepResult {
idempotency_key: IdempotencyKey::derive(exec, step_id, b"tool:old"),
payload: bytes::Bytes::copy_from_slice(b"pre-rotation result"),
effect: EffectClass::Idempotent,
payload_version: 1,
},
created_at_ms: 0,
})
.await
.unwrap();
}
handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await
.unwrap();
let rotated_config = load_config_or_default(&config_path).unwrap();
assert_eq!(rotated_config.durable.key_id, 1);
assert_eq!(rotated_config.durable.previous_key_id, Some(0));
let new_cipher = load_write_cipher(
&rotated_config,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap()
.unwrap();
let backend = LocalBackend::open(&url, rotated_config.durable.max_payload_bytes)
.await
.unwrap()
.with_cipher(new_cipher);
let new_step_id = StepId::new(1);
backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id: new_step_id,
entry: EntryKind::StepResult {
idempotency_key: IdempotencyKey::derive(exec, new_step_id, b"tool:new"),
payload: bytes::Bytes::copy_from_slice(b"post-rotation result"),
effect: EffectClass::Idempotent,
payload_version: 1,
},
created_at_ms: 1,
})
.await
.unwrap();
let (new_payload,): (Option<Vec<u8>>,) = zeph_db::query_as(zeph_db::sql!(
"SELECT payload FROM durable_journal WHERE execution_id = ? AND step_id = ?"
))
.bind(exec.as_uuid().to_string())
.bind(1i64)
.fetch_one(backend.pool())
.await
.unwrap();
assert_eq!(
new_payload.unwrap()[0],
1,
"seals written after rotation must carry the rotated key-id"
);
let entries = backend.read_execution(exec).await.unwrap();
let old_entry = entries
.iter()
.find(|e| e.step_id.value() == 0)
.expect("pre-rotation entry must still be present");
match &old_entry.entry {
EntryKind::StepResult { payload, .. } => {
assert_eq!(payload.as_ref(), b"pre-rotation result");
}
other => panic!("unexpected entry kind: {other:?}"),
}
}
#[tokio::test]
#[serial]
async fn cli_read_channel_verifies_previous_key_control_entry_through_rotation_window() {
use zeph_durable::{EffectClass, EntryKind, ExecutionKind, IdempotencyKey, JournalEntry};
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.shared_db = true;
let toml = toml::to_string_pretty(&config).unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, toml).unwrap();
handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await
.unwrap();
let rotated = load_config_or_default(&config_path).unwrap();
let url = resolve_durable_db_url(&rotated);
let vault_root = zeph_core::vault::default_vault_dir();
let previous_hmac = load_control_hmac_key(
&rotated.durable,
&url,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap()
.previous
.expect("rotation window must be open after rotate-key");
let exec = ExecutionId::new();
{
let backend = LocalBackend::open(&url, rotated.durable.max_payload_bytes)
.await
.unwrap()
.with_hmac_key(previous_hmac);
backend.init().await.unwrap();
backend
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let step_id = zeph_durable::StepId::new(0);
backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::EffectIntent {
idempotency_key: IdempotencyKey::derive(exec, step_id, b"transfer"),
effect: EffectClass::ExactlyOnceGuarded,
hmac: None,
},
created_at_ms: 0,
})
.await
.unwrap();
}
let result = handle_durable_command(
DurableCommand::Show {
id: exec.as_uuid().to_string(),
reveal: true,
json: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
result.is_ok(),
"the CLI read channel must verify a pre-rotation EffectIntent control entry through \
the open rotation window (try-both current-then-previous): {result:?}"
);
}
#[tokio::test]
#[serial]
async fn rotate_key_drop_previous_refuses_a_payload_less_previous_key_only_effect_intent() {
use zeph_durable::{EffectClass, EntryKind, ExecutionKind, IdempotencyKey, JournalEntry};
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.memory.sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
config.durable.shared_db = true;
let toml = toml::to_string_pretty(&config).unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, toml).unwrap();
handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await
.unwrap();
let rotated = load_config_or_default(&config_path).unwrap();
let url = resolve_durable_db_url(&rotated);
let vault_root = zeph_core::vault::default_vault_dir();
let previous_hmac = load_control_hmac_key(
&rotated.durable,
&url,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap()
.previous
.expect("rotation window must be open after rotate-key");
let exec = ExecutionId::new();
{
let backend = LocalBackend::open(&url, rotated.durable.max_payload_bytes)
.await
.unwrap()
.with_hmac_key(previous_hmac);
backend.init().await.unwrap();
backend
.open_execution(exec, ExecutionKind::AgentTurn)
.await
.unwrap();
let step_id = zeph_durable::StepId::new(0);
backend
.append(JournalEntry {
seq: None,
execution_id: exec,
kind: ExecutionKind::AgentTurn,
step_id,
entry: EntryKind::EffectIntent {
idempotency_key: IdempotencyKey::derive(exec, step_id, b"crash-orphan"),
effect: EffectClass::ExactlyOnceGuarded,
hmac: None,
},
created_at_ms: 0,
})
.await
.unwrap();
}
let refused = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: true,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
refused.is_err(),
"drop-previous must refuse while a payload-less EffectIntent verifies only under \
the previous control-entry HMAC key"
);
assert_eq!(
load_config_or_default(&config_path)
.unwrap()
.durable
.previous_key_id,
Some(0),
"the refused drop must not clear previous_key_id"
);
let forced = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: true,
force: true,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
forced.is_ok(),
"--force must skip the HMAC scan: {forced:?}"
);
}
#[tokio::test]
#[serial]
async fn rotate_key_drop_previous_refuses_when_only_the_hwm_scan_catches_a_stale_epoch_row() {
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await
.unwrap();
let rotated = load_config_or_default(&config_path).unwrap();
assert_eq!(rotated.durable.previous_key_id, Some(0));
let url = resolve_durable_db_url(&rotated);
let exec = ExecutionId::new();
{
let backend = LocalBackend::open(&url, rotated.durable.max_payload_bytes)
.await
.unwrap();
backend.init().await.unwrap();
zeph_db::query(zeph_db::sql!(
"INSERT INTO durable_executions
(execution_id, kind, status, created_at, updated_at, finalized_at)
VALUES (?, 'agent_turn', 'running', 0, 0, NULL)"
))
.bind(exec.as_uuid().to_string())
.execute(backend.pool())
.await
.unwrap();
zeph_db::query(zeph_db::sql!(
"INSERT INTO durable_execution_integrity
(execution_id, key_epoch, max_committed_step_id, committed_result_count, hwm_hmac, updated_at)
VALUES (?, 0, 0, 1, ?, 0)"
))
.bind(exec.as_uuid().to_string())
.bind(vec![0u8; 32])
.execute(backend.pool())
.await
.unwrap();
}
let refused = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: true,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(
refused.is_err(),
"drop-previous must refuse while an integrity row still carries the previous epoch, \
even with no payload or control-entry evidence"
);
assert_eq!(
load_config_or_default(&config_path)
.unwrap()
.durable
.previous_key_id,
Some(0),
"the refused drop must not clear previous_key_id"
);
let forced = handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: true,
force: true,
},
Some(&config_path),
None,
None,
None,
)
.await;
assert!(forced.is_ok(), "--force must skip the HWM scan: {forced:?}");
}
#[tokio::test]
#[serial]
async fn load_write_hwm_key_epoch_matches_key_id_so_rotation_classifies_as_rekeyed_not_tamper()
{
let _guard = VaultDirGuard::new();
seed_vault_with_durable_key();
let dir = tempfile::tempdir().unwrap();
let config_path = write_config_toml(dir.path());
let exec = ExecutionId::new();
{
let config = load_config_or_default(&config_path).unwrap();
let vault_root = zeph_core::vault::default_vault_dir();
let hwm_keys = load_write_hwm_key(
&config,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
let slot = hwm_keys.current.expect("ZEPH_DURABLE_KEY is seeded");
assert_eq!(
slot.epoch, 0,
"un-rotated deployment (key_id=0) must stamp epoch 0 -- no migration"
);
let url = resolve_durable_db_url(&config);
let backend = LocalBackend::open(&url, config.durable.max_payload_bytes)
.await
.unwrap()
.with_hwm_key(slot.epoch, slot.key);
backend.init().await.unwrap();
backend
.open_execution(exec, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap();
backend
.append(zeph_durable::JournalEntry {
seq: None,
execution_id: exec,
kind: zeph_durable::ExecutionKind::AgentTurn,
step_id: zeph_durable::StepId::new(0),
entry: zeph_durable::EntryKind::StepResult {
idempotency_key: zeph_durable::IdempotencyKey::derive(
exec,
zeph_durable::StepId::new(0),
b"tool:test",
),
payload: bytes::Bytes::copy_from_slice(b"pre-rotation result"),
effect: zeph_durable::EffectClass::Idempotent,
payload_version: 1,
},
created_at_ms: 0,
})
.await
.unwrap();
}
handle_durable_command(
DurableCommand::RotateKey {
dry_run: false,
drop_previous: false,
force: false,
},
Some(&config_path),
None,
None,
None,
)
.await
.unwrap();
let rotated = load_config_or_default(&config_path).unwrap();
assert_eq!(rotated.durable.key_id, 1);
assert_eq!(rotated.durable.previous_key_id, Some(0));
let vault_root = zeph_core::vault::default_vault_dir();
let hwm_keys = load_write_hwm_key(
&rotated,
&vault_root.join("vault-key.txt"),
&vault_root.join("secrets.age"),
)
.unwrap();
let current = hwm_keys.current.expect("current HWM slot must resolve");
assert_eq!(current.epoch, 1, "epoch must follow key_id after rotation");
let previous = hwm_keys
.previous
.expect("previous HWM slot must resolve while the window is open");
assert_eq!(previous.epoch, 0);
let url = resolve_durable_db_url(&rotated);
let no_previous = LocalBackend::open(&url, rotated.durable.max_payload_bytes)
.await
.unwrap()
.with_hwm_key(current.epoch, current.key);
let err = no_previous
.open_execution(exec, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap_err();
match err {
zeph_durable::DurableError::HighWaterMarkIntegrity { reason, .. } => {
assert_eq!(
reason, "key_epoch_unresolvable",
"a benign rotation must classify as re-keyed, never as hmac_mismatch (TAMPER)"
);
}
other => panic!("expected HighWaterMarkIntegrity, got {other:?}"),
}
let with_previous = LocalBackend::open(&url, rotated.durable.max_payload_bytes)
.await
.unwrap()
.with_hwm_key(current.epoch, current.key)
.with_previous_hwm_key(previous.epoch, previous.key);
assert!(
with_previous
.open_execution(exec, zeph_durable::ExecutionKind::AgentTurn)
.await
.unwrap(),
"a pre-rotation execution must resume cleanly through the open rotation window"
);
}
}