diff --git a/crates/tracedecay-store-runtime/src/retained_memory.rs b/crates/tracedecay-store-runtime/src/retained_memory.rs index 0c905a20b5..3611617540 100644 --- a/crates/tracedecay-store-runtime/src/retained_memory.rs +++ b/crates/tracedecay-store-runtime/src/retained_memory.rs @@ -187,12 +187,21 @@ impl<'a> DirectRetainedMemoryPortV1<'a> { request: Read<'_>, ) -> Result, RetainedSurfaceExecutionErrorV1> { let (memory_scope, selector) = request.scope(); + let access = match request { + Read::Search(_) => MemoryTargetAccessV1::RecordRetrieval, + Read::Probe(_) + | Read::Related(_) + | Read::Reason(_) + | Read::Contradict(_) + | Read::Get(_) + | Read::List(_) => MemoryTargetAccessV1::Read, + }; execute_scoped_memory!( self, context, memory_scope, selector, - MemoryTargetAccessV1::Read, + access, execute_read_on_db(request) ) } @@ -278,55 +287,63 @@ impl<'a> DirectRetainedMemoryPortV1<'a> { } } +impl DirectRetainedMemoryPortV1<'_> { + pub async fn execute_request( + &self, + context: RetainedSurfaceExecutionContextV1<'_>, + request: RetainedMemoryRequestV1<'_>, + ) -> Result, RetainedSurfaceExecutionErrorV1> { + match request { + RetainedMemoryRequestV1::FactStoreAdd(request) => { + self.execute_add(&context, request).await + } + RetainedMemoryRequestV1::FactStoreSearch(request) => { + self.execute_read(&context, Read::Search(request)).await + } + RetainedMemoryRequestV1::FactStoreProbe(request) => { + self.execute_read(&context, Read::Probe(request)).await + } + RetainedMemoryRequestV1::FactStoreRelated(request) => { + self.execute_read(&context, Read::Related(request)).await + } + RetainedMemoryRequestV1::FactStoreReason(request) => { + self.execute_read(&context, Read::Reason(request)).await + } + RetainedMemoryRequestV1::FactStoreContradict(request) => { + self.execute_read(&context, Read::Contradict(request)).await + } + RetainedMemoryRequestV1::FactStoreGet(request) => { + self.execute_read(&context, Read::Get(request)).await + } + RetainedMemoryRequestV1::FactStoreUpdate(request) => { + self.execute_update(&context, request).await + } + RetainedMemoryRequestV1::FactStoreRemove(request) => { + self.execute_remove(&context, request).await + } + RetainedMemoryRequestV1::FactStoreSupersede(request) => { + self.execute_supersede(&context, request).await + } + RetainedMemoryRequestV1::FactStoreList(request) => { + self.execute_read(&context, Read::List(request)).await + } + RetainedMemoryRequestV1::FactFeedback(request) => { + self.execute_feedback(&context, request).await + } + RetainedMemoryRequestV1::MemoryStatus(request) => { + self.execute_status(&context, request).await + } + } + } +} + impl RetainedMemoryExecutionPortV1 for DirectRetainedMemoryPortV1<'_> { fn execute_memory<'a>( &'a self, context: RetainedSurfaceExecutionContextV1<'a>, request: RetainedMemoryRequestV1<'a>, ) -> RetainedSurfaceExecutionFutureV1<'a> { - Box::pin(async move { - match request { - RetainedMemoryRequestV1::FactStoreAdd(request) => { - self.execute_add(&context, request).await - } - RetainedMemoryRequestV1::FactStoreSearch(request) => { - self.execute_read(&context, Read::Search(request)).await - } - RetainedMemoryRequestV1::FactStoreProbe(request) => { - self.execute_read(&context, Read::Probe(request)).await - } - RetainedMemoryRequestV1::FactStoreRelated(request) => { - self.execute_read(&context, Read::Related(request)).await - } - RetainedMemoryRequestV1::FactStoreReason(request) => { - self.execute_read(&context, Read::Reason(request)).await - } - RetainedMemoryRequestV1::FactStoreContradict(request) => { - self.execute_read(&context, Read::Contradict(request)).await - } - RetainedMemoryRequestV1::FactStoreGet(request) => { - self.execute_read(&context, Read::Get(request)).await - } - RetainedMemoryRequestV1::FactStoreUpdate(request) => { - self.execute_update(&context, request).await - } - RetainedMemoryRequestV1::FactStoreRemove(request) => { - self.execute_remove(&context, request).await - } - RetainedMemoryRequestV1::FactStoreSupersede(request) => { - self.execute_supersede(&context, request).await - } - RetainedMemoryRequestV1::FactStoreList(request) => { - self.execute_read(&context, Read::List(request)).await - } - RetainedMemoryRequestV1::FactFeedback(request) => { - self.execute_feedback(&context, request).await - } - RetainedMemoryRequestV1::MemoryStatus(request) => { - self.execute_status(&context, request).await - } - } - }) + Box::pin(async move { self.execute_request(context, request).await }) } } diff --git a/crates/tracedecay-store-runtime/src/retained_memory/target.rs b/crates/tracedecay-store-runtime/src/retained_memory/target.rs index 7b44372957..aaf9decc63 100644 --- a/crates/tracedecay-store-runtime/src/retained_memory/target.rs +++ b/crates/tracedecay-store-runtime/src/retained_memory/target.rs @@ -9,7 +9,7 @@ use tracedecay_contracts::RetainedSurfaceExecutionErrorV1; use tracedecay_contracts::retained_surfaces::{MemoryScopeV1, RetainedProjectSelectorV1}; use tracedecay_domain::{FactOwnerV1, ProjectId}; use tracedecay_global_db::{RegisteredGlobalDbLeaseV1, registry_context_candidate_roots}; -use tracedecay_runtime_core::db::Database; +use tracedecay_runtime_core::db::{Database, DatabaseAccessMode}; use tracedecay_runtime_core::storage; use tracedecay_session_memory::fact_store::ProjectMemoryDbHandle; use tracedecay_session_runtime::retained::map_execution_error; @@ -23,12 +23,22 @@ pub struct RetainedMemoryTargetAuthorityV1 { pub profile_database: RegisteredGlobalDbLeaseV1, pub project_root: PathBuf, pub project_id: ProjectId, + /// On-disk store identity from `StoreLayout.identity.project_id`. + pub store_layout_project_id: ProjectId, + /// Served project root from the live graph, not the admitted request root. + pub served_project_root: PathBuf, + /// Whether the served graph is published read-only. + pub graph_read_only: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MemoryTargetAccessV1 { Read, Write, + /// Search records a retrieval projection. Writable graphs take a write + /// lease; a read-only graph or owner degrades to a read-only lease and + /// reports `ReadOnly` telemetry instead of refusing. + RecordRetrieval, } pub struct RetainedMemoryTargetV1<'a> { @@ -112,22 +122,40 @@ pub async fn open_project_retained_memory_target( } let selected_project_id = selector.map_or(admitted_project_id, |value| &value.project_id); if selected_project_id == admitted_project_id { - if authority.project_root != registered_root { + if authority.project_id != *admitted_project_id { + return denied(); + } + if authority.served_project_root != registered_root { + return denied(); + } + if authority.store_layout_project_id != *admitted_project_id { + return denied(); + } + if access == MemoryTargetAccessV1::Write && authority.graph_read_only { return denied(); } - let owner = FactOwnerV1::Project { - project_id: admitted_project_id.clone(), + let database = match access { + MemoryTargetAccessV1::RecordRetrieval => authority + .registry + .mounted_project_memory_recording(admitted_project_id, authority.graph_read_only) + .map_err(map_execution_error)?, + MemoryTargetAccessV1::Read => authority + .registry + .mounted_project_memory(admitted_project_id, DatabaseAccessMode::ReadOnly) + .map_err(map_execution_error)?, + MemoryTargetAccessV1::Write => authority + .registry + .mounted_project_memory(admitted_project_id, DatabaseAccessMode::ReadWrite) + .map_err(map_execution_error)?, }; - if authority.project_id != *admitted_project_id { + if access == MemoryTargetAccessV1::Write && !database.is_writable() { return denied(); } - let database = authority - .registry - .mounted_project_memory(admitted_project_id) - .map_err(map_execution_error)?; return Ok(RetainedMemoryTargetV1::new( ProjectMemoryDbHandle::Owned(Box::new(database)), - owner, + FactOwnerV1::Project { + project_id: authority.store_layout_project_id.clone(), + }, )); } if access == MemoryTargetAccessV1::Write { @@ -206,8 +234,293 @@ fn map_target_infrastructure_error( #[cfg(test)] mod tests { + use std::sync::Arc; + + use tempfile::TempDir; + use tracedecay_daemon_identity::profile_identity; + use super::*; + struct MemoryTargetFixture { + registry: Arc, + profile_database: RegisteredGlobalDbLeaseV1, + project_id: ProjectId, + project_root: PathBuf, + profile_root: PathBuf, + _database_scope: tracedecay_runtime_core::db::DaemonDatabaseScope, + _temp: TempDir, + } + + impl MemoryTargetFixture { + async fn new(label: &str) -> Self { + let temp = TempDir::new().expect("memory target fixture root"); + let profile_root = temp.path().join("profile"); + let identity = + profile_identity::load_or_create(&profile_root).expect("profile identity"); + let database_scope = + tracedecay_runtime_core::db::enter_daemon_database_scope(&profile_root, 29, label) + .expect("daemon database scope"); + let registry = Arc::new( + DaemonSessionRuntimeRegistryV1::open(identity) + .await + .expect("session runtime registry"), + ); + let project_id = + ProjectId::new(format!("project.retained-memory.{label}")).expect("project id"); + let project_root = temp.path().join("served"); + std::fs::create_dir_all(&project_root).expect("served project root"); + tracedecay_runtime_core::storage::pin_fixture_repository_identity( + &project_root, + project_id.as_str(), + ) + .expect("project enrollment"); + let _mounted = registry + .project_memory(project_id.clone(), [project_root.clone()]) + .await + .expect("mounted project memory"); + let profile_database = registry.profile_database().await.expect("profile database"); + Self { + registry, + profile_database, + project_id, + project_root, + profile_root, + _database_scope: database_scope, + _temp: temp, + } + } + + async fn reopen_read_only_owner(self) -> Self { + let identity = + profile_identity::load_or_create(&self.profile_root).expect("profile identity"); + let project_id = self.project_id.clone(); + let project_root = self.project_root.clone(); + let profile_root = self.profile_root.clone(); + let temp = self._temp; + self.registry + .shutdown_memory_graph_reconciliation_tasks() + .await + .expect("join seed graph reconciliation"); + drop((self.registry, self.profile_database, self._database_scope)); + let database_scope = tracedecay_runtime_core::db::enter_daemon_database_scope( + &profile_root, + 30, + "read-only-reopen", + ) + .expect("reopened daemon database scope"); + let registry = Arc::new( + DaemonSessionRuntimeRegistryV1::open(identity) + .await + .expect("reopened session runtime registry"), + ); + let _mounted = registry + .publish_read_only_memory_owner_for_test(project_id.clone(), [project_root.clone()]) + .await + .expect("mounted read-only project memory"); + let profile_database = registry.profile_database().await.expect("profile database"); + Self { + registry, + profile_database, + project_id, + project_root, + profile_root, + _database_scope: database_scope, + _temp: temp, + } + } + + fn authority( + &self, + store_layout_project_id: ProjectId, + served_project_root: PathBuf, + ) -> RetainedMemoryTargetAuthorityV1 { + RetainedMemoryTargetAuthorityV1 { + registry: Arc::clone(&self.registry), + profile_database: self.profile_database.clone(), + project_root: self.project_root.clone(), + project_id: self.project_id.clone(), + store_layout_project_id, + served_project_root, + graph_read_only: false, + } + } + } + + async fn open_same_project( + authority: &RetainedMemoryTargetAuthorityV1, + registered_root: &Path, + admitted_project_id: &ProjectId, + ) -> Result, RetainedSurfaceExecutionErrorV1> { + open_same_project_with( + authority, + registered_root, + admitted_project_id, + MemoryTargetAccessV1::Read, + ) + .await + } + + #[tokio::test] + async fn same_project_open_denies_when_store_layout_identity_disagrees() { + let fixture = MemoryTargetFixture::new("store-id-drift").await; + let foreign = ProjectId::new("project.retained-memory.foreign-store").expect("foreign id"); + let authority = fixture.authority(foreign, fixture.project_root.clone()); + let error = open_same_project(&authority, &fixture.project_root, &fixture.project_id) + .await + .err() + .expect("store identity drift must deny"); + assert!(matches!( + error, + RetainedSurfaceExecutionErrorV1::NotFoundOrNotAuthorized + )); + } + + #[tokio::test] + async fn same_project_open_denies_when_mounted_scope_disagrees() { + let fixture = MemoryTargetFixture::new("mounted-scope-drift").await; + let foreign = ProjectId::new("project.retained-memory.foreign-mount").expect("foreign id"); + let mut authority = + fixture.authority(fixture.project_id.clone(), fixture.project_root.clone()); + authority.project_id = foreign; + let error = open_same_project(&authority, &fixture.project_root, &fixture.project_id) + .await + .err() + .expect("mounted scope drift must deny"); + assert!(matches!( + error, + RetainedSurfaceExecutionErrorV1::NotFoundOrNotAuthorized + )); + } + + #[tokio::test] + async fn same_project_read_open_issues_a_read_only_lease() { + let fixture = MemoryTargetFixture::new("read-lease").await; + let authority = fixture.authority(fixture.project_id.clone(), fixture.project_root.clone()); + let target = open_same_project(&authority, &fixture.project_root, &fixture.project_id) + .await + .expect("matching store identity must open"); + assert!( + !target.database().is_writable(), + "Read access must issue a read-only lease" + ); + } + + async fn open_same_project_with( + authority: &RetainedMemoryTargetAuthorityV1, + registered_root: &Path, + admitted_project_id: &ProjectId, + access: MemoryTargetAccessV1, + ) -> Result, RetainedSurfaceExecutionErrorV1> { + open_project_retained_memory_target( + authority, + registered_root, + admitted_project_id, + Some(MemoryScopeV1::Project), + None, + access, + ) + .await + } + + #[tokio::test] + async fn same_project_record_retrieval_open_issues_a_writable_lease() { + let fixture = MemoryTargetFixture::new("retrieval-lease").await; + let authority = fixture.authority(fixture.project_id.clone(), fixture.project_root.clone()); + let target = open_same_project_with( + &authority, + &fixture.project_root, + &fixture.project_id, + MemoryTargetAccessV1::RecordRetrieval, + ) + .await + .expect("matching store identity must open"); + assert!( + target.database().is_writable(), + "RecordRetrieval on a writable graph must issue a write lease" + ); + } + + #[tokio::test] + async fn same_project_record_retrieval_degrades_when_owner_is_not_writable() { + let fixture = MemoryTargetFixture::new("retrieval-owner-ro") + .await + .reopen_read_only_owner() + .await; + let authority = fixture.authority(fixture.project_id.clone(), fixture.project_root.clone()); + assert!(!authority.graph_read_only); + let target = open_same_project_with( + &authority, + &fixture.project_root, + &fixture.project_id, + MemoryTargetAccessV1::RecordRetrieval, + ) + .await + .expect("owner-not-writable search must degrade, not fail"); + assert!( + !target.database().is_writable(), + "RecordRetrieval must fall back to a read-only lease when the owner refuses writability" + ); + } + + #[tokio::test] + async fn same_project_record_retrieval_on_read_only_graph_issues_a_read_only_lease() { + let fixture = MemoryTargetFixture::new("retrieval-readonly").await; + let mut authority = + fixture.authority(fixture.project_id.clone(), fixture.project_root.clone()); + authority.graph_read_only = true; + let target = open_same_project_with( + &authority, + &fixture.project_root, + &fixture.project_id, + MemoryTargetAccessV1::RecordRetrieval, + ) + .await + .expect("read-only graph must degrade retrieval recording, not deny"); + assert!( + !target.database().is_writable(), + "RecordRetrieval on a read-only graph must issue a read-only lease" + ); + } + + #[tokio::test] + async fn same_project_write_against_read_only_graph_is_denied() { + let fixture = MemoryTargetFixture::new("write-readonly").await; + let mut authority = + fixture.authority(fixture.project_id.clone(), fixture.project_root.clone()); + authority.graph_read_only = true; + let error = open_project_retained_memory_target( + &authority, + &fixture.project_root, + &fixture.project_id, + Some(MemoryScopeV1::Project), + None, + MemoryTargetAccessV1::Write, + ) + .await + .err() + .expect("write against a read-only graph must deny"); + assert!(matches!( + error, + RetainedSurfaceExecutionErrorV1::NotFoundOrNotAuthorized + )); + } + + #[tokio::test] + async fn same_project_open_denies_when_served_root_disagrees() { + let fixture = MemoryTargetFixture::new("served-root-drift").await; + let foreign_root = fixture._temp.path().join("foreign-served"); + std::fs::create_dir_all(&foreign_root).expect("foreign served root"); + let authority = fixture.authority(fixture.project_id.clone(), foreign_root); + let error = open_same_project(&authority, &fixture.project_root, &fixture.project_id) + .await + .err() + .expect("served root drift must deny"); + assert!(matches!( + error, + RetainedSurfaceExecutionErrorV1::NotFoundOrNotAuthorized + )); + } + #[test] fn selected_target_infrastructure_failures_remain_typed() { let RetainedSurfaceExecutionErrorV1::Unavailable { detail } = diff --git a/crates/tracedecay-store-runtime/src/session_registry/mounts.rs b/crates/tracedecay-store-runtime/src/session_registry/mounts.rs index 1dc69665eb..4fe4aa7e96 100644 --- a/crates/tracedecay-store-runtime/src/session_registry/mounts.rs +++ b/crates/tracedecay-store-runtime/src/session_registry/mounts.rs @@ -1387,32 +1387,83 @@ impl DaemonSessionRuntimeRegistryV1 { Ok(lease) } - /// Issues a write lease for a project-memory store that is already mounted. + /// Issues a lease for a project-memory store that is already mounted. /// /// Retained project memory never discovers or initializes a store; the root /// assembler selected this runtime because the project is already Ready. - pub fn mounted_project_memory(&self, project_id: &ProjectId) -> Result { + /// Read access and read-only graphs take a read-only lease. A write + /// request against a read-only owner is refused rather than silently + /// narrowed. + pub fn mounted_project_memory( + &self, + project_id: &ProjectId, + access: DatabaseAccessMode, + ) -> Result { let mounted = self .project_owners .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); match mounted.get(project_id) { - Some(ProjectRuntimeOwnerStateV1::Ready(owners)) => owners - .memory - .as_ref() - .ok_or_else(|| { + Some(ProjectRuntimeOwnerStateV1::Ready(owners)) => { + let owner = owners.memory.as_ref().ok_or_else(|| { session_registry_error( "issue mounted project memory database client", "project memory owner is not mounted".to_string(), ) - })? - .issue_database_lease() - .map_err(|error| { + })?; + issue_mounted_memory_lease(owner, access, true) + } + Some(ProjectRuntimeOwnerStateV1::Opening(_)) => Err(TraceDecayError::project_route( + "project_runtime_opening", + true, + "Project runtime is already opening", + )), + Some( + ProjectRuntimeOwnerStateV1::Retiring(_) + | ProjectRuntimeOwnerStateV1::ReplacingSessions(_) + | ProjectRuntimeOwnerStateV1::Recovering(_) + | ProjectRuntimeOwnerStateV1::RecoveryRequired(_) + | ProjectRuntimeOwnerStateV1::Faulted(_), + ) => Err(TraceDecayError::project_route( + "project_runtime_retiring", + true, + "Project runtime is unavailable while retirement is terminal or in progress", + )), + None => Err(session_registry_error( + "issue mounted project memory database client", + "project memory owner is not mounted".to_string(), + )), + } + } + + /// Issues a retrieval-recording lease: write when the owner is writable, + /// otherwise a read-only lease so search can still serve evidence. + pub fn mounted_project_memory_recording( + &self, + project_id: &ProjectId, + graph_read_only: bool, + ) -> Result { + if graph_read_only { + return self.mounted_project_memory(project_id, DatabaseAccessMode::ReadOnly); + } + let mounted = self + .project_owners + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match mounted.get(project_id) { + Some(ProjectRuntimeOwnerStateV1::Ready(owners)) => { + let owner = owners.memory.as_ref().ok_or_else(|| { session_registry_error( "issue mounted project memory database client", - error.to_string(), + "project memory owner is not mounted".to_string(), ) - }), + })?; + match issue_mounted_memory_lease(owner, DatabaseAccessMode::ReadWrite, false) { + Ok(database) if database.is_writable() => Ok(database), + Ok(_) => issue_mounted_memory_lease(owner, DatabaseAccessMode::ReadOnly, false), + Err(error) => Err(error), + } + } Some(ProjectRuntimeOwnerStateV1::Opening(_)) => Err(TraceDecayError::project_route( "project_runtime_opening", true, @@ -1657,4 +1708,109 @@ impl DaemonSessionRuntimeRegistryV1 { ) }) } + + /// Test-only: occupy a registry slot with a read-only memory owner so + /// recording-lease degradation can be exercised without teaching the + /// production read-only open to publish. + #[cfg(test)] + pub async fn publish_read_only_memory_owner_for_test( + &self, + project_id: ProjectId, + enrollment_roots: impl IntoIterator, + ) -> Result { + self.resolver + .register_project_authority(LocalProjectEnrollmentAuthorityV1::new( + project_id.clone(), + enrollment_roots, + )) + .map_err(|error| { + session_registry_error("register project memory authority", format!("{error:?}")) + })?; + let shard_id = StoreShardIdV1::project( + self.identity.brain_id().clone(), + self.identity.profile_id().clone(), + project_id.clone(), + ); + let pin = self + .profile_authority_pin("publish read-only project memory owner for test") + .await?; + let runtime = match self + .registry + .open(StoreRuntimeOpenRequest::new_read_only( + shard_id.clone(), + self.incarnation, + Some(pin), + )) + .await + { + StoreRuntimeOpenResult::Published(runtime) => runtime, + StoreRuntimeOpenResult::Failed(failure) => { + return Err(registry_open_error( + "publish read-only project memory owner for test", + failure, + )); + } + }; + let owner = Database::publish_runtime(runtime, DatabaseAccessMode::ReadOnly).await?; + let database = owner.issue_read_only_lease().map_err(|error| { + session_registry_error( + "issue project memory read-only database client", + format!("{error:?}"), + ) + })?; + let mut admission = match self.admit_project_runtime_owner(&project_id)? { + ProjectRuntimeOwnerAdmissionV1::Opening(admission) => admission, + ProjectRuntimeOwnerAdmissionV1::Existing => return Ok(database), + }; + admission.publish_memory(MemoryStoreOwnerV1 { + database: owner.weak_lease_issuer(), + graph: Arc::new(std::sync::Mutex::new( + MemoryGraphAttachmentStateV1::Detached { + database: owner, + error: "test read-only memory owner does not attach a writer graph".to_owned(), + }, + )), + graph_open_task_key: format!("{shard_id:?}"), + })?; + Ok(database) + } + + #[cfg(test)] + pub(crate) fn project_has_ready_memory_owner(&self, project_id: &ProjectId) -> bool { + let mounted = self + .project_owners + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + matches!( + mounted.get(project_id), + Some(ProjectRuntimeOwnerStateV1::Ready(owners)) if owners.memory.is_some() + ) + } +} + +fn issue_mounted_memory_lease( + owner: &MemoryStoreOwnerV1, + access: DatabaseAccessMode, + refuse_nonwritable_write: bool, +) -> Result { + let database = match access { + DatabaseAccessMode::ReadOnly => owner.issue_database_read_only_lease(), + DatabaseAccessMode::ReadWrite => owner.issue_database_lease(), + } + .map_err(|error| { + session_registry_error( + "issue mounted project memory database client", + error.to_string(), + ) + })?; + if refuse_nonwritable_write + && matches!(access, DatabaseAccessMode::ReadWrite) + && !database.is_writable() + { + return Err(session_registry_error( + "issue mounted project memory database client", + "project memory owner is read-only".to_string(), + )); + } + Ok(database) } diff --git a/crates/tracedecay-store-runtime/src/session_registry/verified_graph_runtime_port_contract_tests.rs b/crates/tracedecay-store-runtime/src/session_registry/verified_graph_runtime_port_contract_tests.rs index 3635bb3537..a5942feec8 100644 --- a/crates/tracedecay-store-runtime/src/session_registry/verified_graph_runtime_port_contract_tests.rs +++ b/crates/tracedecay-store-runtime/src/session_registry/verified_graph_runtime_port_contract_tests.rs @@ -23,6 +23,7 @@ use super::DaemonSessionRuntimeRegistryV1; use super::code_graph::inline_graph_publication_input_digest; use tracedecay_daemon_identity::profile_identity; use tracedecay_global_db::{RegisteredGlobalDbLeaseV1, VerifiedGraphRuntimePortV1}; +use tracedecay_runtime_core::db::DatabaseAccessMode; mod concurrency; mod mount_scope; @@ -96,6 +97,23 @@ impl ContractFixture { } } +#[tokio::test] +async fn mounted_project_memory_read_issues_a_read_only_lease() { + let fixture = ContractFixture::new("mounted-memory-read").await; + let project_id = project_id("mounted-memory-read"); + let (_database, _sessions) = fixture.mount_project(&project_id).await; + let read = fixture + .registry + .mounted_project_memory(&project_id, DatabaseAccessMode::ReadOnly) + .expect("read lease"); + assert!(!read.is_writable()); + let write = fixture + .registry + .mounted_project_memory(&project_id, DatabaseAccessMode::ReadWrite) + .expect("write lease"); + assert!(write.is_writable()); +} + fn project_id(label: &str) -> ProjectId { ProjectId::new(format!("project.graph-port-contract.{label}")).expect("project id") } @@ -431,6 +449,74 @@ async fn read_only_memory_database_rejects_a_writer_graph_runtime() { assert_eq!(retained.verified_head(), initial.verified_head()); } +#[tokio::test] +async fn read_only_open_does_not_publish_a_project_memory_owner() { + let temp = TempDir::new().expect("read-only owner isolation root"); + let profile_root = temp.path().join("profile"); + let identity = profile_identity::load_or_create(&profile_root).expect("profile identity"); + let project_id = project_id("ro-open-no-publish"); + let project_root = temp.path().join("served"); + std::fs::create_dir_all(&project_root).expect("project root"); + tracedecay_runtime_core::storage::pin_fixture_repository_identity( + &project_root, + project_id.as_str(), + ) + .expect("project enrollment"); + + let seed_scope = + tracedecay_runtime_core::db::enter_daemon_database_scope(&profile_root, 41, "ro-seed") + .expect("seed database scope"); + let seed = DaemonSessionRuntimeRegistryV1::open(identity.clone()) + .await + .expect("seed registry"); + let _seeded = seed + .project_memory(project_id.clone(), [project_root.clone()]) + .await + .expect("seed writable memory"); + seed.shutdown_memory_graph_reconciliation_tasks() + .await + .expect("join seed graph reconciliation"); + drop((seed, seed_scope)); + + let reopen_scope = + tracedecay_runtime_core::db::enter_daemon_database_scope(&profile_root, 42, "ro-reopen") + .expect("reopened database scope"); + let reopened = DaemonSessionRuntimeRegistryV1::open(identity.clone()) + .await + .expect("reopened registry"); + let read_only = reopened + .project_memory_read_only(project_id.clone(), [project_root.clone()]) + .await + .expect("cross-project read-only open"); + assert!(!read_only.is_writable()); + assert!( + !reopened.project_has_ready_memory_owner(&project_id), + "read-only open must not publish a registry owner" + ); + assert!( + reopened + .mounted_project_memory(&project_id, DatabaseAccessMode::ReadOnly) + .is_err(), + "read-only open must not install a mounted memory owner" + ); + drop((read_only, reopened, reopen_scope)); + + let _later_scope = + tracedecay_runtime_core::db::enter_daemon_database_scope(&profile_root, 43, "ro-later") + .expect("later database scope"); + let later = DaemonSessionRuntimeRegistryV1::open(identity) + .await + .expect("later registry"); + let writable = later + .project_memory(project_id, [project_root]) + .await + .expect("subsequent writable mount"); + assert!( + writable.is_writable(), + "project_memory must not inherit a read-only owner from a prior read-only open" + ); +} + #[tokio::test] async fn bound_verified_port_does_not_retain_the_database_facade() { let fixture = ContractFixture::new("no-database-cycle").await; diff --git a/crates/tracedecay/src/daemon/project_open_owners.rs b/crates/tracedecay/src/daemon/project_open_owners.rs index 2a71b19ad1..f8cc5bd0f2 100644 --- a/crates/tracedecay/src/daemon/project_open_owners.rs +++ b/crates/tracedecay/src/daemon/project_open_owners.rs @@ -400,13 +400,11 @@ pub(super) async fn register_project_open_production_owners( message: format!("project-open retained grant is invalid: {error}"), } })?; - let retained_ports = server - .retained_surface_ports( - project_root, - scope.project_id.clone(), - access.configuration_digest.clone(), - ) - .await; + let retained_ports = server.retained_surface_ports( + project_root, + scope.project_id.clone(), + access.configuration_digest.clone(), + ); hotpath::future!( invocation.retained_runtime_registrar().register( project_root.to_path_buf(), diff --git a/crates/tracedecay/src/daemon/projectless.rs b/crates/tracedecay/src/daemon/projectless.rs index e8e4357c97..69f09364c7 100644 --- a/crates/tracedecay/src/daemon/projectless.rs +++ b/crates/tracedecay/src/daemon/projectless.rs @@ -34,7 +34,7 @@ where /// Request grants are issued only after the adapter supplies exact controls. struct ProjectlessConnectionStateV1 { client_identity: DaemonClientIdentity, - profile_authority: crate::daemon::retained_owner::ProfileRetainedConnectionAuthorityV1, + profile_authority: tracedecay_session_runtime::retained::ProfileRetainedConnectionAuthorityV1, } /// Two profile roots name the same profile when they resolve to the same @@ -87,7 +87,7 @@ fn admit_projectless_connection( profile_identity.profile_id().clone(), ); let serving_db = user_sessions_db_path(&pinned_profile_root); - let serving = crate::daemon::retained_owner::profile_session_retrieval_serving_identity( + let serving = tracedecay_session_runtime::retained::profile_session_retrieval_serving_identity( profile_identity, &shard, &serving_db, @@ -99,10 +99,11 @@ fn admit_projectless_connection( DaemonSessionRetrievalRoot::profile(serving).ok_or_else(|| TraceDecayError::Config { message: "projectless profile session authority is unavailable".to_owned(), })?; - let profile_authority = crate::daemon::retained_owner::profile_retained_connection_authority( - profile_identity, - profile_session_root.identity(), - )?; + let profile_authority = + tracedecay_session_runtime::retained::profile_retained_connection_authority( + profile_identity, + profile_session_root.identity(), + )?; Ok(ProjectlessConnectionStateV1 { client_identity: DaemonClientIdentity::new( pinned_profile_root.clone(), @@ -556,7 +557,7 @@ async fn projectless_profile_retained_response( &connection.profile_authority, None, session_refresh.as_deref().map(|service| { - service as &dyn crate::daemon::retained_owner::RetainedSessionRefreshPortV1 + service as &dyn tracedecay_session_runtime::retained::RetainedSessionRefreshPortV1 }), None, None, diff --git a/crates/tracedecay/src/daemon/retained_owner.rs b/crates/tracedecay/src/daemon/retained_owner.rs index 83f0d1bed0..c170b7e5a8 100644 --- a/crates/tracedecay/src/daemon/retained_owner.rs +++ b/crates/tracedecay/src/daemon/retained_owner.rs @@ -9,11 +9,16 @@ use tracedecay_contracts::retained_surfaces::{ FactStoreCurateRequestV1, MemoryScopeV1, RetainedAutomationExecutionPortV1, RetainedProjectSelectorV1, RetainedSurfaceExecutionContextV1, RetainedSurfaceExecutionFutureV1, }; -use tracedecay_contracts::{RetainedSurfaceExecutionErrorV1, RetainedSurfacePortsV1}; +use tracedecay_contracts::{ + RetainedMemoryExecutionPortV1, RetainedSurfaceExecutionErrorV1, RetainedSurfacePortsV1, +}; use tracedecay_daemon_service::DaemonInvocationService; -use tracedecay_domain::{ManifestDigest, ProjectId}; +use tracedecay_domain::{FactOwnerV1, ManifestDigest, ProjectId}; +use tracedecay_session_runtime::retained::{ + ProjectRetainedSessionAuthoritiesV1, RetainedSessionRefreshPortV1, map_execution_error, +}; use tracedecay_store_runtime::retained_memory::{ - RetainedMemoryTargetAuthorityV1, RetainedMemoryTargetV1, + MemoryTargetAccessV1, RetainedMemoryTargetAuthorityV1, RetainedMemoryTargetV1, }; use crate::tracedecay::TraceDecay; @@ -25,22 +30,11 @@ mod profile_refresh_journeys; #[cfg(test)] mod session_retained_effect_tests; -pub(crate) use tracedecay_session_runtime::retained::{ - ProfileRetainedAuthoritiesV1, ProfileRetainedConnectionAuthorityV1, - ProjectRetainedSessionAuthoritiesV1, RetainedSessionRefreshPortV1, - execute_profile_retained_application, profile_retained_connection_authority, - profile_session_retrieval_serving_identity, -}; -pub(crate) use tracedecay_store_runtime::retained_memory::MemoryTargetAccessV1; - /// Exact authorities used by independently mounted project retained families. /// A missing session or LCM authority cannot prevent memory from registering. #[derive(Clone)] pub(crate) struct ProductionRetainedAuthoritiesV1 { pub(crate) cg: Arc>>, - pub(crate) store_runtime_registry: - Arc, - pub(crate) profile_database: tracedecay_global_db::RegisteredGlobalDbLeaseV1, pub(crate) project_root: PathBuf, pub(crate) project_id: tracedecay_domain::ProjectId, pub(crate) mounted_profile_id: Option, @@ -58,21 +52,79 @@ pub(crate) struct ProductionRetainedAuthoritiesV1 { pub(crate) invocation_service: Option, } +fn served_store_identity( + cg: &TraceDecay, +) -> Result<(PathBuf, ProjectId, bool), RetainedSurfaceExecutionErrorV1> { + match cg.project_memory_owner() { + Ok(FactOwnerV1::Project { project_id }) => Ok(( + cg.project_root().to_path_buf(), + project_id, + cg.is_read_only(), + )), + Ok(FactOwnerV1::Profile) => Err(RetainedSurfaceExecutionErrorV1::NotFoundOrNotAuthorized), + Err(error) => Err(map_execution_error(error)), + } +} + +pub(crate) async fn live_retained_memory_authority( + cg: &tokio::sync::RwLock>, + mounted_project_id: &ProjectId, + mounted_project_root: &Path, +) -> Result { + let graph = cg.read().await; + let (served_project_root, store_layout_project_id, graph_read_only) = + served_store_identity(graph.as_ref())?; + Ok(RetainedMemoryTargetAuthorityV1 { + registry: graph.retained_store_runtime_registry(), + profile_database: graph.profile_database().clone(), + project_root: mounted_project_root.to_path_buf(), + project_id: mounted_project_id.clone(), + store_layout_project_id, + served_project_root, + graph_read_only, + }) +} + +struct AssembledRetainedMemory { + cg: Arc>>, + mounted_project_id: ProjectId, + mounted_project_root: PathBuf, + configuration_digest: ManifestDigest, +} + +impl RetainedMemoryExecutionPortV1 for AssembledRetainedMemory { + fn execute_memory<'a>( + &'a self, + context: RetainedSurfaceExecutionContextV1<'a>, + request: tracedecay_contracts::RetainedMemoryRequestV1<'a>, + ) -> RetainedSurfaceExecutionFutureV1<'a> { + Box::pin(async move { + let authority = live_retained_memory_authority( + self.cg.as_ref(), + &self.mounted_project_id, + &self.mounted_project_root, + ) + .await?; + tracedecay_store_runtime::retained_memory::DirectRetainedMemoryPortV1::project( + authority, + self.configuration_digest.clone(), + ) + .execute_request(context, request) + .await + }) + } +} + pub(crate) fn retained_surface_ports( authorities: ProductionRetainedAuthoritiesV1, ) -> Arc> { - let memory_authority = RetainedMemoryTargetAuthorityV1 { - registry: authorities.store_runtime_registry, - profile_database: authorities.profile_database, - project_root: authorities.project_root.clone(), - project_id: authorities.project_id.clone(), - }; - let mut ports = RetainedSurfacePortsV1::default().with_memory(Arc::new( - tracedecay_store_runtime::retained_memory::DirectRetainedMemoryPortV1::project( - memory_authority, - authorities.configuration_digest.clone(), - ), - )); + let mut ports = RetainedSurfacePortsV1::default(); + ports = ports.with_memory(Arc::new(AssembledRetainedMemory { + cg: Arc::clone(&authorities.cg), + mounted_project_id: authorities.project_id.clone(), + mounted_project_root: authorities.project_root.clone(), + configuration_digest: authorities.configuration_digest.clone(), + })); if let Some(invocation_service) = authorities.invocation_service.clone() { ports = ports.with_automation(Arc::new(AssembledRetainedAutomation { cg: Arc::clone(&authorities.cg), @@ -125,9 +177,11 @@ pub(crate) fn retained_surface_ports( Arc::new(ports) } -/// Root-selected automation runtime. The curator still consumes `TraceDecay` -/// inside `dashboard_automation` (not this lane); this assembler only passes -/// the already-selected lock and invocation service upward. +/// Single `RetainedAutomationExecutionPortV1` impl at the composition root. +/// The curator still requires the selected `TraceDecay` lock inside +/// `dashboard_automation`; this type forwards that already-selected runtime +/// and the invocation service. It is not a compatibility rename of +/// `DirectRetainedAutomationPortV1`. struct AssembledRetainedAutomation { cg: Arc>>, invocation_service: DaemonInvocationService, @@ -163,11 +217,16 @@ pub(crate) async fn open_project_retained_memory_target( selector: Option<&RetainedProjectSelectorV1>, access: MemoryTargetAccessV1, ) -> Result, RetainedSurfaceExecutionErrorV1> { + let (served_project_root, store_layout_project_id, graph_read_only) = + served_store_identity(cg)?; let authority = RetainedMemoryTargetAuthorityV1 { registry: cg.retained_store_runtime_registry(), profile_database: cg.profile_database().clone(), project_root: cg.project_root().to_path_buf(), project_id: admitted_project_id.clone(), + store_layout_project_id, + served_project_root, + graph_read_only, }; tracedecay_store_runtime::retained_memory::open_project_retained_memory_target( &authority, diff --git a/crates/tracedecay/src/daemon/retained_owner/memory_target_journeys.rs b/crates/tracedecay/src/daemon/retained_owner/memory_target_journeys.rs index ea7dff50e6..3b5d5b17ee 100644 --- a/crates/tracedecay/src/daemon/retained_owner/memory_target_journeys.rs +++ b/crates/tracedecay/src/daemon/retained_owner/memory_target_journeys.rs @@ -5,7 +5,9 @@ use tracedecay_contracts::RetainedSurfaceExecutionErrorV1; use tracedecay_contracts::retained_surfaces::{MemoryScopeV1, RetainedProjectSelectorV1}; use tracedecay_domain::{FactOwnerV1, ProjectId}; use tracedecay_store::StoreShardScopeV1; -use tracedecay_store_runtime::retained_memory::MemoryTargetAccessV1; +use tracedecay_store_runtime::retained_memory::{ + MemoryTargetAccessV1, RetainedMemoryTargetAuthorityV1, +}; use crate::daemon::retained_owner::open_project_retained_memory_target; use crate::tracedecay::TraceDecay; @@ -101,7 +103,7 @@ async fn selected_project_opens_its_exact_read_only_store_not_the_active_store() .await .unwrap(); - assert!(active_target.database().is_writable()); + assert!(!active_target.database().is_writable()); assert!(!selected_target.database().is_writable()); assert_eq!( selected_target.owner(), @@ -153,3 +155,68 @@ async fn missing_unenrolled_and_write_selected_targets_share_one_denial() { )); } } + +#[tokio::test] +async fn assembled_memory_authority_tracks_swapped_graph_identity() { + let (_tmp, active, selected, _sibling) = project_pair().await; + let active_id = project_id(&active); + let active_root = active.project_root().to_path_buf(); + let selected_root = selected.project_root().to_path_buf(); + let lock = Arc::new(tokio::sync::RwLock::new(Arc::new(active))); + let before = super::live_retained_memory_authority(&lock, &active_id, &active_root) + .await + .expect("active graph must resolve a served identity"); + assert_eq!(before.served_project_root, active_root); + *lock.write().await = Arc::new(selected); + let after = super::live_retained_memory_authority(&lock, &active_id, &active_root) + .await + .expect("swapped graph must resolve a served identity"); + assert_eq!(after.served_project_root, selected_root); + let error = tracedecay_store_runtime::retained_memory::open_project_retained_memory_target( + &after, + &active_root, + &active_id, + Some(MemoryScopeV1::Project), + None, + MemoryTargetAccessV1::Read, + ) + .await + .err() + .expect("open must deny against the swapped served identity"); + assert!(matches!( + error, + RetainedSurfaceExecutionErrorV1::NotFoundOrNotAuthorized + )); +} + +#[tokio::test] +async fn same_project_open_denies_when_store_identity_disagrees() { + let (_tmp, active, selected, _sibling) = project_pair().await; + let active_id = project_id(&active); + let selected_id = project_id(&selected); + let authority = RetainedMemoryTargetAuthorityV1 { + registry: active.retained_store_runtime_registry(), + profile_database: active.profile_database().clone(), + project_root: active.project_root().to_path_buf(), + project_id: active_id.clone(), + store_layout_project_id: selected_id, + served_project_root: active.project_root().to_path_buf(), + graph_read_only: false, + }; + + let error = tracedecay_store_runtime::retained_memory::open_project_retained_memory_target( + &authority, + active.project_root(), + &active_id, + Some(MemoryScopeV1::Project), + None, + MemoryTargetAccessV1::Read, + ) + .await + .err() + .expect("store identity drift must deny"); + assert!(matches!( + error, + RetainedSurfaceExecutionErrorV1::NotFoundOrNotAuthorized + )); +} diff --git a/crates/tracedecay/src/daemon/retained_test_support.rs b/crates/tracedecay/src/daemon/retained_test_support.rs index fe515de417..68d30b8bd2 100644 --- a/crates/tracedecay/src/daemon/retained_test_support.rs +++ b/crates/tracedecay/src/daemon/retained_test_support.rs @@ -176,13 +176,11 @@ pub(crate) async fn register_project_retained_owner_for_test( message: format!("retained test owner grant is invalid: {error}"), } })?; - let ports = server - .retained_surface_ports( - &project_root, - scope.project_id.clone(), - access.configuration_digest.clone(), - ) - .await; + let ports = server.retained_surface_ports( + &project_root, + scope.project_id.clone(), + access.configuration_digest.clone(), + ); DaemonRetainedRuntimeRegistrar::new(service) .register(project_root, scope, access.requester, grant, ports) .await diff --git a/crates/tracedecay/src/daemon/scheduler/combined_effect.rs b/crates/tracedecay/src/daemon/scheduler/combined_effect.rs index a88de9b36c..ea3dccc797 100644 --- a/crates/tracedecay/src/daemon/scheduler/combined_effect.rs +++ b/crates/tracedecay/src/daemon/scheduler/combined_effect.rs @@ -891,8 +891,6 @@ mod tests { let retained_ports = crate::daemon::retained_owner::retained_surface_ports( crate::daemon::retained_owner::ProductionRetainedAuthoritiesV1 { cg: Arc::new(tokio::sync::RwLock::new(Arc::clone(&memory))), - store_runtime_registry: memory.retained_store_runtime_registry(), - profile_database: memory.profile_database().clone(), project_root: project_root.clone(), project_id: project_id.clone(), mounted_profile_id: None, diff --git a/crates/tracedecay/src/mcp/server.rs b/crates/tracedecay/src/mcp/server.rs index b819e6252a..92ff4be1f7 100644 --- a/crates/tracedecay/src/mcp/server.rs +++ b/crates/tracedecay/src/mcp/server.rs @@ -342,7 +342,7 @@ pub struct McpServer { profile_root: Option, profile_identity: Option>, profile_retained_authority: - Option, + Option, accounting_db: Option, /// Registered project session store. Startup recovery, ingestion, /// retrieval, and host admission all borrow this one lease and never @@ -1039,7 +1039,7 @@ impl McpServer { .zip(profile_session_db.as_ref()) .and_then(|(profile, registered)| { let serving = - crate::daemon::retained_owner::profile_session_retrieval_serving_identity( + tracedecay_session_runtime::retained::profile_session_retrieval_serving_identity( profile, ®istered.binding().shard_id, registered.db_path(), @@ -1101,7 +1101,7 @@ impl McpServer { .zip(profile_session_retrieval_root.as_ref()) { Some((identity, root)) => { - match crate::daemon::retained_owner::profile_retained_connection_authority( + match tracedecay_session_runtime::retained::profile_retained_connection_authority( identity.as_ref(), root.identity(), ) { @@ -1397,8 +1397,8 @@ impl McpServer { } } - #[hotpath::measure(label = "mcp.server.mount_retained_surfaces", future = true)] - pub(crate) async fn retained_surface_ports( + #[hotpath::measure(label = "mcp.server.mount_retained_surfaces")] + pub(crate) fn retained_surface_ports( &self, project_root: &Path, project_id: tracedecay_domain::ProjectId, @@ -1408,12 +1408,9 @@ impl McpServer { Arc::new(DaemonWorkflowIndexReadService::new(database.clone())) as Arc }); - let graph = self.cg.read().await; crate::daemon::retained_owner::retained_surface_ports( crate::daemon::retained_owner::ProductionRetainedAuthoritiesV1 { cg: Arc::clone(&self.cg), - store_runtime_registry: graph.retained_store_runtime_registry(), - profile_database: graph.profile_database().clone(), project_root: project_root.to_path_buf(), project_id, configuration_digest, diff --git a/crates/tracedecay/src/mcp/tools/handlers/analytics.rs b/crates/tracedecay/src/mcp/tools/handlers/analytics.rs index 4982898329..9453c21cd9 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/analytics.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/analytics.rs @@ -23,7 +23,7 @@ use tracedecay_domain::{FactOwnerV1, ObservationScopeV1, ProjectId}; use tracedecay_session_memory::memory::MemoryApplication; use tracedecay_store::{FactReadControl, StoreShardScopeV1}; -use crate::daemon::retained_owner::{MemoryTargetAccessV1, open_project_retained_memory_target}; +use crate::daemon::retained_owner::open_project_retained_memory_target; use crate::tracedecay::TraceDecay; use crate::tracedecay::current_timestamp; use tracedecay_automation_runtime::automation::run_ledger::load_run_records; @@ -31,6 +31,7 @@ use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::{AnalyticsToolCounts, RegisteredGlobalDb}; use tracedecay_runtime_core::timeutil::parse_rfc3339_timestamp; use tracedecay_session_memory::fact_store::DatabaseFactStore; +use tracedecay_store_runtime::retained_memory::MemoryTargetAccessV1; use super::support::tool_json_with_md; use tracedecay_mcp::ToolResult; diff --git a/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs index d59fcb40ef..16864f596c 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs @@ -1727,7 +1727,7 @@ async fn user_lcm_doctor_reports_a_missing_store_without_opening_it() { .expect("profile root identity"), ); let profile_retained_authority = - crate::daemon::retained_owner::profile_retained_connection_authority( + tracedecay_session_runtime::retained::profile_retained_connection_authority( &profile_identity, &session_identity, ) @@ -1804,7 +1804,7 @@ async fn profile_scoped_session_refresh_dispatches_to_the_profile_authority() { .expect("profile root identity"), ); let profile_retained_authority = - crate::daemon::retained_owner::profile_retained_connection_authority( + tracedecay_session_runtime::retained::profile_retained_connection_authority( &profile_identity, &session_identity, ) @@ -1860,7 +1860,7 @@ async fn profile_scoped_session_refresh_dispatches_to_the_profile_authority() { .with_profile_retained_authority(Some(profile_retained_authority)) .with_profile_session_refresh(mounted.then_some( refresh - as &dyn crate::daemon::retained_owner::RetainedSessionRefreshPortV1, + as &dyn tracedecay_session_runtime::retained::RetainedSessionRefreshPortV1, )), ..Default::default() }, diff --git a/crates/tracedecay/src/mcp/tools/handlers/retained_catalog.rs b/crates/tracedecay/src/mcp/tools/handlers/retained_catalog.rs index e480ace792..e09045f3e5 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/retained_catalog.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/retained_catalog.rs @@ -186,9 +186,11 @@ pub(crate) async fn execute_profile_retained_mcp_tool( tool_name: &str, mut args: Value, runtime_registry: &tracedecay_store_runtime::DaemonSessionRuntimeRegistryV1, - authority: &crate::daemon::retained_owner::ProfileRetainedConnectionAuthorityV1, + authority: &tracedecay_session_runtime::retained::ProfileRetainedConnectionAuthorityV1, lcm_authority: Option<&dyn tracedecay_session_runtime::lcm_authority::MountedLcmAuthorityPort>, - session_refresh: Option<&dyn crate::daemon::retained_owner::RetainedSessionRefreshPortV1>, + session_refresh: Option< + &dyn tracedecay_session_runtime::retained::RetainedSessionRefreshPortV1, + >, protocol_request_id: Option, protocol_deadline: Option, protocol_cancellation: Option, @@ -238,8 +240,8 @@ pub(crate) async fn execute_profile_retained_mcp_tool( ) })?; let result = hotpath::future!( - crate::daemon::retained_owner::execute_profile_retained_application( - crate::daemon::retained_owner::ProfileRetainedAuthoritiesV1 { + tracedecay_session_runtime::retained::execute_profile_retained_application( + tracedecay_session_runtime::retained::ProfileRetainedAuthoritiesV1 { profile_sessions: Some(Arc::new(|| Box::pin(runtime_registry.profile_sessions()))), session_identity: authority.session_identity().clone(), configuration_digest: authority.configuration_digest().clone(), diff --git a/crates/tracedecay/src/mcp/tools/handlers/session_authorities.rs b/crates/tracedecay/src/mcp/tools/handlers/session_authorities.rs index 38b675e4a3..607accf9b9 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/session_authorities.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/session_authorities.rs @@ -21,7 +21,7 @@ pub struct SessionAuthorities<'a> { /// under; absent on direct servers, where capture fails closed. pub(crate) background_cpu: Option>, pub(crate) profile_retained_authority: - Option<&'a crate::daemon::retained_owner::ProfileRetainedConnectionAuthorityV1>, + Option<&'a tracedecay_session_runtime::retained::ProfileRetainedConnectionAuthorityV1>, pub(crate) project_lcm: Option<&'a dyn tracedecay_session_runtime::lcm_authority::MountedLcmAuthorityPort>, pub(crate) profile_lcm: @@ -29,7 +29,7 @@ pub struct SessionAuthorities<'a> { /// Daemon-wide profile session refresh service serving profile-scoped /// `tracedecay_session_refresh_*` calls on this connection. pub(crate) profile_session_refresh: - Option<&'a dyn crate::daemon::retained_owner::RetainedSessionRefreshPortV1>, + Option<&'a dyn tracedecay_session_runtime::retained::RetainedSessionRefreshPortV1>, } impl<'a> SessionAuthorities<'a> { @@ -69,7 +69,9 @@ impl<'a> SessionAuthorities<'a> { #[hotpath::skip] pub(crate) const fn with_profile_retained_authority( mut self, - authority: Option<&'a crate::daemon::retained_owner::ProfileRetainedConnectionAuthorityV1>, + authority: Option< + &'a tracedecay_session_runtime::retained::ProfileRetainedConnectionAuthorityV1, + >, ) -> Self { self.profile_retained_authority = authority; self @@ -89,7 +91,7 @@ impl<'a> SessionAuthorities<'a> { #[hotpath::skip] pub(crate) const fn with_profile_session_refresh( mut self, - refresh: Option<&'a dyn crate::daemon::retained_owner::RetainedSessionRefreshPortV1>, + refresh: Option<&'a dyn tracedecay_session_runtime::retained::RetainedSessionRefreshPortV1>, ) -> Self { self.profile_session_refresh = refresh; self diff --git a/crates/tracedecay/tests/common/dashboard_configuration_test_runtime.rs b/crates/tracedecay/tests/common/dashboard_configuration_test_runtime.rs index 3984793325..3f2eb68eb8 100644 --- a/crates/tracedecay/tests/common/dashboard_configuration_test_runtime.rs +++ b/crates/tracedecay/tests/common/dashboard_configuration_test_runtime.rs @@ -416,8 +416,6 @@ pub(crate) async fn register_dashboard_test_retained_runtime( let retained_ports = super::retained_owner::retained_surface_ports( super::retained_owner::ProductionRetainedAuthoritiesV1 { cg: Arc::new(tokio::sync::RwLock::new(Arc::clone(cg))), - store_runtime_registry: cg.retained_store_runtime_registry(), - profile_database: cg.profile_database().clone(), project_root: project_root.clone(), project_id, mounted_profile_id: None, diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_facts_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_facts_test.rs index 99d88edf72..ffeebcd059 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_facts_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/memory_facts_test.rs @@ -978,7 +978,29 @@ async fn memory_recall_updates_retrieval_count() { .as_u64() .unwrap_or_default() > 0, - "returned facts should increment retrieval_count: {status}" + "writable-graph search must increment retrieval_count: {status}" + ); + assert!( + fact["telemetry"]["last_retrieved_at"].as_i64().is_some(), + "writable-graph search must stamp last_retrieved_at: {status}" + ); + + let memory_status = invoke_production_tool(&cg, "tracedecay_memory_status", json!({})) + .await + .unwrap(); + assert!( + memory_status["memory"]["feedback_funnel"]["retrieval_count_total"] + .as_u64() + .unwrap_or_default() + > 0, + "memory_status retrieval funnel must move after search: {memory_status}" + ); + assert!( + memory_status["memory"]["feedback_funnel"]["retrieved_fact_count"] + .as_u64() + .unwrap_or_default() + > 0, + "memory_status must count the retrieved fact: {memory_status}" ); close_test_graph(cg).await; } diff --git a/docs/TRUST-DECAY-SEMANTICS.md b/docs/TRUST-DECAY-SEMANTICS.md index 4b9c3758c8..ee596aa232 100644 --- a/docs/TRUST-DECAY-SEMANTICS.md +++ b/docs/TRUST-DECAY-SEMANTICS.md @@ -52,7 +52,7 @@ by `(fact_id, owner_kind, project_id)`: |---|---|---| | `trust_score` | persisted per-fact trust, `CHECK`-constrained to `[0, 1]` (or `NULL`) | replayed from lineage events on every commit (see §2) | | `updated_at` | last mutation time — the input to the ranking decay factor | every commit that touches this fact (create, edit, feedback, curation) | -| `retrieval_count` / `access_count` | scan count vs. returned-count | `project_memory_update_retrieval_projection_tx`, on every search/probe/reason/related hit | +| `retrieval_count` / `access_count` | scan count vs. returned-count | `project_memory_update_retrieval_projection_tx`, on search hits via `track_explicit_search` | | `last_retrieved_at` / `last_recalled_at` | last scan / last time a search actually returned the fact | the same retrieval-projection update | | `helpful_count` / `unhelpful_count` / `last_feedback_at` | feedback tallies | `project_memory_update_feedback_projection_tx`, on every feedback event |