use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use clawft_platform::Platform;
use crate::agent::soul_journal::{PendingJournalEntry, SoulJournalReader};
use crate::runtime::RwLock;
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use thiserror::Error;
use tracing::{debug, warn};
pub const BINDING_THREAD_EXCERPT: &str =
"an agent must not diminish human capability, or by inaction allow it to be diminished";
pub const BINDING_THREAD_GATE_ACTION: &str = "soul.binding_thread_intact";
pub const BINDING_THREAD_MISMATCH_REASON: &str = "binding-thread mismatch";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IdentitySource {
Clawft,
Cache,
Substrate,
Journal,
Stub,
}
impl IdentitySource {
pub const fn as_str(self) -> &'static str {
match self {
Self::Clawft => "clawft",
Self::Cache => "cache",
Self::Substrate => "substrate",
Self::Journal => "journal",
Self::Stub => "stub",
}
}
}
impl fmt::Display for IdentitySource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct Identity {
pub soul: String,
pub identity: String,
pub hash: String,
pub source: IdentitySource,
pub pending_journal: Vec<PendingJournalEntry>,
}
impl Identity {
pub fn from_files(
soul: impl Into<String>,
identity: impl Into<String>,
source: IdentitySource,
) -> Self {
let soul = soul.into();
let identity = identity.into();
let hash = sha256_identity_hash(&soul, &identity);
Self {
soul,
identity,
hash,
source,
pending_journal: Vec::new(),
}
}
}
#[derive(Debug, Error)]
pub enum IdentityError {
#[error(
"identity load failed: <workspace>/.clawft/{{SOUL.md,IDENTITY.md}} missing — run `weaver init`"
)]
NotFound,
#[error("identity load failed: binding-thread mismatch")]
BindingThreadMismatch,
#[error("identity load failed: IO error: {0}")]
Io(String),
#[error("identity load failed: deserialize error: {0}")]
Deserialize(String),
#[error("identity load failed: substrate error: {0}")]
Substrate(String),
}
pub fn soul_contains_binding_thread(soul: &str) -> bool {
soul.contains(BINDING_THREAD_EXCERPT)
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
pub trait IdentityProvider: Send + Sync + 'static {
async fn current(&self) -> Result<Identity, IdentityError>;
}
pub struct FileIdentityProvider<P: Platform> {
workspace: PathBuf,
platform: Arc<P>,
cached: RwLock<Option<Identity>>,
watch_mode: AtomicBool,
stale: AtomicBool,
}
impl<P: Platform> FileIdentityProvider<P> {
pub fn new(workspace: impl Into<PathBuf>, platform: Arc<P>) -> Self {
Self {
workspace: workspace.into(),
platform,
cached: RwLock::new(None),
watch_mode: AtomicBool::new(false),
stale: AtomicBool::new(true),
}
}
pub fn workspace(&self) -> &Path {
&self.workspace
}
pub fn invalidate(&self) {
self.stale.store(true, Ordering::Release);
debug!("identity provider: cache invalidated");
}
pub fn enable_watch_mode(&self) {
self.watch_mode.store(true, Ordering::Release);
self.stale.store(true, Ordering::Release);
debug!("identity provider: watch mode enabled");
}
pub fn is_watch_mode(&self) -> bool {
self.watch_mode.load(Ordering::Acquire)
}
pub fn is_stale(&self) -> bool {
self.stale.load(Ordering::Acquire)
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
impl<P: Platform + 'static> IdentityProvider for FileIdentityProvider<P> {
async fn current(&self) -> Result<Identity, IdentityError> {
let watch_mode = self.watch_mode.load(Ordering::Acquire);
let stale = self.stale.load(Ordering::Acquire);
if watch_mode && !stale
&& let Some(cached) = self.cached.read().await.clone() {
debug!(
hash = %cached.hash,
"identity provider: serving cache (watch mode)"
);
return Ok(cached);
}
let loader = IdentityLoader::new(self.workspace.clone(), Arc::clone(&self.platform));
match loader.current().await {
Some(id) => {
let mut cache = self.cached.write().await;
*cache = Some(id.clone());
self.stale.store(false, Ordering::Release);
Ok(id)
}
None => {
if let Some(mut cached) = self.cached.read().await.clone() {
warn!(
"identity provider: disk re-read failed; \
serving cached load (hash={})",
cached.hash
);
cached.source = IdentitySource::Cache;
return Ok(cached);
}
Err(IdentityError::NotFound)
}
}
}
}
pub struct JournalAwareIdentityProvider {
base: Arc<dyn IdentityProvider>,
journal: Arc<dyn SoulJournalReader>,
fail_closed: bool,
}
impl JournalAwareIdentityProvider {
pub fn new(base: Arc<dyn IdentityProvider>, journal: Arc<dyn SoulJournalReader>) -> Self {
Self {
base,
journal,
fail_closed: false,
}
}
pub fn fail_closed(mut self, fail_closed: bool) -> Self {
self.fail_closed = fail_closed;
self
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
impl IdentityProvider for JournalAwareIdentityProvider {
async fn current(&self) -> Result<Identity, IdentityError> {
let mut id = self.base.current().await?;
match self.journal.list_pending().await {
Ok(pending) => {
if !pending.is_empty() {
debug!(
count = pending.len(),
"identity: journal substrate read-on-every-turn \
attached pending observations"
);
id.pending_journal = pending;
id.source = IdentitySource::Journal;
}
Ok(id)
}
Err(e) => {
if self.fail_closed {
let lower = e.to_ascii_lowercase();
if lower.contains("deserial") || lower.contains("json") {
return Err(IdentityError::Deserialize(e));
}
if lower.contains("substrate")
|| lower.contains("grant")
|| lower.contains("mesh")
{
return Err(IdentityError::Substrate(e));
}
return Err(IdentityError::Io(e));
}
warn!(
error = %e,
"identity: journal read-on-every-turn failed; \
serving base identity without pending rows"
);
Ok(id)
}
}
}
}
pub struct IdentityLoader<P: Platform> {
workspace: PathBuf,
platform: Arc<P>,
}
impl<P: Platform> IdentityLoader<P> {
pub fn new(workspace: impl Into<PathBuf>, platform: Arc<P>) -> Self {
Self {
workspace: workspace.into(),
platform,
}
}
pub fn from_agents_config(
agents: &clawft_types::config::AgentsConfig,
platform: Arc<P>,
) -> std::io::Result<Self> {
Ok(Self::new(agents.resolve_workspace_root()?, platform))
}
pub fn workspace(&self) -> &Path {
&self.workspace
}
pub async fn current(&self) -> Option<Identity> {
self.try_load_from(&self.workspace.join(".clawft"), IdentitySource::Clawft)
.await
}
async fn try_load_from(&self, dir: &Path, source: IdentitySource) -> Option<Identity> {
let soul_path = dir.join("SOUL.md");
let identity_path = dir.join("IDENTITY.md");
let soul = self.platform.fs().read_to_string(&soul_path).await.ok()?;
let identity = self
.platform
.fs()
.read_to_string(&identity_path)
.await
.ok()?;
debug!(
?soul_path,
?identity_path,
source = source.as_str(),
"identity loaded"
);
Some(Identity::from_files(soul, identity, source))
}
}
pub fn sha256_identity_hash(soul: &str, identity: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(soul.as_bytes());
hasher.update(b"\n");
hasher.update(identity.as_bytes());
let digest = hasher.finalize();
hex_lower(&digest)
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use clawft_platform::fs::{FileSystem, FsMetadata};
use std::collections::HashMap;
use std::sync::Mutex as StdMutex;
struct MockFs {
files: StdMutex<HashMap<PathBuf, String>>,
}
impl MockFs {
fn new() -> Self {
Self {
files: StdMutex::new(HashMap::new()),
}
}
fn seed(&self, path: impl Into<PathBuf>, content: impl Into<String>) {
self.files
.lock()
.unwrap()
.insert(path.into(), content.into());
}
}
#[async_trait]
impl FileSystem for MockFs {
async fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
self.files.lock().unwrap().get(path).cloned().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("file not found: {}", path.display()),
)
})
}
async fn write_string(&self, path: &Path, content: &str) -> std::io::Result<()> {
self.files
.lock()
.unwrap()
.insert(path.to_path_buf(), content.to_string());
Ok(())
}
async fn append_string(&self, path: &Path, content: &str) -> std::io::Result<()> {
let mut files = self.files.lock().unwrap();
files.entry(path.to_path_buf()).or_default().push_str(content);
Ok(())
}
async fn exists(&self, path: &Path) -> bool {
self.files.lock().unwrap().contains_key(path)
}
async fn list_dir(&self, path: &Path) -> std::io::Result<Vec<PathBuf>> {
Ok(self
.files
.lock()
.unwrap()
.keys()
.filter(|p| p.parent() == Some(path))
.cloned()
.collect())
}
async fn create_dir_all(&self, _path: &Path) -> std::io::Result<()> {
Ok(())
}
async fn remove_file(&self, path: &Path) -> std::io::Result<()> {
self.files.lock().unwrap().remove(path);
Ok(())
}
fn home_dir(&self) -> Option<PathBuf> {
Some(PathBuf::from("/mock-home"))
}
async fn metadata(&self, path: &Path) -> std::io::Result<FsMetadata> {
if let Some(content) = self.files.lock().unwrap().get(path) {
return Ok(FsMetadata {
is_dir: false,
len: content.len() as u64,
});
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"not found",
))
}
}
struct MockEnv;
impl clawft_platform::env::Environment for MockEnv {
fn get_var(&self, _name: &str) -> Option<String> {
None
}
fn set_var(&self, _name: &str, _value: &str) {}
fn remove_var(&self, _name: &str) {}
}
struct MockHttp;
#[async_trait]
impl clawft_platform::http::HttpClient for MockHttp {
async fn request(
&self,
_method: &str,
_url: &str,
_headers: &HashMap<String, String>,
_body: Option<&[u8]>,
) -> Result<clawft_platform::http::HttpResponse, Box<dyn std::error::Error + Send + Sync>>
{
Err("MockHttp unused in identity tests".into())
}
}
struct MockPlatform {
fs: MockFs,
env: MockEnv,
http: MockHttp,
}
impl MockPlatform {
fn new() -> Self {
Self {
fs: MockFs::new(),
env: MockEnv,
http: MockHttp,
}
}
}
#[async_trait]
impl Platform for MockPlatform {
fn http(&self) -> &dyn clawft_platform::http::HttpClient {
&self.http
}
fn fs(&self) -> &dyn FileSystem {
&self.fs
}
fn env(&self) -> &dyn clawft_platform::env::Environment {
&self.env
}
fn process(&self) -> Option<&dyn clawft_platform::process::ProcessSpawner> {
None
}
}
fn mock_platform() -> Arc<MockPlatform> {
Arc::new(MockPlatform::new())
}
#[cfg(feature = "native")]
fn native_platform() -> Arc<clawft_platform::NativePlatform> {
Arc::new(clawft_platform::NativePlatform::new())
}
#[test]
fn binding_thread_excerpt_is_non_empty() {
assert!(!BINDING_THREAD_EXCERPT.is_empty());
assert!(BINDING_THREAD_EXCERPT.len() > 16);
assert_eq!(BINDING_THREAD_GATE_ACTION, "soul.binding_thread_intact");
assert_eq!(BINDING_THREAD_MISMATCH_REASON, "binding-thread mismatch");
}
#[test]
fn soul_contains_binding_thread_helper() {
assert!(soul_contains_binding_thread(&format!(
"prefix {BINDING_THREAD_EXCERPT} suffix"
)));
assert!(!soul_contains_binding_thread("nothing relevant here"));
}
#[test]
fn sha256_hash_matches_known_vector() {
let h = sha256_identity_hash("hello", "world");
assert_eq!(
h,
"26c60a61d01db5836ca70fefd44a6a016620413c8ef5f259a6c5612d4f79d3b8"
);
assert_eq!(h.len(), 64); assert_eq!(h, sha256_identity_hash("hello", "world"));
assert_ne!(h, sha256_identity_hash("hello", "WORLD"));
}
#[tokio::test]
async fn loads_via_platform_fs_mock_no_std_fs() {
let platform = mock_platform();
let workspace = PathBuf::from("/wasm-ws");
let clawft = workspace.join(".clawft");
platform
.fs
.seed(clawft.join("SOUL.md"), "soul content");
platform
.fs
.seed(clawft.join("IDENTITY.md"), "identity content");
let loader = IdentityLoader::new(&workspace, Arc::clone(&platform));
let id = loader.current().await.expect("should load via mock fs");
assert_eq!(id.soul, "soul content");
assert_eq!(id.identity, "identity content");
assert_eq!(id.source, IdentitySource::Clawft);
assert!(id.pending_journal.is_empty());
assert_eq!(
id.hash,
sha256_identity_hash("soul content", "identity content")
);
assert_eq!(id.hash.len(), 64);
}
#[cfg(feature = "native")]
#[tokio::test]
async fn loads_from_clawft_when_present() {
let tmp = tempfile::tempdir().unwrap();
let clawft = tmp.path().join(".clawft");
std::fs::create_dir_all(&clawft).unwrap();
std::fs::write(clawft.join("SOUL.md"), "soul content").unwrap();
std::fs::write(clawft.join("IDENTITY.md"), "identity content").unwrap();
let loader = IdentityLoader::new(tmp.path(), native_platform());
let id = loader.current().await.expect("should load");
assert_eq!(id.soul, "soul content");
assert_eq!(id.identity, "identity content");
assert_eq!(id.source, IdentitySource::Clawft);
assert_eq!(
id.hash,
sha256_identity_hash("soul content", "identity content")
);
assert_eq!(id.hash.len(), 64);
}
#[cfg(feature = "native")]
#[tokio::test]
async fn does_not_load_from_docs_skills_anymore() {
let tmp = tempfile::tempdir().unwrap();
let docs = tmp.path().join("docs").join("skills").join("clawft");
std::fs::create_dir_all(&docs).unwrap();
std::fs::write(docs.join("SOUL.md"), "doc soul").unwrap();
std::fs::write(docs.join("IDENTITY.md"), "doc identity").unwrap();
let loader = IdentityLoader::new(tmp.path(), native_platform());
assert!(
loader.current().await.is_none(),
"post-F1: docs/skills/clawft/ must not satisfy the loader"
);
}
#[tokio::test]
async fn returns_none_when_clawft_dir_missing() {
let loader = IdentityLoader::new(PathBuf::from("/empty-ws"), mock_platform());
assert!(loader.current().await.is_none());
}
#[tokio::test]
async fn returns_none_when_only_one_file_present() {
let platform = mock_platform();
let workspace = PathBuf::from("/half-ws");
platform
.fs
.seed(workspace.join(".clawft").join("SOUL.md"), "soul only");
let loader = IdentityLoader::new(&workspace, platform);
assert!(loader.current().await.is_none());
}
#[tokio::test]
async fn two_workspaces_load_distinct_identities() {
let platform = mock_platform();
let alpha = PathBuf::from("/ws-alpha");
let beta = PathBuf::from("/ws-beta");
for (root, soul, id) in [
(&alpha, "soul-alpha", "id-alpha"),
(&beta, "soul-beta", "id-beta"),
] {
let clawft = root.join(".clawft");
platform.fs.seed(clawft.join("SOUL.md"), soul);
platform.fs.seed(clawft.join("IDENTITY.md"), id);
}
let cfg_alpha = clawft_types::config::AgentsConfig {
workspace_root: Some(alpha.clone()),
..clawft_types::config::AgentsConfig::default()
};
let cfg_beta = clawft_types::config::AgentsConfig {
workspace_root: Some(beta.clone()),
..clawft_types::config::AgentsConfig::default()
};
let loader_a =
IdentityLoader::from_agents_config(&cfg_alpha, Arc::clone(&platform)).unwrap();
let loader_b =
IdentityLoader::from_agents_config(&cfg_beta, Arc::clone(&platform)).unwrap();
let id_a = loader_a.current().await.expect("alpha identity");
let id_b = loader_b.current().await.expect("beta identity");
assert_eq!(id_a.soul, "soul-alpha");
assert_eq!(id_b.soul, "soul-beta");
assert_ne!(id_a.hash, id_b.hash);
assert_eq!(loader_a.workspace(), alpha.as_path());
assert_eq!(loader_b.workspace(), beta.as_path());
}
#[test]
fn from_agents_config_falls_back_to_cwd_when_unset() {
let cwd = std::env::current_dir().unwrap();
let cfg = clawft_types::config::AgentsConfig::default();
assert!(cfg.workspace_root.is_none());
let loader = IdentityLoader::from_agents_config(&cfg, mock_platform()).unwrap();
assert_eq!(loader.workspace(), cwd.as_path());
}
#[tokio::test]
async fn file_provider_loads_and_caches() {
let platform = mock_platform();
let workspace = PathBuf::from("/prov-ws");
let clawft = workspace.join(".clawft");
platform.fs.seed(clawft.join("SOUL.md"), "soul-1");
platform.fs.seed(clawft.join("IDENTITY.md"), "id-1");
let provider = FileIdentityProvider::new(&workspace, Arc::clone(&platform));
let first = provider.current().await.expect("first load");
assert_eq!(first.soul, "soul-1");
platform.fs.seed(clawft.join("SOUL.md"), "soul-2");
let second = provider.current().await.expect("second load");
assert_eq!(second.soul, "soul-2");
assert_ne!(first.hash, second.hash);
}
#[tokio::test]
async fn file_provider_serves_cache_when_disk_disappears() {
let platform = mock_platform();
let workspace = PathBuf::from("/cache-ws");
let clawft = workspace.join(".clawft");
platform.fs.seed(clawft.join("SOUL.md"), "cached-soul");
platform.fs.seed(clawft.join("IDENTITY.md"), "cached-id");
let provider = FileIdentityProvider::new(&workspace, Arc::clone(&platform));
let first = provider.current().await.expect("warm cache");
platform
.fs()
.remove_file(&clawft.join("SOUL.md"))
.await
.unwrap();
platform
.fs()
.remove_file(&clawft.join("IDENTITY.md"))
.await
.unwrap();
let cached = provider.current().await.expect("cache fallback");
assert_eq!(cached.soul, first.soul);
assert_eq!(cached.hash, first.hash);
assert_eq!(
cached.source,
IdentitySource::Cache,
"cache fallback must tag IdentitySource::Cache (WEFT-97)"
);
}
#[tokio::test]
async fn file_provider_returns_not_found_with_no_cache() {
let provider = FileIdentityProvider::new(PathBuf::from("/missing"), mock_platform());
let err = provider.current().await.unwrap_err();
assert!(matches!(err, IdentityError::NotFound));
}
#[tokio::test]
async fn watch_mode_serves_cache_until_invalidate() {
let platform = mock_platform();
let workspace = PathBuf::from("/watch-ws");
let clawft = workspace.join(".clawft");
platform.fs.seed(clawft.join("SOUL.md"), "soul-v1");
platform.fs.seed(clawft.join("IDENTITY.md"), "id-v1");
let provider = FileIdentityProvider::new(&workspace, Arc::clone(&platform));
provider.enable_watch_mode();
assert!(provider.is_watch_mode());
assert!(provider.is_stale());
let first = provider.current().await.expect("warm");
assert_eq!(first.soul, "soul-v1");
assert!(!provider.is_stale());
platform.fs.seed(clawft.join("SOUL.md"), "soul-v2");
let cached = provider.current().await.expect("cache hit");
assert_eq!(cached.soul, "soul-v1");
assert_eq!(cached.hash, first.hash);
provider.invalidate();
assert!(provider.is_stale());
let reloaded = provider.current().await.expect("reload");
assert_eq!(reloaded.soul, "soul-v2");
assert_ne!(reloaded.hash, first.hash);
assert!(!provider.is_stale());
}
#[tokio::test]
async fn watch_mode_re_read_after_invalidate_with_temp_files() {
#[cfg(feature = "native")]
{
let tmp = tempfile::tempdir().unwrap();
let clawft = tmp.path().join(".clawft");
std::fs::create_dir_all(&clawft).unwrap();
std::fs::write(clawft.join("SOUL.md"), "temp-soul-a").unwrap();
std::fs::write(clawft.join("IDENTITY.md"), "temp-id-a").unwrap();
let provider =
FileIdentityProvider::new(tmp.path(), native_platform());
provider.enable_watch_mode();
let a = provider.current().await.expect("load a");
assert_eq!(a.soul, "temp-soul-a");
std::fs::write(clawft.join("SOUL.md"), "temp-soul-b").unwrap();
assert_eq!(
provider.current().await.expect("still a").soul,
"temp-soul-a"
);
provider.invalidate();
let b = provider.current().await.expect("load b");
assert_eq!(b.soul, "temp-soul-b");
assert_eq!(b.source, IdentitySource::Clawft);
}
}
#[test]
fn identity_source_wire_labels_are_stable() {
assert_eq!(IdentitySource::Clawft.as_str(), "clawft");
assert_eq!(IdentitySource::Cache.as_str(), "cache");
assert_eq!(IdentitySource::Substrate.as_str(), "substrate");
assert_eq!(IdentitySource::Journal.as_str(), "journal");
assert_eq!(IdentitySource::Stub.as_str(), "stub");
assert_eq!(IdentitySource::Journal.to_string(), "journal");
}
#[test]
fn identity_error_variants_display_distinct_messages() {
let msgs = [
IdentityError::NotFound.to_string(),
IdentityError::BindingThreadMismatch.to_string(),
IdentityError::Io("disk full".into()).to_string(),
IdentityError::Deserialize("bad json".into()).to_string(),
IdentityError::Substrate("grant denied".into()).to_string(),
];
for m in &msgs {
assert!(m.starts_with("identity load failed:"), "{m}");
}
assert!(msgs[2].contains("IO error"));
assert!(msgs[3].contains("deserialize"));
assert!(msgs[4].contains("substrate"));
}
struct StubBaseProvider(Identity);
#[async_trait]
impl IdentityProvider for StubBaseProvider {
async fn current(&self) -> Result<Identity, IdentityError> {
Ok(self.0.clone())
}
}
#[tokio::test]
async fn journal_aware_provider_attaches_pending_on_every_turn() {
use crate::agent::soul_journal::{
DriftObservation, InMemorySoulJournal, SoulJournal, SoulJournalReader,
};
let base = Arc::new(StubBaseProvider(Identity::from_files(
"soul-base",
"id-base",
IdentitySource::Clawft,
)));
let journal = Arc::new(InMemorySoulJournal::new());
journal
.append(DriftObservation::synthetic(
"prefer terse replies",
"user wants shorter answers going forward",
))
.await
.unwrap();
let reader: Arc<dyn SoulJournalReader> = journal.clone();
let provider = JournalAwareIdentityProvider::new(base, reader);
let id = provider.current().await.expect("load");
assert_eq!(id.source, IdentitySource::Journal);
assert_eq!(id.soul, "soul-base");
assert_eq!(id.pending_journal.len(), 1);
assert_eq!(id.pending_journal[0].summary, "prefer terse replies");
assert_eq!(id.hash, sha256_identity_hash("soul-base", "id-base"));
journal
.append(DriftObservation::synthetic("second", "another note"))
.await
.unwrap();
let id2 = provider.current().await.expect("second turn");
assert_eq!(id2.pending_journal.len(), 2);
assert_eq!(id2.source, IdentitySource::Journal);
}
#[tokio::test]
async fn journal_aware_provider_keeps_base_source_when_empty() {
use crate::agent::soul_journal::{InMemorySoulJournal, SoulJournalReader};
let base = Arc::new(StubBaseProvider(Identity::from_files(
"s",
"i",
IdentitySource::Clawft,
)));
let reader: Arc<dyn SoulJournalReader> = Arc::new(InMemorySoulJournal::new());
let provider = JournalAwareIdentityProvider::new(base, reader);
let id = provider.current().await.unwrap();
assert_eq!(id.source, IdentitySource::Clawft);
assert!(id.pending_journal.is_empty());
}
struct FailingJournalReader;
#[async_trait]
impl crate::agent::soul_journal::SoulJournalReader for FailingJournalReader {
async fn list_pending(
&self,
) -> Result<Vec<crate::agent::soul_journal::PendingJournalEntry>, String> {
Err("substrate grant denied for soul_journal".into())
}
}
#[tokio::test]
async fn journal_aware_provider_degrades_on_reader_error_by_default() {
let base = Arc::new(StubBaseProvider(Identity::from_files(
"s",
"i",
IdentitySource::Clawft,
)));
let provider =
JournalAwareIdentityProvider::new(base, Arc::new(FailingJournalReader));
let id = provider.current().await.expect("degrade open");
assert_eq!(id.source, IdentitySource::Clawft);
assert!(id.pending_journal.is_empty());
}
#[tokio::test]
async fn journal_aware_provider_fail_closed_maps_substrate_error() {
let base = Arc::new(StubBaseProvider(Identity::from_files(
"s",
"i",
IdentitySource::Clawft,
)));
let provider = JournalAwareIdentityProvider::new(base, Arc::new(FailingJournalReader))
.fail_closed(true);
let err = provider.current().await.unwrap_err();
assert!(
matches!(err, IdentityError::Substrate(_)),
"expected Substrate variant, got {err:?}"
);
}
}