From ef69fa6063b4fe200d1e2ee3f4129504b2456465 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 04:41:02 +0000 Subject: [PATCH 01/12] feat(multi-root): mount named collection resolver for the dashboard The dashboard resolves a named multi-root collection (a persisted scope set with a frozen revision and canonical member order) through the daemon application transport: GET /api/multi-root/collection resolves explicit targets and /api/capabilities reports the resolver's typed states instead of a hardcoded unmounted string. Selection precedence lives in the application resolver, where a default collection can never outrank an explicit target. Co-authored-by: Zack Jackson --- crates/tracedecay-application/src/lib.rs | 3 +- .../tracedecay-application/src/multi_root.rs | 5 + .../src/multi_root/collection.rs | 266 ++++++++++++++++++ .../src/application_surface.rs | 36 ++- crates/tracedecay-dashboard-api/src/lib.rs | 218 ++++++++++++-- .../src/multi_root_api.rs | 124 ++++++++ .../dashboard_configuration_test_runtime.rs | 19 +- src/mcp/tools/handlers/dashboard.rs | 63 ++++- 8 files changed, 712 insertions(+), 22 deletions(-) create mode 100644 crates/tracedecay-application/src/multi_root/collection.rs create mode 100644 crates/tracedecay-dashboard-api/src/multi_root_api.rs diff --git a/crates/tracedecay-application/src/lib.rs b/crates/tracedecay-application/src/lib.rs index a8b8228ec6..b5da0dde97 100644 --- a/crates/tracedecay-application/src/lib.rs +++ b/crates/tracedecay-application/src/lib.rs @@ -221,7 +221,8 @@ pub use lsp_context_catalog::{lsp_context_catalog_contribution, lsp_context_hand pub use mcp_catalog::mcp_executable_binding_registry; pub use multi_root::{ AuthorizedMultiRootQueryService, AuthorizedRoot, AuthorizedRootAdmission, AuthorizedScopeSet, - AuthorizedScopeSetAuthority, AuthorizedScopeSetError, MultiRootContinuationV1, + AuthorizedScopeSetAuthority, AuthorizedScopeSetError, MultiRootCollectionResolutionV1, + MultiRootCollectionSelectorV1, MultiRootCollectionUnavailableV1, MultiRootContinuationV1, MultiRootExecuteRequestV1, MultiRootOperationV1, MultiRootQueryError, MultiRootQueryPageV1, MultiRootQueryPort, MultiRootQueryRequestV1, MultiRootScopeSetCasRequestV1, MultiRootScopeSetCasResultV1, MultiRootScopeSetCasStatusV1, MultiRootScopeSetReadRequestV1, diff --git a/crates/tracedecay-application/src/multi_root.rs b/crates/tracedecay-application/src/multi_root.rs index 17ac52ed9f..09d51e5481 100644 --- a/crates/tracedecay-application/src/multi_root.rs +++ b/crates/tracedecay-application/src/multi_root.rs @@ -15,12 +15,17 @@ use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; use crate::{RequestAdmission, RequestContext}; pub mod catalog; +mod collection; mod locator; pub use catalog::{ MultiRootApplicationOperation, multi_root_capability_manifest, multi_root_executable_binding_registry, multi_root_operation_authority, }; +pub use collection::{ + MultiRootCollectionResolutionV1, MultiRootCollectionSelectorV1, + MultiRootCollectionUnavailableV1, +}; pub use locator::{ AuthorizedRoot, AuthorizedRootAdmission, RegisteredRootLocatorV1, RegisteredRootSelectorV1, SharedProfileStoreLocatorV1, diff --git a/crates/tracedecay-application/src/multi_root/collection.rs b/crates/tracedecay-application/src/multi_root/collection.rs new file mode 100644 index 0000000000..6b60c1ad50 --- /dev/null +++ b/crates/tracedecay-application/src/multi_root/collection.rs @@ -0,0 +1,266 @@ +//! Named multi-root collection resolution for read surfaces. +//! +//! A named collection is a persisted [`AuthorizedScopeSet`]: its revision is +//! frozen at compare-and-swap time and its members are canonically ordered by +//! the scope-set authority. This module owns only target selection precedence +//! and read mapping — it never resolves paths, reads storage, or widens +//! authority. + +use tracedecay_domain::ScopeSetId; + +use super::{AuthorizedScopeSet, MultiRootQueryError}; + +/// Selects the collection a read surface must resolve. +/// +/// The default collection never outranks an explicit target: when both are +/// present the explicit target wins unconditionally, and the default is only +/// consulted when the caller named nothing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MultiRootCollectionSelectorV1 { + explicit_target: Option, + default_collection: Option, +} + +impl MultiRootCollectionSelectorV1 { + pub fn new( + explicit_target: Option, + default_collection: Option, + ) -> Result { + for collection in explicit_target.iter().chain(default_collection.iter()) { + collection + .validate() + .map_err(|error| MultiRootQueryError::Invalid(error.to_string()))?; + } + Ok(Self { + explicit_target, + default_collection, + }) + } + + pub fn target(&self) -> Option<&ScopeSetId> { + self.explicit_target + .as_ref() + .or(self.default_collection.as_ref()) + } +} + +/// Typed unavailable states for named-collection resolution. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MultiRootCollectionUnavailableV1 { + /// The read surface has no daemon application transport at all. + TransportNotAdmitted, + /// No explicit target was named and no default collection is configured. + NoCollectionNamed, + /// The named collection has no persisted scope set for this project. + CollectionNotPersisted { scope_set_id: ScopeSetId }, + /// The transport answered, but not with a usable persisted scope set. + AuthorityUnavailable { detail: String }, +} + +impl MultiRootCollectionUnavailableV1 { + /// Human-readable reason carried on the wire capability projection. + pub fn reason(&self) -> String { + match self { + Self::TransportNotAdmitted => { + "the daemon application transport is not admitted for this dashboard".to_owned() + } + Self::NoCollectionNamed => { + "no default multi-root collection is configured; name an explicit collection" + .to_owned() + } + Self::CollectionNotPersisted { scope_set_id } => format!( + "multi-root collection {} names no persisted scope set for this project", + scope_set_id.as_str() + ), + Self::AuthorityUnavailable { detail } => { + format!("the multi-root collection authority is unavailable: {detail}") + } + } + } +} + +/// Resolution of one named multi-root collection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MultiRootCollectionResolutionV1 { + /// The frozen scope-set revision with canonical member order. + Mounted { scope_set: AuthorizedScopeSet }, + Unavailable { + reason: MultiRootCollectionUnavailableV1, + }, +} + +impl MultiRootCollectionResolutionV1 { + /// Maps one persisted scope-set read for `target`. + /// + /// A mounted answer is revalidated so it always carries the frozen + /// revision, canonical member order, and matching digest; a persisted row + /// answering for a different collection id is an authority fault, not a + /// silent alias. + pub fn from_persisted_read(target: &ScopeSetId, read: Option) -> Self { + let Some(scope_set) = read else { + return Self::Unavailable { + reason: MultiRootCollectionUnavailableV1::CollectionNotPersisted { + scope_set_id: target.clone(), + }, + }; + }; + if scope_set.scope_set_id() != target { + return Self::Unavailable { + reason: MultiRootCollectionUnavailableV1::AuthorityUnavailable { + detail: format!( + "persisted scope set {} does not answer for collection {}", + scope_set.scope_set_id().as_str(), + target.as_str() + ), + }, + }; + } + if let Err(error) = scope_set.validate() { + return Self::Unavailable { + reason: MultiRootCollectionUnavailableV1::AuthorityUnavailable { + detail: error.to_string(), + }, + }; + } + Self::Mounted { scope_set } + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use tracedecay_domain::{ + ActorId, ManifestDigest, ProjectId, RefId, RepositoryId, ScopeSetId, ScopeSetRevision, + UtcMicros, WorktreeId, + }; + use tracedecay_tool_catalog::{CapabilityId, UseCaseId}; + + use super::{ + MultiRootCollectionResolutionV1, MultiRootCollectionSelectorV1, + MultiRootCollectionUnavailableV1, + }; + use crate::multi_root::{AuthorizedScopeSet, AuthorizedScopeSetAuthority}; + use crate::{ + CancellationContext, CapabilityGrantSnapshot, Deadline, DisclosureClass, RequestContext, + RequestId, ResolvedScope, + }; + + const CAPABILITY: &str = "capability.multi-root.query"; + const USE_CASE: &str = "use-case.multi-root.query"; + + fn collection(name: &str) -> ScopeSetId { + ScopeSetId::new(name).expect("collection id") + } + + fn persisted_scope_set(name: &str) -> AuthorizedScopeSet { + let scope = ResolvedScope::new( + ProjectId::new("project.fixture").expect("project"), + RepositoryId::new("repository.fixture").expect("repository"), + WorktreeId::new("worktree.main").expect("worktree"), + Some(RefId::new("refs/heads/main").expect("reference")), + ) + .expect("scope"); + let grant = CapabilityGrantSnapshot::new( + "grant.collection".to_owned().try_into().expect("grant id"), + 1, + ManifestDigest::new(format!("sha256:{}", "a".repeat(64))).expect("digest"), + ActorId::new("actor.issuer").expect("issuer"), + UtcMicros(1), + UtcMicros(1_000), + scope.clone(), + BTreeSet::from([CapabilityId::new(CAPABILITY).expect("capability")]), + BTreeSet::from([UseCaseId::new(USE_CASE).expect("use case")]), + DisclosureClass::Evidence, + ) + .expect("grant"); + let context = RequestContext::new( + ActorId::new("actor.requester").expect("actor"), + scope, + grant, + RequestId::new("request.collection").expect("request id"), + Deadline::new(UtcMicros(900)).expect("deadline"), + CancellationContext::active("cancel.collection").expect("cancellation"), + ) + .expect("context"); + AuthorizedScopeSetAuthority::authorize( + collection(name), + ScopeSetRevision::new(1).expect("revision"), + vec![context], + &CapabilityId::new(CAPABILITY).expect("capability"), + &UseCaseId::new(USE_CASE).expect("use case"), + UtcMicros(10), + ) + .expect("authorized scope set") + } + + #[test] + fn explicit_target_always_outranks_the_default_collection() { + let selector = MultiRootCollectionSelectorV1::new( + Some(collection("scope-set.explicit")), + Some(collection("scope-set.default")), + ) + .expect("selector"); + assert_eq!(selector.target(), Some(&collection("scope-set.explicit"))); + } + + #[test] + fn default_collection_answers_only_when_nothing_explicit_is_named() { + let with_default = + MultiRootCollectionSelectorV1::new(None, Some(collection("scope-set.default"))) + .expect("selector"); + assert_eq!( + with_default.target(), + Some(&collection("scope-set.default")) + ); + + let unnamed = MultiRootCollectionSelectorV1::new(None, None).expect("selector"); + assert_eq!(unnamed.target(), None); + } + + #[test] + fn persisted_read_mounts_the_frozen_revision_and_canonical_members() { + let scope_set = persisted_scope_set("scope-set.collection"); + let resolution = MultiRootCollectionResolutionV1::from_persisted_read( + &collection("scope-set.collection"), + Some(scope_set.clone()), + ); + assert_eq!( + resolution, + MultiRootCollectionResolutionV1::Mounted { scope_set } + ); + } + + #[test] + fn missing_persisted_scope_set_is_typed_not_persisted() { + let resolution = MultiRootCollectionResolutionV1::from_persisted_read( + &collection("scope-set.missing"), + None, + ); + assert_eq!( + resolution, + MultiRootCollectionResolutionV1::Unavailable { + reason: MultiRootCollectionUnavailableV1::CollectionNotPersisted { + scope_set_id: collection("scope-set.missing"), + }, + } + ); + } + + #[test] + fn a_scope_set_answering_for_another_collection_is_an_authority_fault() { + let scope_set = persisted_scope_set("scope-set.other"); + let resolution = MultiRootCollectionResolutionV1::from_persisted_read( + &collection("scope-set.requested"), + Some(scope_set), + ); + let MultiRootCollectionResolutionV1::Unavailable { + reason: MultiRootCollectionUnavailableV1::AuthorityUnavailable { detail }, + } = resolution + else { + panic!("mismatched collection identity must not mount"); + }; + assert!(detail.contains("scope-set.other")); + assert!(detail.contains("scope-set.requested")); + } +} diff --git a/crates/tracedecay-dashboard-api/src/application_surface.rs b/crates/tracedecay-dashboard-api/src/application_surface.rs index 7d77246115..72e78021dd 100644 --- a/crates/tracedecay-dashboard-api/src/application_surface.rs +++ b/crates/tracedecay-dashboard-api/src/application_surface.rs @@ -9,14 +9,17 @@ use axum::http::StatusCode; use serde_json::Value; use serde_json::json; use tracedecay_application::{ - ApplicationContractError, ApplicationOutcome, ApplicationProblemEnvelope, RequestId, + ApplicationContractError, ApplicationOutcome, ApplicationProblemEnvelope, AuthorizedScopeSet, + RequestId, }; -use tracedecay_domain::ProjectId; use tracedecay_domain::configuration::{ ConfigurationIdempotencyKey, ConfigurationRevisionId, UserProfileId, }; +use tracedecay_domain::{ProjectId, ScopeSetId}; use tracedecay_usecases::configuration::DirectConfigurationMutation; +use crate::DashboardHttpRequestControlV1; + pub struct DashboardApplicationRouters { pub http: Router, pub configuration: Router, @@ -71,6 +74,25 @@ pub(crate) fn configuration_apply_error( } } +pub type DashboardScopeSetReadFuture<'a> = Pin< + Box< + dyn Future< + Output = std::result::Result< + Option, + DashboardScopeSetReadUnavailableV1, + >, + > + Send + + 'a, + >, +>; + +/// The daemon transport could not answer a persisted scope-set read. The +/// detail is a safe diagnostic, never store paths or payload content. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DashboardScopeSetReadUnavailableV1 { + pub detail: String, +} + pub trait DashboardApplicationRuntime: Send + Sync { /// Exact profile bound by the daemon handshake. A dashboard mounted /// without that identity cannot advertise or dispatch profile writes. @@ -88,6 +110,16 @@ pub trait DashboardApplicationRuntime: Send + Sync { expected_revision: ConfigurationRevisionId, idempotency_key: ConfigurationIdempotencyKey, ) -> DashboardConfigurationApplyFuture<'a>; + + /// Reads one persisted multi-root scope set (a named collection) through + /// the daemon transport under the live request controls. Read-only: the + /// daemon answers only the exact collection identity it was asked for and + /// never resolves paths or widens authority here. + fn read_multi_root_scope_set<'a>( + &'a self, + control: DashboardHttpRequestControlV1, + scope_set_id: ScopeSetId, + ) -> DashboardScopeSetReadFuture<'a>; } #[cfg(test)] diff --git a/crates/tracedecay-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index 4d91f14a58..b66183c22b 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -21,7 +21,8 @@ pub mod tracedecay; // the dashboard-facing project runtime trait. pub use application_surface::{ DashboardApplicationRouters, DashboardApplicationRuntime, DashboardConfigurationApplyError, - DashboardConfigurationApplyFuture, + DashboardConfigurationApplyFuture, DashboardScopeSetReadFuture, + DashboardScopeSetReadUnavailableV1, }; pub use tracedecay::DashboardProjectRuntime; @@ -104,6 +105,7 @@ pub use loom_api::{ mod memory_analysis; mod memory_api; mod memory_service; +mod multi_root_api; pub mod project_graph; pub mod project_registry; mod projects; @@ -137,7 +139,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use axum::Router; use axum::body::Body; -use axum::extract::{Path as AxumPath, State}; +use axum::extract::{Extension, Path as AxumPath, State}; use axum::http::{HeaderMap, Method, Request, StatusCode, Uri, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Json, Response}; @@ -1274,6 +1276,7 @@ fn router_with_active_application( .route("/api/code-index/{*tail}", any(active_api_gateway)) .route("/api/remote/{*tail}", any(active_api_gateway)) .route("/api/feedback/status", any(active_api_gateway)) + .route("/api/multi-root/{*tail}", any(active_api_gateway)) .route("/api/events", any(active_api_gateway)) .route("/api/events/delivery-ack", any(active_api_gateway)) .with_state(runtime) @@ -1301,6 +1304,10 @@ fn router_with_active_application( fn project_api_router() -> Router { Router::new() .route("/api/capabilities", get(capabilities)) + .route( + "/api/multi-root/collection", + get(multi_root_api::resolve_collection), + ) .route("/api/feedback/status", get(feedback_api::status)) // Holographic memory plugin API (mirrors holographic_plus plugin_api.py) .route("/api/plugins/holographic/", get(memory_api::overview)) @@ -1735,7 +1742,10 @@ async fn forward_project_request( /// Capability discovery for hosts and future delegated-host extensions. The UI /// (or a wrapper) can probe this to decide which panels/actions to enable. -async fn capabilities(State(state): State) -> Json { +async fn capabilities( + State(state): State, + control: Option>, +) -> Json { let has_lcm = state.lcm_read_authority.is_some(); let automation = automation_config_api::effective_automation_config(&state); let (automation_configured, automation_mode, automation_payload) = match automation { @@ -1775,18 +1785,17 @@ async fn capabilities(State(state): State) -> Json { }; let standalone_automation = automation_mode == "standalone_backend"; // Multi-root reads are served by the daemon, never by the dashboard's own - // stores. Report the transport the UI would actually have to use rather - // than a fixed string: without an admitted application executor there is - // no way to reach a scope set at all. - let multi_root = if state.application_invocation_executor.is_some() { - tracedecay_api::read_model::multi_root::MultiRootCapabilityV1::unavailable( - "no multi-root scope set is mounted for this project", - ) - } else { - tracedecay_api::read_model::multi_root::MultiRootCapabilityV1::unavailable( - "the daemon application transport is not admitted for this dashboard", - ) - }; + // stores. Capability discovery resolves through the mounted named-collection + // resolver: with no configured default collection this reports the typed + // no-collection state, and `/api/multi-root/collection` resolves explicit + // targets through the same authority. + let multi_root_resolver_mounted = state.application_invocation_executor.is_some(); + let multi_root = multi_root_api::resolve_collection_capability( + &state, + control.map(|Extension(control)| control), + None, + ) + .await; Json(json!({ "name": "tracedecay-dashboard", "version": crate::version::build_version(), @@ -1821,7 +1830,9 @@ async fn capabilities(State(state): State) -> Json { // Settings tab: aggregated project/user config editing plus // read-only environment and storage-path display. "settings": true, - "multi_root": false, + // Named-collection resolution is mounted whenever the daemon + // application transport is admitted for this dashboard. + "multi_root": multi_root_resolver_mounted, }, "automation": automation_payload, "dashboards": ["tracedecay"], @@ -2215,7 +2226,7 @@ mod authority_tests { let fixture = DashboardStateFixture::open("project.dashboard-state").await; let expected_path = fixture.layout.graph_db_path.display().to_string(); let state = fixture.state; - let Json(capabilities) = capabilities(State(state.clone())).await; + let Json(capabilities) = capabilities(State(state.clone()), None).await; assert_eq!(state.mem_db_path, expected_path); assert_eq!(state._database_guards.len(), 1); @@ -2231,6 +2242,179 @@ mod authority_tests { ); } + /// Serves exactly one persisted named collection, mirroring the daemon + /// scope-set read: an exact-id hit answers the frozen scope set, anything + /// else is a truthful absent read. + struct SingleCollectionRuntime { + scope_set: tracedecay_application::AuthorizedScopeSet, + } + + impl SingleCollectionRuntime { + fn persisted(collection: &str) -> Self { + use std::collections::BTreeSet; + + let capability = "capability.multi-root.query"; + let use_case = "use-case.multi-root.query"; + let scope = tracedecay_application::ResolvedScope::new( + ProjectId::new("project.dashboard-collection").expect("project"), + tracedecay_domain::RepositoryId::new("repository.dashboard-collection") + .expect("repository"), + tracedecay_domain::WorktreeId::new("worktree.main").expect("worktree"), + Some(tracedecay_domain::RefId::new("refs/heads/main").expect("reference")), + ) + .expect("scope"); + let grant = tracedecay_application::CapabilityGrantSnapshot::new( + "grant.dashboard-collection" + .to_owned() + .try_into() + .expect("grant id"), + 1, + tracedecay_domain::ManifestDigest::new(format!("sha256:{}", "a".repeat(64))) + .expect("digest"), + tracedecay_domain::ActorId::new("actor.issuer").expect("issuer"), + tracedecay_domain::UtcMicros(1), + tracedecay_domain::UtcMicros(1_000), + scope.clone(), + BTreeSet::from([ + tracedecay_tool_catalog::CapabilityId::new(capability).expect("capability"), + ]), + BTreeSet::from([ + tracedecay_tool_catalog::UseCaseId::new(use_case).expect("use case"), + ]), + tracedecay_application::DisclosureClass::Evidence, + ) + .expect("grant"); + let context = tracedecay_application::RequestContext::new( + tracedecay_domain::ActorId::new("actor.requester").expect("actor"), + scope, + grant, + tracedecay_application::RequestId::new("request.dashboard-collection") + .expect("request id"), + tracedecay_application::Deadline::new(tracedecay_domain::UtcMicros(900)) + .expect("deadline"), + tracedecay_application::CancellationContext::active("cancel.dashboard-collection") + .expect("cancellation"), + ) + .expect("context"); + let scope_set = tracedecay_application::AuthorizedScopeSetAuthority::authorize( + tracedecay_domain::ScopeSetId::new(collection).expect("collection id"), + tracedecay_domain::ScopeSetRevision::new(1).expect("revision"), + vec![context], + &tracedecay_tool_catalog::CapabilityId::new(capability).expect("capability"), + &tracedecay_tool_catalog::UseCaseId::new(use_case).expect("use case"), + tracedecay_domain::UtcMicros(10), + ) + .expect("authorized scope set"); + Self { scope_set } + } + } + + impl DashboardApplicationRuntime for SingleCollectionRuntime { + fn user_profile_id(&self) -> Option<&tracedecay_domain::UserProfileId> { + None + } + + fn routers( + &self, + _active_project_id: ProjectId, + ) -> std::result::Result { + Err("the single-collection test runtime mounts no application routers".to_owned()) + } + + fn apply_configuration_batch( + &self, + _request_id: tracedecay_application::RequestId, + _mutations: Vec, + _expected_revision: tracedecay_domain::configuration::ConfigurationRevisionId, + _idempotency_key: tracedecay_domain::configuration::ConfigurationIdempotencyKey, + ) -> DashboardConfigurationApplyFuture<'_> { + Box::pin(async { + Err(DashboardConfigurationApplyError::ApplicationContractViolation( + tracedecay_application::ApplicationContractError::Inconsistent { + field: "single-collection test runtime configuration", + }, + )) + }) + } + + fn read_multi_root_scope_set( + &self, + _control: DashboardHttpRequestControlV1, + scope_set_id: tracedecay_domain::ScopeSetId, + ) -> application_surface::DashboardScopeSetReadFuture<'_> { + let read = (self.scope_set.scope_set_id() == &scope_set_id) + .then(|| self.scope_set.clone()); + Box::pin(async move { Ok(read) }) + } + } + + #[tokio::test] + async fn capabilities_with_admitted_transport_report_the_typed_no_collection_state() { + let fixture = + DashboardStateFixture::open("project.dashboard-collection-capabilities").await; + let mut state = fixture.state; + state.application_invocation_executor = Some(Arc::new(SingleCollectionRuntime::persisted( + "scope-set.dashboard-capabilities", + ))); + + let Json(capabilities) = capabilities(State(state), None).await; + + assert_eq!(capabilities["features"]["multi_root"], true); + assert_eq!(capabilities["multi_root"]["status"], "unavailable"); + assert_eq!( + capabilities["multi_root"]["reason"], + "no default multi-root collection is configured; name an explicit collection" + ); + } + + #[tokio::test] + async fn dashboard_resolves_a_named_multi_root_collection() { + let fixture = DashboardStateFixture::open("project.dashboard-collection-resolve").await; + let mut state = fixture.state; + let runtime = SingleCollectionRuntime::persisted("scope-set.dashboard-resolve"); + let expected_digest = runtime.scope_set.digest().clone(); + state.application_invocation_executor = Some(Arc::new(runtime)); + + let mounted = multi_root_api::resolve_collection_capability( + &state, + Some(dashboard_lcm_test_control()), + Some(tracedecay_domain::ScopeSetId::new("scope-set.dashboard-resolve").unwrap()), + ) + .await; + let tracedecay_api::read_model::multi_root::MultiRootCapabilityV1::Mounted { + scope_set_id, + revision, + scope_set_digest, + root_count, + } = mounted + else { + panic!("an explicit persisted collection must resolve as mounted"); + }; + assert_eq!(scope_set_id.as_str(), "scope-set.dashboard-resolve"); + assert_eq!(revision.get(), 1); + assert_eq!(scope_set_digest, expected_digest); + assert_eq!(root_count, 1); + + // The explicit target always outranks any default: the same explicit + // resolution against a runtime persisting a different collection is a + // typed not-persisted state, never a silent fallback. + let missing = multi_root_api::resolve_collection_capability( + &state, + Some(dashboard_lcm_test_control()), + Some(tracedecay_domain::ScopeSetId::new("scope-set.dashboard-missing").unwrap()), + ) + .await; + let tracedecay_api::read_model::multi_root::MultiRootCapabilityV1::Unavailable { reason } = + missing + else { + panic!("an unpersisted collection must stay typed unavailable"); + }; + assert_eq!( + reason, + "multi-root collection scope-set.dashboard-missing names no persisted scope set for this project" + ); + } + #[tokio::test] async fn daemon_dashboard_retains_the_admitted_authorities() { let mut fixture = DashboardStateFixture::open("project.daemon-dashboard").await; diff --git a/crates/tracedecay-dashboard-api/src/multi_root_api.rs b/crates/tracedecay-dashboard-api/src/multi_root_api.rs new file mode 100644 index 0000000000..b0e22c5d69 --- /dev/null +++ b/crates/tracedecay-dashboard-api/src/multi_root_api.rs @@ -0,0 +1,124 @@ +//! Named multi-root collection resolution for the dashboard. +//! +//! The dashboard resolves a named collection (a persisted scope set with a +//! frozen revision and canonical member order) through the daemon application +//! transport. Selection precedence is owned by the application resolver: a +//! default collection can never outrank an explicit target. No default +//! collection is currently configurable — the retired +//! `query.default_collection.v1` setting fails closed in old stores and no +//! replacement setting exists — so an unnamed resolution reports the typed +//! no-collection state instead of guessing a scope set. + +use axum::Json; +use axum::extract::{Extension, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; +use tracedecay_api::read_model::multi_root::MultiRootCapabilityV1; +use tracedecay_application::{ + MultiRootCollectionResolutionV1, MultiRootCollectionSelectorV1, + MultiRootCollectionUnavailableV1, +}; +use tracedecay_domain::ScopeSetId; + +use super::{DashboardHttpRequestControlV1, DashboardState}; + +#[derive(Deserialize)] +pub struct CollectionQueryV1 { + pub collection: Option, +} + +/// `GET /api/multi-root/collection` — resolve the selected named collection. +/// +/// An explicit `collection` query parameter names the target; without one the +/// selector falls through to the (currently absent) default collection and +/// reports the typed no-collection state. +pub async fn resolve_collection( + State(state): State, + control: Option>, + Query(query): Query, +) -> Response { + let explicit_target = match query.collection { + Some(raw) => match ScopeSetId::new(raw) { + Ok(collection) => Some(collection), + Err(error) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "code": "multi_root.invalid_collection", + "detail": format!("collection must name one canonical scope set: {error}"), + })), + ) + .into_response(); + } + }, + None => None, + }; + let capability = resolve_collection_capability( + &state, + control.map(|Extension(control)| control), + explicit_target, + ) + .await; + Json(capability).into_response() +} + +/// Shared resolution used by the collection route and `/api/capabilities`. +pub(crate) async fn resolve_collection_capability( + state: &DashboardState, + control: Option, + explicit_target: Option, +) -> MultiRootCapabilityV1 { + let Some(runtime) = state.application_invocation_executor.as_deref() else { + return MultiRootCapabilityV1::unavailable( + MultiRootCollectionUnavailableV1::TransportNotAdmitted.reason(), + ); + }; + let selector = match MultiRootCollectionSelectorV1::new(explicit_target, None) { + Ok(selector) => selector, + Err(error) => { + return MultiRootCapabilityV1::unavailable( + MultiRootCollectionUnavailableV1::AuthorityUnavailable { + detail: error.to_string(), + } + .reason(), + ); + } + }; + let Some(target) = selector.target().cloned() else { + return MultiRootCapabilityV1::unavailable( + MultiRootCollectionUnavailableV1::NoCollectionNamed.reason(), + ); + }; + let Some(control) = control else { + return MultiRootCapabilityV1::unavailable( + MultiRootCollectionUnavailableV1::AuthorityUnavailable { + detail: "dashboard HTTP request admission is unavailable".to_owned(), + } + .reason(), + ); + }; + let read = match runtime + .read_multi_root_scope_set(control, target.clone()) + .await + { + Ok(read) => read, + Err(unavailable) => { + return MultiRootCapabilityV1::unavailable( + MultiRootCollectionUnavailableV1::AuthorityUnavailable { + detail: unavailable.detail, + } + .reason(), + ); + } + }; + match MultiRootCollectionResolutionV1::from_persisted_read(&target, read) { + MultiRootCollectionResolutionV1::Mounted { scope_set } => { + MultiRootCapabilityV1::mounted(&scope_set) + } + MultiRootCollectionResolutionV1::Unavailable { reason } => { + MultiRootCapabilityV1::unavailable(reason.reason()) + } + } +} diff --git a/src/daemon/dashboard_configuration_test_runtime.rs b/src/daemon/dashboard_configuration_test_runtime.rs index b958b6ce09..0cebb20d6a 100644 --- a/src/daemon/dashboard_configuration_test_runtime.rs +++ b/src/daemon/dashboard_configuration_test_runtime.rs @@ -24,7 +24,8 @@ use crate::daemon_client::invocation_now_micros; use crate::daemon_contract::{DaemonInvocationOutcome, DaemonInvocationRequest}; use crate::dashboard::{ DashboardApplicationRouters, DashboardApplicationRuntime, DashboardConfigurationApplyError, - DashboardConfigurationApplyFuture, + DashboardConfigurationApplyFuture, DashboardHttpRequestControlV1, DashboardScopeSetReadFuture, + DashboardScopeSetReadUnavailableV1, }; use crate::errors::{Result, TraceDecayError}; use crate::tracedecay::TraceDecay; @@ -129,6 +130,22 @@ impl DashboardApplicationRuntime for DashboardConfigurationRuntimeForTestV1 { } }) } + + fn read_multi_root_scope_set( + &self, + _control: DashboardHttpRequestControlV1, + _scope_set_id: tracedecay_domain::ScopeSetId, + ) -> DashboardScopeSetReadFuture<'_> { + // This runtime registers only the configuration and retained owners; + // multi-root scope-set reads route through the daemon project + // invocation owner, which is deliberately absent here. + Box::pin(async { + Err(DashboardScopeSetReadUnavailableV1 { + detail: "the dashboard configuration test runtime serves no multi-root scope-set reads" + .to_owned(), + }) + }) + } } impl ApplicationInvocationExecutor for DashboardConfigurationRuntimeForTestV1 { diff --git a/src/mcp/tools/handlers/dashboard.rs b/src/mcp/tools/handlers/dashboard.rs index 0b1b4f9eae..66757ddf12 100644 --- a/src/mcp/tools/handlers/dashboard.rs +++ b/src/mcp/tools/handlers/dashboard.rs @@ -29,7 +29,8 @@ use super::support::generic_tool_result; use crate::dashboard::{ AutomationSchedulerReconciler, DEFAULT_PORT, DashboardApplicationRouters, DashboardApplicationRuntime, DashboardAutomationWriter, DashboardConfigurationApplyError, - DashboardConfigurationApplyFuture, DashboardStateCompositionV1, bind_dashboard, + DashboardConfigurationApplyFuture, DashboardHttpRequestControlV1, DashboardScopeSetReadFuture, + DashboardScopeSetReadUnavailableV1, DashboardStateCompositionV1, bind_dashboard, build_state_with_automation_reconciler, router, validate_dashboard_host, }; @@ -151,6 +152,66 @@ impl DashboardApplicationRuntime for DashboardInvocationExecutorAdapter { } }) } + + fn read_multi_root_scope_set( + &self, + control: DashboardHttpRequestControlV1, + scope_set_id: tracedecay_domain::ScopeSetId, + ) -> DashboardScopeSetReadFuture<'_> { + let executor = Arc::clone(&self.executor); + Box::pin(async move { + let request = tracedecay_application::MultiRootScopeSetReadRequestV1::new(scope_set_id) + .map_err(|error| DashboardScopeSetReadUnavailableV1 { + detail: error.to_string(), + })?; + let invocation = crate::daemon_contract::DaemonInvocationRequest::multi_root_scope_set_read( + control.request_id().as_str(), + request, + control.observed_at(), + control.deadline(), + control.cancellation().context(), + ); + let response = executor + .invoke_controlled( + invocation, + control.deadline(), + control.cancellation().clone(), + crate::daemon_client::InvocationCancellationPolicy::ReadOnly, + ) + .await + .map_err(|error| DashboardScopeSetReadUnavailableV1 { + detail: format!("the daemon multi-root read transport failed: {error:?}"), + })?; + match response.outcome { + crate::daemon_contract::DaemonInvocationOutcome::MultiRootScopeSetRead { + outcome: tracedecay_application::ApplicationOutcome::Evidence(packet), + .. + } => packet + .payload + .ok_or_else(|| DashboardScopeSetReadUnavailableV1 { + detail: "the daemon multi-root read returned no evidence payload" + .to_owned(), + }), + crate::daemon_contract::DaemonInvocationOutcome::ApplicationProblem { + problem, + } => Err(DashboardScopeSetReadUnavailableV1 { + detail: format!( + "the daemon rejected the multi-root read: {}", + problem.safe_message() + ), + }), + crate::daemon_contract::DaemonInvocationOutcome::Problem { problem } => { + Err(DashboardScopeSetReadUnavailableV1 { + detail: format!("the daemon refused the multi-root read: {problem:?}"), + }) + } + _ => Err(DashboardScopeSetReadUnavailableV1 { + detail: "the daemon multi-root read answered with a foreign outcome" + .to_owned(), + }), + } + }) + } } fn append_direct_configuration_mutations( From 86faa309f613d6d36f2e9f71137c4a06c9dd3cf3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 05:34:22 +0000 Subject: [PATCH 02/12] feat(native-integration): mount dashboard status and LSP notifications The dashboard reads one native-integration transaction status over the catalog-bound dashboard surface (GET /api/native-integration/status), answering the same application result CLI and MCP project; only the read-only status operation gains a dashboard binding, so no gateway can advance a transaction, apply edits, or mutate Git. The daemon handler publishes observed status projections into a per-project fan-out, and ready LSP sessions forward changed projections as read-only tracedecay/nativeIntegrationStatus notifications. Co-authored-by: Zack Jackson --- .../src/git/native_integration_surface.rs | 17 +- .../src/application_surface.rs | 35 ++- crates/tracedecay-dashboard-api/src/lib.rs | 124 ++++++++++- .../src/native_integration_api.rs | 74 +++++++ crates/tracedecay-lsp/src/lib.rs | 5 + .../tracedecay-lsp/src/native_integration.rs | 26 +++ crates/tracedecay-lsp/src/protocol.rs | 5 + .../src/protocol/lifecycle_controller.rs | 17 +- .../protocol/native_integration_controller.rs | 206 ++++++++++++++++++ .../src/lsp_support/factory.rs | 75 ++++++- .../src/native_integration/mod.rs | 2 + .../native_integration/status_broadcast.rs | 130 +++++++++++ .../dashboard_configuration_test_runtime.rs | 19 +- src/daemon/service/invocation.rs | 26 +++ src/daemon/service/invocation/dispatch.rs | 7 + .../service/invocation/native_integration.rs | 72 +++++- .../service/invocation/registrars/lsp.rs | 10 +- src/mcp/tools/handlers/dashboard.rs | 77 ++++++- tests/native_integration_surface_mount.rs | 30 +++ 19 files changed, 915 insertions(+), 42 deletions(-) create mode 100644 crates/tracedecay-dashboard-api/src/native_integration_api.rs create mode 100644 crates/tracedecay-lsp/src/native_integration.rs create mode 100644 crates/tracedecay-lsp/src/protocol/native_integration_controller.rs create mode 100644 crates/tracedecay-usecases/src/native_integration/status_broadcast.rs diff --git a/crates/tracedecay-application/src/git/native_integration_surface.rs b/crates/tracedecay-application/src/git/native_integration_surface.rs index f3084ddc82..ec6e99035b 100644 --- a/crates/tracedecay-application/src/git/native_integration_surface.rs +++ b/crates/tracedecay-application/src/git/native_integration_surface.rs @@ -556,10 +556,19 @@ struct NativeIntegrationSurfaceSpec { surfaces: &'static [BindingSurface], } -/// Plan 36 exposes this journey through CLI and MCP only. HTTP is deliberately -/// excluded for the same reason `git_preview`/`git_apply` are: apply is an -/// authoritative native mutation and there is no transport fallback path. +/// Plan 36 exposes the transaction journey through CLI and MCP only. HTTP is +/// deliberately excluded for the same reason `git_preview`/`git_apply` are: +/// apply is an authoritative native mutation and there is no transport +/// fallback path. const NATIVE_INTEGRATION_SURFACES: [BindingSurface; 2] = [BindingSurface::Cli, BindingSurface::Mcp]; +/// The read-only status projection additionally serves the dashboard consumer +/// over the same application result. No mutating operation gains a dashboard +/// binding: the dashboard can observe a transaction but never advance one. +const NATIVE_INTEGRATION_STATUS_SURFACES: [BindingSurface; 3] = [ + BindingSurface::Cli, + BindingSurface::Mcp, + BindingSurface::Dashboard, +]; const NATIVE_WORKTREE_SURFACES: [BindingSurface; 3] = [ BindingSurface::Cli, BindingSurface::Mcp, @@ -631,7 +640,7 @@ const NATIVE_INTEGRATION_SPECS: [NativeIntegrationSurfaceSpec; 11] = [ description: "Read the durable phase, cancellation request, and terminal outcome of \ one native-integration transaction.", example: "Show the status of this native-integration transaction", - surfaces: &NATIVE_INTEGRATION_SURFACES, + surfaces: &NATIVE_INTEGRATION_STATUS_SURFACES, }, NativeIntegrationSurfaceSpec { operation: NATIVE_INTEGRATION_CANCEL_OPERATION, diff --git a/crates/tracedecay-dashboard-api/src/application_surface.rs b/crates/tracedecay-dashboard-api/src/application_surface.rs index 72e78021dd..fb080759e9 100644 --- a/crates/tracedecay-dashboard-api/src/application_surface.rs +++ b/crates/tracedecay-dashboard-api/src/application_surface.rs @@ -10,12 +10,12 @@ use serde_json::Value; use serde_json::json; use tracedecay_application::{ ApplicationContractError, ApplicationOutcome, ApplicationProblemEnvelope, AuthorizedScopeSet, - RequestId, + NativeIntegrationSurfaceResultV1, RequestId, }; use tracedecay_domain::configuration::{ ConfigurationIdempotencyKey, ConfigurationRevisionId, UserProfileId, }; -use tracedecay_domain::{ProjectId, ScopeSetId}; +use tracedecay_domain::{NativeIntegrationTransactionId, ProjectId, ScopeSetId}; use tracedecay_usecases::configuration::DirectConfigurationMutation; use crate::DashboardHttpRequestControlV1; @@ -79,17 +79,17 @@ pub type DashboardScopeSetReadFuture<'a> = Pin< dyn Future< Output = std::result::Result< Option, - DashboardScopeSetReadUnavailableV1, + DashboardDaemonReadUnavailableV1, >, > + Send + 'a, >, >; -/// The daemon transport could not answer a persisted scope-set read. The -/// detail is a safe diagnostic, never store paths or payload content. +/// The daemon transport could not answer a dashboard read. The detail is a +/// safe diagnostic, never store paths or payload content. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct DashboardScopeSetReadUnavailableV1 { +pub struct DashboardDaemonReadUnavailableV1 { pub detail: String, } @@ -120,8 +120,31 @@ pub trait DashboardApplicationRuntime: Send + Sync { control: DashboardHttpRequestControlV1, scope_set_id: ScopeSetId, ) -> DashboardScopeSetReadFuture<'a>; + + /// Reads one native-integration transaction status through the daemon + /// transport, answering the same application result the CLI and MCP + /// surfaces project. Read-only: the dashboard can observe a transaction + /// but never preflight, approve, apply, or cancel one, apply edits, or + /// mutate Git through this path. + fn native_integration_status<'a>( + &'a self, + control: DashboardHttpRequestControlV1, + transaction_id: NativeIntegrationTransactionId, + ) -> DashboardNativeIntegrationStatusFuture<'a>; } +pub type DashboardNativeIntegrationStatusFuture<'a> = Pin< + Box< + dyn Future< + Output = std::result::Result< + NativeIntegrationSurfaceResultV1, + DashboardDaemonReadUnavailableV1, + >, + > + Send + + 'a, + >, +>; + #[cfg(test)] mod tests { use tracedecay_application::{Deadline, RequestId}; diff --git a/crates/tracedecay-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index b66183c22b..9bf2a93337 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -21,8 +21,8 @@ pub mod tracedecay; // the dashboard-facing project runtime trait. pub use application_surface::{ DashboardApplicationRouters, DashboardApplicationRuntime, DashboardConfigurationApplyError, - DashboardConfigurationApplyFuture, DashboardScopeSetReadFuture, - DashboardScopeSetReadUnavailableV1, + DashboardConfigurationApplyFuture, DashboardDaemonReadUnavailableV1, + DashboardNativeIntegrationStatusFuture, DashboardScopeSetReadFuture, }; pub use tracedecay::DashboardProjectRuntime; @@ -106,6 +106,7 @@ mod memory_analysis; mod memory_api; mod memory_service; mod multi_root_api; +mod native_integration_api; pub mod project_graph; pub mod project_registry; mod projects; @@ -1277,6 +1278,7 @@ fn router_with_active_application( .route("/api/remote/{*tail}", any(active_api_gateway)) .route("/api/feedback/status", any(active_api_gateway)) .route("/api/multi-root/{*tail}", any(active_api_gateway)) + .route("/api/native-integration/{*tail}", any(active_api_gateway)) .route("/api/events", any(active_api_gateway)) .route("/api/events/delivery-ack", any(active_api_gateway)) .with_state(runtime) @@ -1308,6 +1310,10 @@ fn project_api_router() -> Router { "/api/multi-root/collection", get(multi_root_api::resolve_collection), ) + .route( + "/api/native-integration/status", + get(native_integration_api::status), + ) .route("/api/feedback/status", get(feedback_api::status)) // Holographic memory plugin API (mirrors holographic_plus plugin_api.py) .route("/api/plugins/holographic/", get(memory_api::overview)) @@ -2244,12 +2250,23 @@ mod authority_tests { /// Serves exactly one persisted named collection, mirroring the daemon /// scope-set read: an exact-id hit answers the frozen scope set, anything - /// else is a truthful absent read. + /// else is a truthful absent read. Optionally answers one scripted + /// native-integration status result. struct SingleCollectionRuntime { scope_set: tracedecay_application::AuthorizedScopeSet, + native_integration_status: + Option, } impl SingleCollectionRuntime { + fn with_native_integration_status( + mut self, + result: tracedecay_application::NativeIntegrationSurfaceResultV1, + ) -> Self { + self.native_integration_status = Some(result); + self + } + fn persisted(collection: &str) -> Self { use std::collections::BTreeSet; @@ -2305,7 +2322,10 @@ mod authority_tests { tracedecay_domain::UtcMicros(10), ) .expect("authorized scope set"); - Self { scope_set } + Self { + scope_set, + native_integration_status: None, + } } } @@ -2346,6 +2366,20 @@ mod authority_tests { .then(|| self.scope_set.clone()); Box::pin(async move { Ok(read) }) } + + fn native_integration_status( + &self, + _control: DashboardHttpRequestControlV1, + _transaction_id: tracedecay_domain::NativeIntegrationTransactionId, + ) -> application_surface::DashboardNativeIntegrationStatusFuture<'_> { + let result = self.native_integration_status.clone(); + Box::pin(async move { + result.ok_or(application_surface::DashboardDaemonReadUnavailableV1 { + detail: "the single-collection test runtime scripts no native-integration status" + .to_owned(), + }) + }) + } } #[tokio::test] @@ -2415,6 +2449,88 @@ mod authority_tests { ); } + async fn json_response_body(response: axum::response::Response) -> (StatusCode, Value) { + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("response body"); + (status, serde_json::from_slice(&body).expect("json body")) + } + + #[tokio::test] + async fn dashboard_serves_the_native_integration_status_application_result() { + let fixture = DashboardStateFixture::open("project.dashboard-native-status").await; + let mut state = fixture.state; + let projection = tracedecay_application::NativeIntegrationStatusProjectionV1 { + transaction_id: tracedecay_domain::NativeIntegrationTransactionId::new( + "transaction.dashboard.native", + ) + .expect("transaction"), + preview_id: tracedecay_domain::NativeIntegrationPreviewId::new( + "preview.dashboard.native", + ) + .expect("preview"), + preview_digest: tracedecay_domain::ManifestDigest::new(format!( + "sha256:{}", + "d".repeat(64) + )) + .expect("digest"), + repository_id: tracedecay_domain::RepositoryId::new("repository.dashboard.native") + .expect("repository"), + destination_ref: tracedecay_domain::RefId::new("refs/heads/main").expect("reference"), + phase: tracedecay_domain::NativeIntegrationPhaseV1::Terminal, + phase_revision: 4, + cancellation_requested: false, + terminal_outcome: Some(tracedecay_domain::NativeIntegrationTerminalOutcomeV1::Committed), + updated_at: tracedecay_domain::UtcMicros(9), + }; + state.application_invocation_executor = Some(Arc::new( + SingleCollectionRuntime::persisted("scope-set.dashboard-native") + .with_native_integration_status( + tracedecay_application::NativeIntegrationSurfaceResultV1::Status( + projection.clone(), + ), + ), + )); + + let response = native_integration_api::status( + State(state), + Some(Extension(dashboard_lcm_test_control())), + axum::extract::Query(native_integration_api::NativeIntegrationStatusQueryV1 { + transaction_id: "transaction.dashboard.native".to_owned(), + }), + ) + .await; + let (status, body) = json_response_body(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["outcome"], "status"); + assert_eq!(body["transaction_id"], "transaction.dashboard.native"); + assert_eq!(body["phase"], "terminal"); + assert_eq!(body["terminal_outcome"], "committed"); + assert_eq!(body["phase_revision"], projection.phase_revision); + } + + #[tokio::test] + async fn standalone_dashboard_reports_native_integration_authority_unmounted() { + let fixture = + DashboardStateFixture::open("project.dashboard-native-status-standalone").await; + + let response = native_integration_api::status( + State(fixture.state), + Some(Extension(dashboard_lcm_test_control())), + axum::extract::Query(native_integration_api::NativeIntegrationStatusQueryV1 { + transaction_id: "transaction.dashboard.native".to_owned(), + }), + ) + .await; + let (status, body) = json_response_body(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["outcome"], "unavailable"); + assert_eq!(body["reason"], "authority_unmounted"); + } + #[tokio::test] async fn daemon_dashboard_retains_the_admitted_authorities() { let mut fixture = DashboardStateFixture::open("project.daemon-dashboard").await; diff --git a/crates/tracedecay-dashboard-api/src/native_integration_api.rs b/crates/tracedecay-dashboard-api/src/native_integration_api.rs new file mode 100644 index 0000000000..9fbc01633a --- /dev/null +++ b/crates/tracedecay-dashboard-api/src/native_integration_api.rs @@ -0,0 +1,74 @@ +//! Read-only native-integration status for the dashboard. +//! +//! The dashboard consumes the same application result the CLI and MCP +//! surfaces project (`NativeIntegrationSurfaceResultV1`), resolved through +//! the daemon transport under the live request controls. No mutating +//! native-integration operation is reachable here: the dashboard can observe +//! a transaction but never preflight, approve, apply, or cancel one, apply +//! edits, or mutate Git. + +use axum::Json; +use axum::extract::{Extension, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; +use tracedecay_application::{ + NativeIntegrationSurfaceResultV1, NativeIntegrationSurfaceUnavailableV1, +}; +use tracedecay_domain::NativeIntegrationTransactionId; + +use super::{DashboardHttpRequestControlV1, DashboardState}; + +#[derive(Deserialize)] +pub struct NativeIntegrationStatusQueryV1 { + pub transaction_id: String, +} + +/// `GET /api/native-integration/status` — read one transaction status. +pub async fn status( + State(state): State, + control: Option>, + Query(query): Query, +) -> Response { + let transaction_id = match NativeIntegrationTransactionId::new(query.transaction_id) { + Ok(transaction_id) => transaction_id, + Err(error) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "code": "native_integration.invalid_transaction", + "detail": format!( + "transaction_id must name one canonical transaction: {error}" + ), + })), + ) + .into_response(); + } + }; + // A dashboard without the daemon application transport, or without live + // request admission, has no authority to consult; that is the same typed + // unmounted state the daemon answers for a project without the runtime. + let (Some(runtime), Some(Extension(control))) = + (state.application_invocation_executor.as_deref(), control) + else { + return Json(NativeIntegrationSurfaceResultV1::unavailable( + NativeIntegrationSurfaceUnavailableV1::AuthorityUnmounted, + )) + .into_response(); + }; + match runtime + .native_integration_status(control, transaction_id) + .await + { + Ok(result) => Json(result).into_response(), + Err(unavailable) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "code": "native_integration.transport_unavailable", + "detail": unavailable.detail, + })), + ) + .into_response(), + } +} diff --git a/crates/tracedecay-lsp/src/lib.rs b/crates/tracedecay-lsp/src/lib.rs index 607432b384..214f6e6cea 100644 --- a/crates/tracedecay-lsp/src/lib.rs +++ b/crates/tracedecay-lsp/src/lib.rs @@ -37,6 +37,7 @@ mod context; mod diagnostics; mod dispatch; mod gateway; +mod native_integration; mod overlay; mod protocol; mod provider; @@ -96,6 +97,10 @@ pub use gateway::{ decode_uri_segment, lsp_semantic_request, percent_hex_nibble, project_semantic_outcome, strict_file_uri_path, strict_file_url, valid_raw_uri_path, }; +pub use native_integration::{ + MAX_NATIVE_INTEGRATION_STATUS_BYTES, MAX_NATIVE_INTEGRATION_STATUS_PER_POLL, + NativeIntegrationStatusPort, TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD, +}; pub use overlay::{ CanonicalDiagnosticRefreshRequest, CanonicalDiagnosticSnapshotAuthority, DebouncedDiagnostic, DebouncedDiagnosticKind, DiagnosticSnapshotAdapter, MAX_DIAGNOSTIC_OPERATIONS, diff --git a/crates/tracedecay-lsp/src/native_integration.rs b/crates/tracedecay-lsp/src/native_integration.rs new file mode 100644 index 0000000000..40c7809aa7 --- /dev/null +++ b/crates/tracedecay-lsp/src/native_integration.rs @@ -0,0 +1,26 @@ +//! Read-only native-integration status notifications for LSP clients. +//! +//! The daemon transaction coordinator remains the sole mutation authority; +//! this module carries only the bounded application status projection to an +//! already-authorized session as a server-to-client notification. No client +//! method is admitted here: the gateway cannot preflight, approve, apply, or +//! cancel a native integration, apply edits, or mutate Git through this path. + +use tracedecay_application::NativeIntegrationStatusProjectionV1; + +pub const TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD: &str = + "tracedecay/nativeIntegrationStatus"; + +/// The most recent status projections one session flush may forward. +pub const MAX_NATIVE_INTEGRATION_STATUS_PER_POLL: usize = 16; + +/// Bytes reserved on the outbound queue before a status flush runs. +pub const MAX_NATIVE_INTEGRATION_STATUS_BYTES: usize = 16 * 1024; + +/// Daemon-owned read of recently observed native-integration transaction +/// statuses. Implementations return current bounded projections; each session +/// dedupes what it already forwarded, so re-returning an unchanged status is +/// harmless and never re-notifies. +pub trait NativeIntegrationStatusPort: Send + Sync { + fn poll_status(&self, maximum: usize) -> Vec; +} diff --git a/crates/tracedecay-lsp/src/protocol.rs b/crates/tracedecay-lsp/src/protocol.rs index b7f046cf1d..5dd16836fb 100644 --- a/crates/tracedecay-lsp/src/protocol.rs +++ b/crates/tracedecay-lsp/src/protocol.rs @@ -79,6 +79,7 @@ mod context_controller; mod diagnostics_controller; mod dynamic_diagnostics_controller; mod lifecycle_controller; +mod native_integration_controller; mod outbound_controller; mod semantic_controller; mod workspace_diagnostics_controller; @@ -93,6 +94,7 @@ pub use outbound_controller::DaemonLspProtocolTransport; use outbound_controller::OutboundController; #[cfg(test)] use outbound_controller::QueuedFrame; +use native_integration_controller::NativeIntegrationController; use semantic_controller::SemanticController; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -126,6 +128,7 @@ where diagnostics: DiagnosticsController, dynamic_diagnostics: DynamicDiagnosticsController, context: ContextController, + native_integration: NativeIntegrationController, semantic: SemanticController, catalog: Result, pending_workspace_mutation: Option, @@ -216,6 +219,7 @@ where self.poll_context_expansions(); self.poll_semantic_requests(); self.flush_context_changes(); + self.flush_native_integration_status(); ProtocolDispatch { queued_messages: self.outbound.queue.len().saturating_sub(before), closed: matches!( @@ -316,6 +320,7 @@ where self.poll_context_expansions(); self.poll_semantic_requests(); self.flush_context_changes(); + self.flush_native_integration_status(); ProtocolDispatch { queued_messages: self.outbound.queue.len().saturating_sub(before), closed: matches!( diff --git a/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs b/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs index f5399be2a5..f3ba6de0f1 100644 --- a/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs +++ b/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs @@ -6,7 +6,8 @@ use super::{ DynamicDiagnosticsController, EffectiveCapabilities, FeedbackCyclePort, GatewayCapabilities, GatewayMethod, LifecycleError, LspCatalogAdmission, LspRequestFailure, LspRequestId, LspSessionControl, MAX_CONTEXT_PROJECTION_KINDS, Map, MethodUnavailableReason, - OutboundController, OverlayError, OverlayStore, RpcFailure, SemanticController, + NativeIntegrationController, OutboundController, OverlayError, OverlayStore, RpcFailure, + SemanticController, SemanticProviderPort, SessionLifecycle, TRACEDECAY_CONTEXT_EXPAND_METHOD, TRACEDECAY_CONTEXT_METHOD, UnavailableDiagnosticSnapshotProvider, UpstreamCapabilities, Value, error_response, initialized_workspace_uris, is_supported_context_projection, json, @@ -136,6 +137,7 @@ where diagnostics: DiagnosticsController::new(diagnostics), dynamic_diagnostics: DynamicDiagnosticsController::default(), context: ContextController::default(), + native_integration: NativeIntegrationController::default(), semantic: SemanticController::default(), catalog: LspCatalogAdmission::from_application_catalog(), pending_workspace_mutation: None, @@ -176,6 +178,19 @@ where self } + /// Mounts the daemon-owned read of recently observed native-integration + /// transaction statuses. The session forwards them as server-to-client + /// notifications only; no client-callable native-integration method is + /// admitted through the gateway. + #[must_use] + pub fn with_native_integration_status_port( + mut self, + port: Arc, + ) -> Self { + self.native_integration.port = Some(port); + self + } + pub fn root(&self) -> &AdmittedRoot { self.lifecycle.gateway.root() } diff --git a/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs b/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs new file mode 100644 index 0000000000..5301b31095 --- /dev/null +++ b/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs @@ -0,0 +1,206 @@ +//! Server-to-client native-integration status notifications. +//! +//! One bounded flush forwards the daemon's application status projections to a +//! ready session as `tracedecay/nativeIntegrationStatus` notifications. The +//! session dedupes per transaction, so a port re-returning an unchanged status +//! never re-notifies. This path admits no client method: the gateway cannot +//! start, approve, apply, or cancel a native integration, apply edits, or +//! mutate Git from here. + +use tracedecay_application::NativeIntegrationStatusProjectionV1; +use tracedecay_domain::NativeIntegrationTransactionId; + +use super::{ + Arc, BTreeMap, DaemonLspProtocolSession, DiagnosticSnapshotPort, FeedbackCyclePort, + SemanticProviderPort, SessionLifecycle, json, +}; +use crate::native_integration::{ + MAX_NATIVE_INTEGRATION_STATUS_BYTES, MAX_NATIVE_INTEGRATION_STATUS_PER_POLL, + NativeIntegrationStatusPort, TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD, +}; + +/// Transactions each session remembers for dedupe. Terminal statuses stay +/// remembered so a port that keeps returning them cannot re-notify; the oldest +/// entry by `updated_at` is evicted beyond this bound. +const MAX_TRACKED_NATIVE_INTEGRATION_TRANSACTIONS: usize = 128; + +#[derive(Default)] +pub(super) struct NativeIntegrationController { + pub(super) port: Option>, + pub(super) notified: + BTreeMap, +} + +impl DaemonLspProtocolSession +where + P: FeedbackCyclePort, + S: SemanticProviderPort, + D: DiagnosticSnapshotPort, +{ + pub(super) fn flush_native_integration_status(&mut self) { + if self.lifecycle.control.lifecycle() != SessionLifecycle::Ready + || !self.has_outbound_capacity(MAX_NATIVE_INTEGRATION_STATUS_BYTES) + { + return; + } + let Some(port) = self.native_integration.port.clone() else { + return; + }; + for projection in port.poll_status(MAX_NATIVE_INTEGRATION_STATUS_PER_POLL) { + if self + .native_integration + .notified + .get(&projection.transaction_id) + == Some(&projection) + { + continue; + } + let Ok(params) = serde_json::to_value(&projection) else { + continue; + }; + let notification = json!({ + "jsonrpc": "2.0", + "method": TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD, + "params": params, + }); + if !self.enqueue_value(notification) { + break; + } + self.native_integration + .notified + .insert(projection.transaction_id.clone(), projection); + while self.native_integration.notified.len() + > MAX_TRACKED_NATIVE_INTEGRATION_TRANSACTIONS + { + let Some(oldest) = self + .native_integration + .notified + .iter() + .min_by_key(|(_, status)| status.updated_at) + .map(|(transaction_id, _)| transaction_id.clone()) + else { + break; + }; + self.native_integration.notified.remove(&oldest); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use serde_json::Value; + use tracedecay_application::NativeIntegrationStatusProjectionV1; + use tracedecay_domain::{ + ManifestDigest, NativeIntegrationPhaseV1, NativeIntegrationPreviewId, + NativeIntegrationTerminalOutcomeV1, NativeIntegrationTransactionId, RefId, RepositoryId, + UtcMicros, + }; + + use super::super::tests::{initialize, session}; + use crate::native_integration::{ + NativeIntegrationStatusPort, TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD, + }; + + struct ScriptedStatusPort { + statuses: Mutex>, + } + + impl ScriptedStatusPort { + fn holding(projection: NativeIntegrationStatusProjectionV1) -> Arc { + Arc::new(Self { + statuses: Mutex::new(vec![projection]), + }) + } + + fn replace(&self, projection: NativeIntegrationStatusProjectionV1) { + *self.statuses.lock().unwrap() = vec![projection]; + } + } + + impl NativeIntegrationStatusPort for ScriptedStatusPort { + fn poll_status(&self, maximum: usize) -> Vec { + let mut statuses = self.statuses.lock().unwrap().clone(); + statuses.truncate(maximum); + statuses + } + } + + fn projection( + phase: NativeIntegrationPhaseV1, + phase_revision: u64, + terminal_outcome: Option, + ) -> NativeIntegrationStatusProjectionV1 { + NativeIntegrationStatusProjectionV1 { + transaction_id: NativeIntegrationTransactionId::new("transaction.lsp.notify").unwrap(), + preview_id: NativeIntegrationPreviewId::new("preview.lsp.notify").unwrap(), + preview_digest: ManifestDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap(), + repository_id: RepositoryId::new("repository.lsp.notify").unwrap(), + destination_ref: RefId::new("refs/heads/main").unwrap(), + phase, + phase_revision, + cancellation_requested: false, + terminal_outcome, + updated_at: UtcMicros(i64::from(u32::try_from(phase_revision).unwrap())), + } + } + + fn native_integration_notifications(frames: Vec>) -> Vec { + frames + .into_iter() + .map(|frame| serde_json::from_slice::(&frame).unwrap()) + .filter(|message| { + message["method"] == TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD + }) + .collect() + } + + #[test] + fn ready_sessions_forward_each_status_change_exactly_once() { + let port = ScriptedStatusPort::holding(projection( + NativeIntegrationPhaseV1::Prepared, + 1, + None, + )); + let mut session = + session().with_native_integration_status_port(Arc::clone(&port) as Arc<_>); + initialize(&mut session); + + let first = native_integration_notifications(session.drain_outbound()); + assert_eq!(first.len(), 1, "one changed status must notify once"); + assert_eq!(first[0]["params"]["phase"], "prepared"); + assert_eq!(first[0]["params"]["phase_revision"], 1); + + // An unchanged status re-returned by the port never re-notifies. + session.flush_due(2); + assert!(native_integration_notifications(session.drain_outbound()).is_empty()); + + // A durable phase advance notifies again with the terminal outcome. + port.replace(projection( + NativeIntegrationPhaseV1::Terminal, + 5, + Some(NativeIntegrationTerminalOutcomeV1::Committed), + )); + session.flush_due(3); + let terminal = native_integration_notifications(session.drain_outbound()); + assert_eq!(terminal.len(), 1); + assert_eq!(terminal[0]["params"]["phase"], "terminal"); + assert_eq!(terminal[0]["params"]["terminal_outcome"], "committed"); + } + + #[test] + fn sessions_before_initialization_receive_no_native_integration_notifications() { + let port = ScriptedStatusPort::holding(projection( + NativeIntegrationPhaseV1::Prepared, + 1, + None, + )); + let mut session = session().with_native_integration_status_port(port as Arc<_>); + + session.flush_due(1); + + assert!(native_integration_notifications(session.drain_outbound()).is_empty()); + } +} diff --git a/crates/tracedecay-usecases/src/lsp_support/factory.rs b/crates/tracedecay-usecases/src/lsp_support/factory.rs index f05d4cfed6..4ac8249a4f 100644 --- a/crates/tracedecay-usecases/src/lsp_support/factory.rs +++ b/crates/tracedecay-usecases/src/lsp_support/factory.rs @@ -14,9 +14,10 @@ use tracedecay_lsp::{ DiagnosticSnapshotOutcome, DiagnosticSnapshotPort, FeedbackCycleAdapter, FeedbackCyclePort, FeedbackCycleRequest, FeedbackCycleResponse, FeedbackCycleRuntimePort, GatewayCapabilities, LspAnalyzerCancellationAuthority, LspRequestId, LspRuntimeFailure, LspRuntimeFuture, - OverlaySnapshot, SemanticProviderOutcome, SemanticProviderPort, SemanticRequest, - SemanticResponse, UpstreamCapabilities, WorkspaceDiagnosticSnapshotOutcome, + NativeIntegrationStatusPort, OverlaySnapshot, SemanticProviderOutcome, SemanticProviderPort, + SemanticRequest, SemanticResponse, UpstreamCapabilities, WorkspaceDiagnosticSnapshotOutcome, }; +use tracedecay_application::NativeIntegrationStatusProjectionV1; use super::runtime_adapters::runtime_spawner; @@ -54,6 +55,7 @@ pub struct DaemonLspSessionFactory { diagnostics: Arc, cancellation: Arc, context: Arc, + native_integration_status: Option>, gateway_capabilities: GatewayCapabilities, upstream_capabilities: UpstreamCapabilities, upstream_capability_initializer: Arc, @@ -82,6 +84,7 @@ impl DaemonLspSessionFactory { diagnostics, cancellation, context, + native_integration_status: None, gateway_capabilities, upstream_capability_initializer: Arc::new(StaticUpstreamCapabilities { capabilities: upstream_capabilities.clone(), @@ -90,6 +93,18 @@ impl DaemonLspSessionFactory { } } + /// Mounts the daemon-owned native-integration status read. Sessions opened + /// from this factory forward observed transaction statuses to their client + /// as read-only notifications. + #[must_use] + pub fn with_native_integration_status_port( + mut self, + port: Arc, + ) -> Self { + self.native_integration_status = Some(port); + self + } + /// Replaces the static test capability source with the production /// initializer backed by the shared analyzer client. pub fn with_upstream_capability_initializer( @@ -142,7 +157,7 @@ impl DaemonLspSessionFactory { } pub fn open_session(&self, root: AdmittedRoot) -> DaemonLspRuntimeSession { - self.provider_bundle().into_session(root) + self.attach_native_integration_status(self.provider_bundle().into_session(root)) } pub async fn open_workspace_session( @@ -150,9 +165,20 @@ impl DaemonLspSessionFactory { workspace: AuthorizedLspWorkspace, ) -> std::result::Result { let upstream_capabilities = self.initialize_upstream_capabilities().await?; - Ok(self - .provider_bundle_with_upstream_capabilities(upstream_capabilities) - .into_workspace_session(workspace)) + Ok(self.attach_native_integration_status( + self.provider_bundle_with_upstream_capabilities(upstream_capabilities) + .into_workspace_session(workspace), + )) + } + + fn attach_native_integration_status( + &self, + session: DaemonLspRuntimeSession, + ) -> DaemonLspRuntimeSession { + match self.native_integration_status.as_ref() { + Some(port) => session.with_native_integration_status_port(Arc::clone(port)), + None => session, + } } pub async fn open_federated_workspace_session( @@ -184,6 +210,7 @@ impl DaemonLspSessionFactory { let mut diagnostics = BTreeMap::new(); let mut cancellation = BTreeMap::new(); let mut context = BTreeMap::new(); + let mut native_integration_status = Vec::new(); let mut gateway_capabilities: Option = None; let mut upstream_capabilities: Option = None; for (root, factory, factory_upstream_capabilities) in factories { @@ -216,6 +243,9 @@ impl DaemonLspSessionFactory { factory.context.clone(), )) as Arc, ); + if let Some(port) = factory.native_integration_status.as_ref() { + native_integration_status.push(Arc::clone(port)); + } let current_gateway_capabilities = factory.current_gateway_capabilities(); if let Some(capabilities) = gateway_capabilities.as_mut() { capabilities.supports_publish_diagnostics &= @@ -260,7 +290,38 @@ impl DaemonLspSessionFactory { gateway_capabilities?, upstream_capabilities?, ); - Some(bundle.into_workspace_session(workspace)) + let session = bundle.into_workspace_session(workspace); + if native_integration_status.is_empty() { + return Some(session); + } + Some( + session.with_native_integration_status_port(Arc::new( + FederatedNativeIntegrationStatus { + roots: native_integration_status, + }, + )), + ) + } +} + +/// Merges the participating roots' status reads under one poll bound. Each +/// projection already names its exact repository and transaction identity, so +/// merging discloses nothing a single-root session would not see. +struct FederatedNativeIntegrationStatus { + roots: Vec>, +} + +impl NativeIntegrationStatusPort for FederatedNativeIntegrationStatus { + fn poll_status(&self, maximum: usize) -> Vec { + let mut merged = Vec::new(); + for root in &self.roots { + let remaining = maximum.saturating_sub(merged.len()); + if remaining == 0 { + break; + } + merged.extend(root.poll_status(remaining).into_iter().take(remaining)); + } + merged } } diff --git a/crates/tracedecay-usecases/src/native_integration/mod.rs b/crates/tracedecay-usecases/src/native_integration/mod.rs index 61ce5c2946..0b1758071b 100644 --- a/crates/tracedecay-usecases/src/native_integration/mod.rs +++ b/crates/tracedecay-usecases/src/native_integration/mod.rs @@ -2,6 +2,7 @@ mod authorization; mod gix_adapter; +mod status_broadcast; mod topology; mod transaction; @@ -9,6 +10,7 @@ pub use authorization::{ DaemonNativeIntegrationAuthorization, NativeIntegrationAuthorizationError, }; pub use gix_adapter::GixNativeIntegrationAdapter; +pub use status_broadcast::NativeIntegrationStatusBroadcastV1; pub use topology::ExactPairNativeIntegrationTopology; pub use transaction::{ NativeApplyEffectV1, NativeIntegrationAuthorizationOutcomeV1, diff --git a/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs b/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs new file mode 100644 index 0000000000..630106e07b --- /dev/null +++ b/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs @@ -0,0 +1,130 @@ +//! In-memory fan-out of observed native-integration transaction statuses. +//! +//! The daemon invocation handler publishes the same bounded application +//! status projection every surface answers with; LSP sessions read the most +//! recent projections and forward changed ones as notifications. This is a +//! read-only observation channel: nothing here can start, approve, apply, or +//! cancel a transaction. + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use tracedecay_application::NativeIntegrationStatusProjectionV1; +use tracedecay_domain::NativeIntegrationTransactionId; +use tracedecay_lsp::NativeIntegrationStatusPort; + +/// Latest-per-transaction retention. Beyond this bound the oldest projection +/// by `updated_at` is evicted; consumers dedupe on content, so eviction can +/// only cost a redundant re-notification, never a fabricated status. +const MAX_BROADCAST_TRANSACTIONS: usize = 64; + +#[derive(Default)] +pub struct NativeIntegrationStatusBroadcastV1 { + statuses: Mutex>, +} + +impl NativeIntegrationStatusBroadcastV1 { + /// Records the latest observed projection for its transaction. A stale + /// publication (older `phase_revision` for the same transaction) never + /// overwrites newer durable evidence. + pub fn publish(&self, projection: NativeIntegrationStatusProjectionV1) { + let Ok(mut statuses) = self.statuses.lock() else { + return; + }; + match statuses.get(&projection.transaction_id) { + Some(current) if current.phase_revision > projection.phase_revision => return, + _ => {} + } + statuses.insert(projection.transaction_id.clone(), projection); + while statuses.len() > MAX_BROADCAST_TRANSACTIONS { + let Some(oldest) = statuses + .iter() + .min_by_key(|(_, status)| status.updated_at) + .map(|(transaction_id, _)| transaction_id.clone()) + else { + break; + }; + statuses.remove(&oldest); + } + } + + /// The most recently updated projections, newest first. + pub fn recent(&self, maximum: usize) -> Vec { + let Ok(statuses) = self.statuses.lock() else { + return Vec::new(); + }; + let mut recent = statuses.values().cloned().collect::>(); + recent.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); + recent.truncate(maximum); + recent + } +} + +impl NativeIntegrationStatusPort for NativeIntegrationStatusBroadcastV1 { + fn poll_status(&self, maximum: usize) -> Vec { + self.recent(maximum) + } +} + +#[cfg(test)] +mod tests { + use tracedecay_domain::{ + ManifestDigest, NativeIntegrationPhaseV1, NativeIntegrationPreviewId, + NativeIntegrationTerminalOutcomeV1, RefId, RepositoryId, UtcMicros, + }; + + use super::*; + + fn projection( + transaction: &str, + phase_revision: u64, + updated_at: i64, + ) -> NativeIntegrationStatusProjectionV1 { + NativeIntegrationStatusProjectionV1 { + transaction_id: NativeIntegrationTransactionId::new(transaction).expect("transaction"), + preview_id: NativeIntegrationPreviewId::new("preview.broadcast").expect("preview"), + preview_digest: ManifestDigest::new(format!("sha256:{}", "b".repeat(64))) + .expect("digest"), + repository_id: RepositoryId::new("repository.broadcast").expect("repository"), + destination_ref: RefId::new("refs/heads/main").expect("reference"), + phase: NativeIntegrationPhaseV1::Terminal, + phase_revision, + cancellation_requested: false, + terminal_outcome: Some(NativeIntegrationTerminalOutcomeV1::Committed), + updated_at: UtcMicros(updated_at), + } + } + + #[test] + fn newest_projection_per_transaction_wins_and_stale_revisions_never_regress() { + let broadcast = NativeIntegrationStatusBroadcastV1::default(); + broadcast.publish(projection("transaction.broadcast.one", 3, 30)); + broadcast.publish(projection("transaction.broadcast.one", 2, 40)); + + let recent = broadcast.recent(8); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].phase_revision, 3); + assert_eq!(recent[0].updated_at, UtcMicros(30)); + } + + #[test] + fn retention_evicts_the_oldest_projection_beyond_the_bound() { + let broadcast = NativeIntegrationStatusBroadcastV1::default(); + for index in 0..=MAX_BROADCAST_TRANSACTIONS { + broadcast.publish(projection( + &format!("transaction.broadcast.{index}"), + 1, + index as i64, + )); + } + + let recent = broadcast.recent(MAX_BROADCAST_TRANSACTIONS + 1); + assert_eq!(recent.len(), MAX_BROADCAST_TRANSACTIONS); + assert!( + !recent + .iter() + .any(|status| status.updated_at == UtcMicros(0)), + "the oldest projection must be evicted first" + ); + } +} diff --git a/src/daemon/dashboard_configuration_test_runtime.rs b/src/daemon/dashboard_configuration_test_runtime.rs index 0cebb20d6a..73a658f308 100644 --- a/src/daemon/dashboard_configuration_test_runtime.rs +++ b/src/daemon/dashboard_configuration_test_runtime.rs @@ -25,7 +25,7 @@ use crate::daemon_contract::{DaemonInvocationOutcome, DaemonInvocationRequest}; use crate::dashboard::{ DashboardApplicationRouters, DashboardApplicationRuntime, DashboardConfigurationApplyError, DashboardConfigurationApplyFuture, DashboardHttpRequestControlV1, DashboardScopeSetReadFuture, - DashboardScopeSetReadUnavailableV1, + DashboardDaemonReadUnavailableV1, }; use crate::errors::{Result, TraceDecayError}; use crate::tracedecay::TraceDecay; @@ -140,12 +140,27 @@ impl DashboardApplicationRuntime for DashboardConfigurationRuntimeForTestV1 { // multi-root scope-set reads route through the daemon project // invocation owner, which is deliberately absent here. Box::pin(async { - Err(DashboardScopeSetReadUnavailableV1 { + Err(DashboardDaemonReadUnavailableV1 { detail: "the dashboard configuration test runtime serves no multi-root scope-set reads" .to_owned(), }) }) } + + fn native_integration_status( + &self, + control: DashboardHttpRequestControlV1, + transaction_id: tracedecay_domain::NativeIntegrationTransactionId, + ) -> crate::dashboard::DashboardNativeIntegrationStatusFuture<'_> { + Box::pin(async move { + crate::mcp::tools::handlers::dashboard::dashboard_native_integration_status( + self, + &control, + transaction_id, + ) + .await + }) + } } impl ApplicationInvocationExecutor for DashboardConfigurationRuntimeForTestV1 { diff --git a/src/daemon/service/invocation.rs b/src/daemon/service/invocation.rs index de52b63bf5..d2288a284d 100644 --- a/src/daemon/service/invocation.rs +++ b/src/daemon/service/invocation.rs @@ -293,6 +293,17 @@ pub(crate) struct DaemonInvocationService { worktree_holder_admission: crate::daemon::native_integration::WorktreeHolderAdmissionFenceV1, session_holder_databases: Arc>>, + /// Per-project fan-out of observed native-integration transaction + /// statuses. The invocation handler publishes; LSP sessions read and + /// notify. Created on demand under one project-root key shared by both. + native_integration_status_broadcasts: Arc< + Mutex< + BTreeMap< + PathBuf, + Arc, + >, + >, + >, } #[cfg(test)] @@ -326,9 +337,24 @@ impl DaemonInvocationService { worktree_holder_admission: crate::daemon::native_integration::daemon_worktree_holder_admission_fence(), session_holder_databases: Arc::new(Mutex::new(BTreeMap::new())), + native_integration_status_broadcasts: Arc::new(Mutex::new(BTreeMap::new())), } } + /// The one status broadcast shared by the native-integration invocation + /// handler and every LSP session factory registered for `project_root`. + pub(crate) async fn native_integration_status_broadcast( + &self, + project_root: &Path, + ) -> Arc { + let mut broadcasts = self.native_integration_status_broadcasts.lock().await; + Arc::clone( + broadcasts + .entry(project_root.to_path_buf()) + .or_default(), + ) + } + pub(crate) fn github_stack_coordinator( &self, ) -> Arc { diff --git a/src/daemon/service/invocation/dispatch.rs b/src/daemon/service/invocation/dispatch.rs index 9021943b37..74ee785d6b 100644 --- a/src/daemon/service/invocation/dispatch.rs +++ b/src/daemon/service/invocation/dispatch.rs @@ -299,11 +299,18 @@ impl DaemonInvocationService { cancellation, } => { let observability_producer = self.observability_producer(project_root).await; + let status_broadcast = match project_root { + Some(project_root) => { + Some(self.native_integration_status_broadcast(project_root).await) + } + None => None, + }; Box::pin(execute_native_integration( request_id, configuration_runtime.clone(), native_integration_service, observability_producer, + status_broadcast, surface_operation, request, observed_at, diff --git a/src/daemon/service/invocation/native_integration.rs b/src/daemon/service/invocation/native_integration.rs index 9c337d856a..f052c5b131 100644 --- a/src/daemon/service/invocation/native_integration.rs +++ b/src/daemon/service/invocation/native_integration.rs @@ -44,6 +44,7 @@ use tracedecay_domain::{ NativeIntegrationPreviewDispositionV1, NativeIntegrationPreviewId, }; use tracedecay_store::NativeIntegrationStore; +use tracedecay_usecases::native_integration::NativeIntegrationStatusBroadcastV1; use tracedecay_usecases::observability::{ BoundedObservabilityProducerV1, record_native_integration_transition, }; @@ -68,6 +69,7 @@ pub(super) async fn execute_native_integration( registered: Option, owner: Option, observability_producer: Option>, + status_broadcast: Option>, surface_operation: crate::application_surface::ApplicationSurfaceOperation, request: NativeIntegrationSurfaceRequest, observed_at: UtcMicros, @@ -152,6 +154,7 @@ pub(super) async fn execute_native_integration( request, observed_at, signal, + status_broadcast, ) .await; match executed { @@ -212,12 +215,42 @@ impl NativeIntegrationExecutionV1 { } } +/// Publishes one observed transaction status to the project's read-only +/// notification fan-out. Delivery is best-effort observation: a missing +/// broadcast changes nothing about the operation result. +fn publish_transaction_status( + broadcast: Option<&Arc>, + status: &tracedecay_domain::NativeIntegrationTransactionStatusV1, +) { + if let Some(broadcast) = broadcast { + broadcast.publish(NativeIntegrationStatusProjectionV1::from(status)); + } +} + +/// Reads and publishes the durable status a mutation just advanced, from the +/// same blocking context that ran the mutation. +fn publish_current_transaction_status( + broadcast: Option<&Arc>, + owner: &DaemonNativeIntegrationOwner, + transaction_id: &tracedecay_domain::NativeIntegrationTransactionId, +) { + if broadcast.is_none() { + return; + } + if let Ok(Some(status)) = owner.service().status(NativeIntegrationStatusRequestV1 { + transaction_id: transaction_id.clone(), + }) { + publish_transaction_status(broadcast, &status); + } +} + /// Runs one operation against the mounted per-project owner. /// /// The kernel and its store bridge are synchronous (native Git plus a bounded /// store actor), so every owner call crosses to a blocking thread; the /// coordinator's own cancellation map keeps a running apply cancellable /// through the separate cancel operation. +#[allow(clippy::too_many_arguments)] async fn execute_with_owner( wire_request_id: &str, owner: DaemonNativeIntegrationOwner, @@ -225,6 +258,7 @@ async fn execute_with_owner( request: NativeIntegrationSurfaceRequest, observed_at: UtcMicros, signal: CancellationSignal, + status_broadcast: Option>, ) -> Result { let invalid = invalid_native_integration_request; match request { @@ -431,6 +465,7 @@ async fn execute_with_owner( }; let signal_preview = preview.clone(); let signal_approval = approval.clone(); + let applied_transaction_id = apply.transaction_id.clone(); let application_request = NativeIntegrationApplyRequestV1 { context, transaction_id: apply.transaction_id, @@ -438,7 +473,13 @@ async fn execute_with_owner( approval, observed_at, }; - match owner.service().apply(application_request, &signal) { + let apply_outcome = owner.service().apply(application_request, &signal); + publish_current_transaction_status( + status_broadcast.as_ref(), + &owner, + &applied_transaction_id, + ); + match apply_outcome { Ok(receipt) => { if let Some(runtime) = stack_runtime.as_ref() && let Some(stack_signal) = @@ -476,11 +517,14 @@ async fn execute_with_owner( .await .map_err(|_| unavailable_native_integration())?; match outcome { - Ok(Some(status)) => Ok(NativeIntegrationExecutionV1::without_preview( - NativeIntegrationSurfaceResultV1::Status( - NativeIntegrationStatusProjectionV1::from(&status), - ), - )), + Ok(Some(status)) => { + publish_transaction_status(status_broadcast.as_ref(), &status); + Ok(NativeIntegrationExecutionV1::without_preview( + NativeIntegrationSurfaceResultV1::Status( + NativeIntegrationStatusProjectionV1::from(&status), + ), + )) + } Ok(None) => Ok(NativeIntegrationExecutionV1::without_preview( NativeIntegrationSurfaceResultV1::unavailable( NativeIntegrationSurfaceUnavailableV1::UnknownTransaction, @@ -491,14 +535,22 @@ async fn execute_with_owner( } } NativeIntegrationSurfaceRequest::Cancel(cancel) => { + let cancelled_transaction_id = cancel.transaction_id.clone(); let application_request = NativeIntegrationCancelRequestV1 { transaction_id: cancel.transaction_id, requested_at: observed_at, }; - let outcome = - tokio::task::spawn_blocking(move || owner.service().cancel(application_request)) - .await - .map_err(|_| unavailable_native_integration())?; + let outcome = tokio::task::spawn_blocking(move || { + let disposition = owner.service().cancel(application_request); + publish_current_transaction_status( + status_broadcast.as_ref(), + &owner, + &cancelled_transaction_id, + ); + disposition + }) + .await + .map_err(|_| unavailable_native_integration())?; match outcome { Ok(disposition) => Ok(NativeIntegrationExecutionV1::without_preview( NativeIntegrationSurfaceResultV1::from_cancel(disposition), diff --git a/src/daemon/service/invocation/registrars/lsp.rs b/src/daemon/service/invocation/registrars/lsp.rs index 758be00729..1540d42bac 100644 --- a/src/daemon/service/invocation/registrars/lsp.rs +++ b/src/daemon/service/invocation/registrars/lsp.rs @@ -124,6 +124,13 @@ impl DaemonLspOwnerRegistrar { let diagnostic_records = Arc::new( tracedecay_usecases::feedback::diagnostics::DatabaseDiagnosticStore::new(database), ); + // Sessions from this factory forward the daemon-observed + // native-integration transaction statuses as read-only notifications; + // the invocation handler publishes into the same per-project fan-out. + let native_integration_status = self + .service + .native_integration_status_broadcast(&project_root) + .await; let factory = Arc::new( lsp_session_factory( runtime, @@ -142,7 +149,8 @@ impl DaemonLspOwnerRegistrar { .map_err(|error| TraceDecayError::Config { message: format!("could not construct LSP session factory: {error:?}"), })? - .with_upstream_capability_initializer(upstream_capability_initializer), + .with_upstream_capability_initializer(upstream_capability_initializer) + .with_native_integration_status_port(native_integration_status), ); self.register_lsp_owner( project_root, diff --git a/src/mcp/tools/handlers/dashboard.rs b/src/mcp/tools/handlers/dashboard.rs index 66757ddf12..2a60a534f8 100644 --- a/src/mcp/tools/handlers/dashboard.rs +++ b/src/mcp/tools/handlers/dashboard.rs @@ -30,7 +30,7 @@ use crate::dashboard::{ AutomationSchedulerReconciler, DEFAULT_PORT, DashboardApplicationRouters, DashboardApplicationRuntime, DashboardAutomationWriter, DashboardConfigurationApplyError, DashboardConfigurationApplyFuture, DashboardHttpRequestControlV1, DashboardScopeSetReadFuture, - DashboardScopeSetReadUnavailableV1, DashboardStateCompositionV1, bind_dashboard, + DashboardDaemonReadUnavailableV1, DashboardStateCompositionV1, bind_dashboard, build_state_with_automation_reconciler, router, validate_dashboard_host, }; @@ -161,7 +161,7 @@ impl DashboardApplicationRuntime for DashboardInvocationExecutorAdapter { let executor = Arc::clone(&self.executor); Box::pin(async move { let request = tracedecay_application::MultiRootScopeSetReadRequestV1::new(scope_set_id) - .map_err(|error| DashboardScopeSetReadUnavailableV1 { + .map_err(|error| DashboardDaemonReadUnavailableV1 { detail: error.to_string(), })?; let invocation = crate::daemon_contract::DaemonInvocationRequest::multi_root_scope_set_read( @@ -179,7 +179,7 @@ impl DashboardApplicationRuntime for DashboardInvocationExecutorAdapter { crate::daemon_client::InvocationCancellationPolicy::ReadOnly, ) .await - .map_err(|error| DashboardScopeSetReadUnavailableV1 { + .map_err(|error| DashboardDaemonReadUnavailableV1 { detail: format!("the daemon multi-root read transport failed: {error:?}"), })?; match response.outcome { @@ -188,30 +188,93 @@ impl DashboardApplicationRuntime for DashboardInvocationExecutorAdapter { .. } => packet .payload - .ok_or_else(|| DashboardScopeSetReadUnavailableV1 { + .ok_or_else(|| DashboardDaemonReadUnavailableV1 { detail: "the daemon multi-root read returned no evidence payload" .to_owned(), }), crate::daemon_contract::DaemonInvocationOutcome::ApplicationProblem { problem, - } => Err(DashboardScopeSetReadUnavailableV1 { + } => Err(DashboardDaemonReadUnavailableV1 { detail: format!( "the daemon rejected the multi-root read: {}", problem.safe_message() ), }), crate::daemon_contract::DaemonInvocationOutcome::Problem { problem } => { - Err(DashboardScopeSetReadUnavailableV1 { + Err(DashboardDaemonReadUnavailableV1 { detail: format!("the daemon refused the multi-root read: {problem:?}"), }) } - _ => Err(DashboardScopeSetReadUnavailableV1 { + _ => Err(DashboardDaemonReadUnavailableV1 { detail: "the daemon multi-root read answered with a foreign outcome" .to_owned(), }), } }) } + + fn native_integration_status( + &self, + control: DashboardHttpRequestControlV1, + transaction_id: tracedecay_domain::NativeIntegrationTransactionId, + ) -> crate::dashboard::DashboardNativeIntegrationStatusFuture<'_> { + let executor = Arc::clone(&self.executor); + Box::pin(async move { + dashboard_native_integration_status(executor.as_ref(), &control, transaction_id).await + }) + } +} + +/// Resolves one native-integration status read over the catalog-bound +/// dashboard surface, answering the same application result CLI and MCP +/// project. Read-only: only the status operation carries a dashboard binding. +pub(crate) async fn dashboard_native_integration_status( + executor: &dyn crate::daemon_client::DaemonInvocationExecutor, + control: &crate::dashboard::DashboardHttpRequestControlV1, + transaction_id: tracedecay_domain::NativeIntegrationTransactionId, +) -> std::result::Result< + tracedecay_application::NativeIntegrationSurfaceResultV1, + crate::dashboard::DashboardDaemonReadUnavailableV1, +> { + use crate::dashboard::DashboardDaemonReadUnavailableV1; + + let result = crate::application_surface::resolve_dashboard_application_surface( + crate::application_surface::ApplicationSurfaceOperation::NativeIntegrationStatus, + control.request_id(), + crate::application_surface::ApplicationSurfaceRequest::NativeIntegration( + crate::application_surface::NativeIntegrationSurfaceRequest::Status( + tracedecay_application::NativeIntegrationStatusSurfaceRequest { transaction_id }, + ), + ), + crate::daemon_client::RequestedOutputFormat::Json, + Some(executor), + ) + .await + .map_err(|error| DashboardDaemonReadUnavailableV1 { + detail: format!("the dashboard native-integration surface is unavailable: {error}"), + })?; + let envelope = result + .result + .map_err(|problem| DashboardDaemonReadUnavailableV1 { + detail: format!( + "the daemon rejected the native-integration status read: {}", + problem.problem.message + ), + })?; + let tracedecay_application::ApplicationOutcome::Evidence(packet) = envelope.outcome else { + return Err(DashboardDaemonReadUnavailableV1 { + detail: "the native-integration status read answered with a foreign outcome" + .to_owned(), + }); + }; + let payload = packet + .payload + .ok_or_else(|| DashboardDaemonReadUnavailableV1 { + detail: "the native-integration status read returned no evidence payload".to_owned(), + })?; + serde_json::from_value(payload).map_err(|_| DashboardDaemonReadUnavailableV1 { + detail: "the native-integration status payload violated its wire contract".to_owned(), + }) } fn append_direct_configuration_mutations( diff --git a/tests/native_integration_surface_mount.rs b/tests/native_integration_surface_mount.rs index 4438491733..361bbce6c4 100644 --- a/tests/native_integration_surface_mount.rs +++ b/tests/native_integration_surface_mount.rs @@ -152,6 +152,36 @@ fn every_journey_operation_binds_to_cli_and_mcp_and_withholds_http() { } } +/// The dashboard consumes the read-only status projection over the same +/// application result; every mutating transaction operation stays off the +/// dashboard so no gateway can advance a transaction, apply edits, or mutate +/// Git from it. +#[test] +fn only_the_status_read_carries_a_dashboard_binding() { + let contribution = + native_integration_surface_catalog_contribution().expect("catalog contribution"); + for (_, name) in JOURNEY { + let declares_dashboard = contribution.bindings().iter().any(|binding| { + binding.operation().as_str() == name + && binding.surface() == BindingSurface::Dashboard + }); + assert_eq!( + declares_dashboard, + name == "native_integration_status", + "{name} dashboard exposure must match the read-only status contract" + ); + } + let resolved = resolve_catalog_tool_binding( + BindingSurface::Dashboard, + "tracedecay_native_integration_status", + ) + .expect("dashboard binding resolution"); + assert!( + resolved.is_some(), + "the status dashboard binding is declared but the production resolver answers nothing" + ); +} + fn assert_cli_and_mcp_bindings(contribution: &CatalogContributionV1, name: &str) { for surface in [BindingSurface::Cli, BindingSurface::Mcp] { assert!( From 0a07b931b0087cdd3eb1ece890063a0ba8584c84 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 06:24:58 +0000 Subject: [PATCH 03/12] feat(privacy): daemon-owned at-rest rescan quarantines legacy hits Project-open now spawns a bounded background PrivacyRemediation pass per adopted store: the owner re-runs the current in-process detector over every served project-memory fact, redacts what the detector can make safe, quarantines what it cannot, and settles every mutation through the canonical curation authority so a durable curation receipt records what changed. No scanner binary runs, no network is touched, and no unsanitized payload is persisted back. Co-authored-by: Zack Jackson --- crates/tracedecay-usecases/src/memory/mod.rs | 4 + .../src/memory/privacy_remediation.rs | 238 ++++++++++++ src/daemon.rs | 1 + src/daemon/privacy_remediation.rs | 354 ++++++++++++++++++ src/daemon/project_open_owners.rs | 8 + 5 files changed, 605 insertions(+) create mode 100644 crates/tracedecay-usecases/src/memory/privacy_remediation.rs create mode 100644 src/daemon/privacy_remediation.rs diff --git a/crates/tracedecay-usecases/src/memory/mod.rs b/crates/tracedecay-usecases/src/memory/mod.rs index 385abed95d..b1e72dab2e 100644 --- a/crates/tracedecay-usecases/src/memory/mod.rs +++ b/crates/tracedecay-usecases/src/memory/mod.rs @@ -12,6 +12,7 @@ mod curation; mod dashboard; mod error; mod graph; +mod privacy_remediation; mod project_memory; mod sanitize; @@ -25,6 +26,9 @@ pub use curation::{ ProjectMemoryFactMutationTarget, }; pub use error::{MemoryApplicationError, MemoryMutationError}; +pub use privacy_remediation::{ + PrivacyRemediationTriggerV1, ProjectMemoryPrivacyRemediationReceiptV1, +}; pub use project_memory::{ ProjectMemoryFactAddEffectMaterialV1, ProjectMemoryFactAddPreflight, ProjectMemoryFactAddRequest, ProjectMemoryFactAddRequestOutcome, automatic_fact_add_command, diff --git a/crates/tracedecay-usecases/src/memory/privacy_remediation.rs b/crates/tracedecay-usecases/src/memory/privacy_remediation.rs new file mode 100644 index 0000000000..204ba96596 --- /dev/null +++ b/crates/tracedecay-usecases/src/memory/privacy_remediation.rs @@ -0,0 +1,238 @@ +//! At-rest privacy remediation over persisted project-memory facts. +//! +//! Ingest sanitizes before persistence, but rows written under an older +//! detector revision (or under legacy paths that predate the hard cut) can +//! hold values the current detector would refuse. This owner re-runs the +//! current in-process detector over every currently served fact, redacts what +//! the detector can make safe, quarantines what it cannot, and settles every +//! mutation through the one canonical curation authority so the durable +//! curation receipt records exactly what changed. Nothing here executes a +//! scanner binary or touches the network, and no unsanitized payload is ever +//! persisted back. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tracedecay_domain::{Confidence, FactCategoryV1, UtcMicros}; +use tracedecay_runtime_core::privacy::{ + MEMORY_FACT_SANITIZER_VERSION_V1, MemoryFactSanitizationV1, sanitize_memory_fact_payload, +}; +use tracedecay_store::{ + FactReadControl, FactWriteControl, ProjectMemoryFactCurationReceiptV1, + ProjectMemoryFactListQueryV1, ProjectMemoryFactProjectionV1, ProjectMemoryFactStore, + ProjectMemoryFactUpdatePatchV1, ProjectMemoryFactV1, +}; + +use super::MemoryApplication; +use super::context::MemoryOperationContext; +use super::curation::{ProjectMemoryCurationMutationTarget, ProjectMemoryCurationOperation}; +use super::error::{MemoryApplicationError, MemoryMutationError}; + +/// Why an at-rest rescan ran. Recorded on the receipt so operators can +/// distinguish daemon-adopted maintenance from an explicit request. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyRemediationTriggerV1 { + /// The daemon adopted the store under the current detector revision. + DetectorRevisionAdoption, + /// An operator or Doctor explicitly requested a rescan. + OperatorRequest, +} + +/// Truthful outcome of one at-rest rescan. `curation_receipt` is present +/// exactly when the rescan remediated at least one fact; the durable receipt +/// row is owned by the fact store's curation authority. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct ProjectMemoryPrivacyRemediationReceiptV1 { + pub detector_revision: String, + pub trigger: PrivacyRemediationTriggerV1, + pub scanned_facts: u64, + pub clean_facts: u64, + pub redacted_facts: u64, + pub quarantined_facts: u64, + pub curation_receipt: Option, + pub started_at: UtcMicros, + pub finished_at: UtcMicros, +} + +/// One page of currently served facts per authority read. +const RESCAN_PAGE_LIMIT: usize = 64; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SanitizedFactPayloadWire { + content: String, + category: FactCategoryV1, + tags: Vec, + entities: Vec, + metadata: Value, + #[serde(default)] + source_label: Option, +} + +enum FactRescanDispositionV1 { + Clean, + Redact(ProjectMemoryFactUpdatePatchV1), + Quarantine, +} + +impl MemoryApplication { + /// Rescans every currently served fact under the current detector + /// revision, remediating hits through the canonical curation authority. + /// + /// The rescan fails closed: a fact whose payload cannot be re-evaluated + /// aborts the run with a typed error instead of skipping it silently. + pub async fn privacy_remediation_rescan( + &self, + trigger: PrivacyRemediationTriggerV1, + started_at: UtcMicros, + finished_at: impl Fn() -> UtcMicros, + read_control: &FactReadControl, + write_control: &FactWriteControl, + ) -> Result { + let mut scanned_facts = 0_u64; + let mut clean_facts = 0_u64; + let mut operations = Vec::new(); + let mut redacted_facts = 0_u64; + let mut quarantined_facts = 0_u64; + let mut after_fact_id = None; + loop { + let query = ProjectMemoryFactListQueryV1::new( + self.owner.clone(), + None, + None, + after_fact_id.take(), + RESCAN_PAGE_LIMIT, + )?; + let page = self + .list_project_memory_facts(query, read_control) + .await?; + for projection in page.facts() { + let ProjectMemoryFactProjectionV1::Available(fact) = projection else { + // A withheld projection serves no payload, so there is + // nothing at rest for this pass to disclose or rewrite. + continue; + }; + scanned_facts = scanned_facts.saturating_add(1); + let target = ProjectMemoryCurationMutationTarget::new( + fact.fact_id().clone(), + fact.last_event_id().clone(), + ); + match rescan_fact(fact)? { + FactRescanDispositionV1::Clean => { + clean_facts = clean_facts.saturating_add(1); + } + FactRescanDispositionV1::Redact(patch) => { + redacted_facts = redacted_facts.saturating_add(1); + operations.push(ProjectMemoryCurationOperation::Update { + target: target.clone(), + patch, + evidence_facts: vec![target], + confidence: remediation_confidence()?, + reason: "at-rest privacy rescan redacted detector findings".to_owned(), + }); + } + FactRescanDispositionV1::Quarantine => { + quarantined_facts = quarantined_facts.saturating_add(1); + operations.push(ProjectMemoryCurationOperation::Remove { + target: target.clone(), + evidence_facts: vec![target], + confidence: remediation_confidence()?, + reason: "at-rest privacy rescan quarantined this fact".to_owned(), + }); + } + } + } + match page.next_after_fact_id() { + Some(next) => after_fact_id = Some(next.clone()), + None => break, + } + } + let curation_receipt = if operations.is_empty() { + None + } else { + let context = MemoryOperationContext::generated( + &self.owner, + "privacy_remediation_rescan", + None, + )?; + let receipt = self + .apply_project_memory_curation( + operations, + remediation_confidence()?, + context, + None, + write_control, + ) + .await + .map_err(|error| match error { + MemoryMutationError::Application(error) => error, + MemoryMutationError::InvalidAuthorityResult { error, .. } => error, + })?; + Some(receipt) + }; + Ok(ProjectMemoryPrivacyRemediationReceiptV1 { + detector_revision: MEMORY_FACT_SANITIZER_VERSION_V1.to_owned(), + trigger, + scanned_facts, + clean_facts, + redacted_facts, + quarantined_facts, + curation_receipt, + started_at, + finished_at: finished_at(), + }) + } +} + +fn remediation_confidence() -> Result { + Confidence::new(1.0).map_err(|_| MemoryApplicationError::InvalidInput { + invariant: "privacy remediation confidence", + }) +} + +/// Re-evaluates one served fact's canonical payload wire under the current +/// detector. The wire mirrors the ingest sanitizer exactly, so an unchanged +/// durable answer proves the persisted row already satisfies the revision. +fn rescan_fact(fact: &ProjectMemoryFactV1) -> Result { + let mut wire = json!({ + "content": fact.content(), + "category": fact.category(), + "tags": fact.tags(), + "entities": fact.entities(), + "metadata": fact.metadata(), + }); + if let Some(source_label) = fact.source_label() + && let Value::Object(object) = &mut wire + { + object.insert( + "source_label".to_owned(), + Value::String(source_label.to_owned()), + ); + } + let sanitized = sanitize_memory_fact_payload(wire.clone()).map_err(|_| { + MemoryApplicationError::InvalidInput { + invariant: "at-rest privacy rescan detector evaluation", + } + })?; + let MemoryFactSanitizationV1::Durable { payload, .. } = sanitized else { + return Ok(FactRescanDispositionV1::Quarantine); + }; + if payload == wire { + return Ok(FactRescanDispositionV1::Clean); + } + let sanitized = serde_json::from_value::(payload).map_err(|_| { + MemoryApplicationError::InvalidInput { + invariant: "at-rest privacy rescan sanitized payload", + } + })?; + let patch = ProjectMemoryFactUpdatePatchV1::new( + Some(sanitized.content), + Some(sanitized.category), + Some(sanitized.source_label), + Some(sanitized.tags), + Some(sanitized.entities), + Some(sanitized.metadata), + None, + )?; + Ok(FactRescanDispositionV1::Redact(patch)) +} diff --git a/src/daemon.rs b/src/daemon.rs index 9820427bea..d7f4dda4d1 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -185,6 +185,7 @@ pub(crate) mod dashboard_automation; mod dashboard_configuration_test_runtime; pub(crate) mod doctor_kernel; pub(crate) mod hook_v2_replay; +pub(crate) mod privacy_remediation; pub(crate) mod project_open_owners; #[cfg(feature = "test-transport")] pub(crate) use dashboard_configuration_test_runtime::{ diff --git a/src/daemon/privacy_remediation.rs b/src/daemon/privacy_remediation.rs new file mode 100644 index 0000000000..8f3c24c914 --- /dev/null +++ b/src/daemon/privacy_remediation.rs @@ -0,0 +1,354 @@ +//! Daemon-owned at-rest privacy remediation. +//! +//! Project-open spawns one bounded background rescan per adopted project +//! store after fail-closed admission has finished; it never blocks admission +//! or retrieval. The rescan re-runs the current in-process detector over +//! persisted project-memory facts, redacts what the detector can make safe, +//! quarantines what it cannot, and settles every mutation through the +//! canonical curation authority so a durable curation receipt records what +//! changed. No scanner binary runs and no unsanitized payload is persisted. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use tracedecay_domain::UtcMicros; +use tracedecay_store::{FactReadControl, FactWriteControl}; +use tracedecay_usecases::memory::{ + PrivacyRemediationTriggerV1, ProjectMemoryPrivacyRemediationReceiptV1, +}; + +use crate::daemon_client::invocation_now_micros; +use crate::errors::Result; +use crate::tracedecay::TraceDecay; + +/// Spawns the bounded background rescan for one adopted project store. +pub(crate) fn spawn_project_memory_privacy_remediation(graph: Arc) { + tokio::spawn(async move { + let project = graph.project_root().display().to_string(); + match run_project_memory_privacy_remediation( + &graph, + PrivacyRemediationTriggerV1::DetectorRevisionAdoption, + ) + .await + { + Ok(receipt) => { + tracing::info!( + event = "project_memory_privacy_remediation", + project = %project, + detector_revision = %receipt.detector_revision, + scanned_facts = receipt.scanned_facts, + clean_facts = receipt.clean_facts, + redacted_facts = receipt.redacted_facts, + quarantined_facts = receipt.quarantined_facts, + ); + } + Err(error) => { + tracing::warn!( + event = "project_memory_privacy_remediation_failed", + project = %project, + %error, + ); + } + } + }); +} + +/// Runs one at-rest rescan over the project's persisted memory facts. This is +/// the production entry point shared by the daemon project-open background +/// task and explicit operator paths. +pub(crate) async fn run_project_memory_privacy_remediation( + graph: &TraceDecay, + trigger: PrivacyRemediationTriggerV1, +) -> Result { + let memory = graph.project_memory_application().await?; + let started_at = UtcMicros(invocation_now_micros().0); + memory + .privacy_remediation_rescan( + trigger, + started_at, + || UtcMicros(invocation_now_micros().0), + &remediation_read_control(), + &remediation_write_control(), + ) + .await + .map_err(tracedecay_usecases::memory::memory_application_error) +} + +fn remediation_read_control() -> FactReadControl { + FactReadControl::new(Arc::new(|| false)) +} + +/// One-shot commit gate: the rescan settles exactly one curation batch, and a +/// second commit attempt under the same control is refused. +fn remediation_write_control() -> FactWriteControl { + let granted = Arc::new(AtomicBool::new(false)); + FactWriteControl::new( + Arc::new(|| false), + Arc::new(move || { + granted + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + }), + ) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use serde_json::{Value, json}; + use tempfile::TempDir; + use tracedecay_domain::{ + ComponentVersion, Confidence, FactCategoryV1, FactOwnerV1, FactPayloadV1, ProjectId, + ProvenanceId, SanitizationReceiptId, SanitizationReceiptRefV1, SanitizationReceiptV1, + SanitizerDispositionV1, SensitivityV1, + }; + use tracedecay_store::{ + ProjectMemoryFactAddMaterialV1, ProjectMemoryFactListQueryV1, + ProjectMemoryFactProjectionV1, ProjectMemoryFactStore, + }; + use tracedecay_usecases::memory::{MemoryApplication, PrivacyRemediationTriggerV1}; + + use super::{remediation_read_control, remediation_write_control}; + use crate::daemon::profile_identity; + use crate::daemon::store_runtime::session_registry::DaemonSessionRuntimeRegistryV1; + use crate::store::DatabaseFactStore; + + fn secret() -> String { + ["sk", "-test-", "1234567890abcdef"].concat() + } + + fn enrolled_root(base: &Path, project_id: &ProjectId) -> PathBuf { + let root = base.join(project_id.as_str()); + std::fs::create_dir_all(&root).expect("project root"); + crate::storage::pin_fixture_repository_identity(&root, project_id.as_str()) + .expect("project enrollment"); + root + } + + /// The memory-fact receipt identity recipe, restated here as the reverse + /// authority so the fixture can write exactly what an older binary (same + /// pinned revision string, older vendored detector rules) wrote: a + /// receipt-bound raw payload the current detector rules never evaluated. + fn legacy_receipt_id( + payload_reference: &tracedecay_domain::PayloadReferenceV1, + sanitizer_version: &ComponentVersion, + disposition: SanitizerDispositionV1, + sensitivity: SensitivityV1, + ) -> SanitizationReceiptId { + use sha2::{Digest, Sha256}; + + let mut hasher = Sha256::new(); + for part in [ + b"tracedecay.privacy.memory-fact.receipt.v1\0".as_slice(), + sanitizer_version.as_str().as_bytes(), + disposition.as_str().as_bytes(), + sensitivity.as_str().as_bytes(), + payload_reference.digest().as_str().as_bytes(), + &payload_reference.byte_len().to_be_bytes(), + ] { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part); + } + SanitizationReceiptId::new(format!( + "memory-fact-receipt.v1.{}", + hex::encode(hasher.finalize()) + )) + .expect("legacy receipt id") + } + + /// Persists one fact exactly as an ingest path running an older vendored + /// ruleset could have: the receipt binds the raw payload without the + /// current detector rules ever evaluating it. The store's write firewall + /// pins the sanitizer revision string, so the legacy condition being + /// simulated is a ruleset refresh within the pinned revision. + async fn seed_legacy_fact( + database: &crate::db::Database, + owner: &FactOwnerV1, + label: &str, + content: &str, + metadata: Value, + ) { + let mut tags = Vec::new(); + let mut entities = Vec::new(); + let payload_reference = FactPayloadV1::canonicalize_material( + content, + FactCategoryV1::Project, + &mut tags, + &mut entities, + &metadata, + None, + ) + .expect("legacy payload reference"); + let sanitizer_version = ComponentVersion::new( + tracedecay_runtime_core::privacy::MEMORY_FACT_SANITIZER_VERSION_V1, + ) + .expect("pinned detector revision"); + let receipt = SanitizationReceiptV1::new( + SanitizationReceiptRefV1::new( + legacy_receipt_id( + &payload_reference, + &sanitizer_version, + SanitizerDispositionV1::Accepted, + SensitivityV1::NonSensitive, + ), + sanitizer_version, + ) + .expect("legacy receipt reference"), + SanitizerDispositionV1::Accepted, + SensitivityV1::NonSensitive, + Some(payload_reference), + ) + .expect("legacy sanitization receipt"); + let command = ProjectMemoryFactAddMaterialV1::new( + owner.clone(), + content.to_owned(), + FactCategoryV1::Project, + None, + tags, + entities, + metadata, + receipt, + None, + Confidence::new(0.8).expect("legacy trust"), + None, + ) + .expect("legacy fact material") + .into_command( + ProvenanceId::new(format!("operation.privacy-legacy.{label}")) + .expect("legacy operation id"), + ) + .expect("legacy fact command"); + DatabaseFactStore::new(database) + .add_project_memory_fact(command, &remediation_write_control()) + .await + .expect("persist legacy fact"); + } + + async fn served_contents( + memory: &MemoryApplication>, + owner: &FactOwnerV1, + ) -> Vec { + let page = memory + .list_project_memory_facts( + ProjectMemoryFactListQueryV1::new(owner.clone(), None, None, None, 64) + .expect("list query"), + &remediation_read_control(), + ) + .await + .expect("list served facts"); + page.facts() + .iter() + .filter_map(|projection| match projection { + ProjectMemoryFactProjectionV1::Available(fact) => Some(fact.content().to_owned()), + ProjectMemoryFactProjectionV1::Unavailable(_) => None, + }) + .collect() + } + + #[tokio::test] + async fn at_rest_rescan_quarantines_and_redacts_legacy_detector_hits() { + let temp = TempDir::new().expect("privacy remediation fixture root"); + let profile_root = temp.path().join("profile"); + let project_id = + ProjectId::new("project.privacy-remediation.fixture").expect("project id"); + let project_root = enrolled_root(temp.path(), &project_id); + let _database_scope = + crate::db::enter_daemon_database_scope(&profile_root, 43, "privacy remediation test") + .expect("daemon database scope"); + let identity = profile_identity::load_or_create(&profile_root).expect("profile identity"); + let registry = DaemonSessionRuntimeRegistryV1::open(identity) + .await + .expect("daemon registry"); + let database = registry + .project_memory(project_id.clone(), [project_root.clone()]) + .await + .expect("project memory authority"); + let owner = FactOwnerV1::Project { + project_id: project_id.clone(), + }; + + seed_legacy_fact( + &database, + &owner, + "clean", + "the retry budget is three attempts", + json!({"fixture": "clean"}), + ) + .await; + seed_legacy_fact( + &database, + &owner, + "redactable", + &format!("deploys authenticate with the token {}", secret()), + json!({"fixture": "redactable"}), + ) + .await; + seed_legacy_fact( + &database, + &owner, + "quarantinable", + "the staging credentials map is keyed by raw token", + json!({ secret(): "staging" }), + ) + .await; + + let memory = MemoryApplication::new(owner.clone(), DatabaseFactStore::new(&database)) + .expect("owner-bound memory application"); + let receipt = memory + .privacy_remediation_rescan( + PrivacyRemediationTriggerV1::OperatorRequest, + tracedecay_domain::UtcMicros(1), + || tracedecay_domain::UtcMicros(2), + &remediation_read_control(), + &remediation_write_control(), + ) + .await + .expect("at-rest privacy rescan"); + + assert_eq!(receipt.trigger, PrivacyRemediationTriggerV1::OperatorRequest); + assert_eq!(receipt.scanned_facts, 3); + assert_eq!(receipt.clean_facts, 1); + assert_eq!(receipt.redacted_facts, 1); + assert_eq!(receipt.quarantined_facts, 1); + let curation = receipt + .curation_receipt + .as_ref() + .expect("remediation hits settle one durable curation receipt"); + assert_eq!(curation.facts_updated(), 1); + assert_eq!(curation.facts_removed(), 1); + + // Served content no longer carries the secret anywhere, and the + // quarantined fact stopped being served entirely. + let served = served_contents(&memory, &owner).await; + assert_eq!(served.len(), 2, "the quarantined fact must not serve"); + assert!( + served.iter().all(|content| !content.contains(&secret())), + "no served fact may retain the detector hit" + ); + assert!( + served + .iter() + .any(|content| content.contains("deploys authenticate with the token")), + "the redactable fact must stay served with sanitized content" + ); + + // A second pass over the remediated store is clean and settles no + // further mutation: the rescan is idempotent. + let second = memory + .privacy_remediation_rescan( + PrivacyRemediationTriggerV1::DetectorRevisionAdoption, + tracedecay_domain::UtcMicros(3), + || tracedecay_domain::UtcMicros(4), + &remediation_read_control(), + &remediation_write_control(), + ) + .await + .expect("idempotent rescan"); + assert_eq!(second.scanned_facts, 2); + assert_eq!(second.clean_facts, 2); + assert_eq!(second.redacted_facts, 0); + assert_eq!(second.quarantined_facts, 0); + assert!(second.curation_receipt.is_none()); + } +} diff --git a/src/daemon/project_open_owners.rs b/src/daemon/project_open_owners.rs index f9d98434fa..8150323c3e 100644 --- a/src/daemon/project_open_owners.rs +++ b/src/daemon/project_open_owners.rs @@ -1086,6 +1086,14 @@ pub(super) async fn register_project_open_production_owners( delivery_settlements, ); + // At-rest privacy remediation runs as bounded background work after + // fail-closed admission: rescan persisted project-memory facts under the + // current detector revision, quarantining hits through the canonical + // curation authority. It never blocks admission or retrieval. + crate::daemon::privacy_remediation::spawn_project_memory_privacy_remediation(Arc::clone( + &graph, + )); + // Semantic restore can decode a large durable generation. Keep that // capability-specific warm-up behind every independent production owner // so diagnostics, tests, feedback, and LSP reads remain available while From 3d18f8e408cf560a257a97e9cb7dab8f0ce731d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 06:31:21 +0000 Subject: [PATCH 04/12] style(native-integration): sort status broadcast with sort_by_key Co-authored-by: Zack Jackson --- .../src/native_integration/status_broadcast.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs b/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs index 630106e07b..514517708a 100644 --- a/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs +++ b/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs @@ -54,7 +54,7 @@ impl NativeIntegrationStatusBroadcastV1 { return Vec::new(); }; let mut recent = statuses.values().cloned().collect::>(); - recent.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); + recent.sort_by_key(|status| std::cmp::Reverse(status.updated_at)); recent.truncate(maximum); recent } From 74a044d716252d33d80e61f74e7bc3deea73b4fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 07:33:03 +0000 Subject: [PATCH 05/12] refactor(cleanup): trim narration and unused surface from the mounts Co-authored-by: Zack Jackson --- .../src/application_surface.rs | 5 ++--- crates/tracedecay-dashboard-api/src/lib.rs | 5 ++--- .../src/multi_root_api.rs | 14 ++++++-------- .../src/native_integration_api.rs | 3 +-- crates/tracedecay-lsp/src/lib.rs | 1 - crates/tracedecay-lsp/src/native_integration.rs | 4 ++-- .../src/protocol/lifecycle_controller.rs | 4 +--- .../src/protocol/native_integration_controller.rs | 5 ++--- .../src/memory/privacy_remediation.rs | 7 +++---- .../src/native_integration/status_broadcast.rs | 15 ++++++--------- src/daemon/privacy_remediation.rs | 4 +--- src/daemon/project_open_owners.rs | 6 ++---- src/daemon/service/invocation/registrars/lsp.rs | 5 ++--- src/mcp/tools/handlers/dashboard.rs | 2 +- 14 files changed, 31 insertions(+), 49 deletions(-) diff --git a/crates/tracedecay-dashboard-api/src/application_surface.rs b/crates/tracedecay-dashboard-api/src/application_surface.rs index fb080759e9..d13df00484 100644 --- a/crates/tracedecay-dashboard-api/src/application_surface.rs +++ b/crates/tracedecay-dashboard-api/src/application_surface.rs @@ -123,9 +123,8 @@ pub trait DashboardApplicationRuntime: Send + Sync { /// Reads one native-integration transaction status through the daemon /// transport, answering the same application result the CLI and MCP - /// surfaces project. Read-only: the dashboard can observe a transaction - /// but never preflight, approve, apply, or cancel one, apply edits, or - /// mutate Git through this path. + /// surfaces project. Read-only: mutating operations carry no dashboard + /// binding. fn native_integration_status<'a>( &'a self, control: DashboardHttpRequestControlV1, diff --git a/crates/tracedecay-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index 9bf2a93337..929554f2e8 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -2429,9 +2429,8 @@ mod authority_tests { assert_eq!(scope_set_digest, expected_digest); assert_eq!(root_count, 1); - // The explicit target always outranks any default: the same explicit - // resolution against a runtime persisting a different collection is a - // typed not-persisted state, never a silent fallback. + // An explicit target naming an unpersisted collection is a typed + // not-persisted state, never a silent fallback to another scope set. let missing = multi_root_api::resolve_collection_capability( &state, Some(dashboard_lcm_test_control()), diff --git a/crates/tracedecay-dashboard-api/src/multi_root_api.rs b/crates/tracedecay-dashboard-api/src/multi_root_api.rs index b0e22c5d69..df78bb249e 100644 --- a/crates/tracedecay-dashboard-api/src/multi_root_api.rs +++ b/crates/tracedecay-dashboard-api/src/multi_root_api.rs @@ -1,13 +1,11 @@ //! Named multi-root collection resolution for the dashboard. //! -//! The dashboard resolves a named collection (a persisted scope set with a -//! frozen revision and canonical member order) through the daemon application -//! transport. Selection precedence is owned by the application resolver: a -//! default collection can never outrank an explicit target. No default -//! collection is currently configurable — the retired -//! `query.default_collection.v1` setting fails closed in old stores and no -//! replacement setting exists — so an unnamed resolution reports the typed -//! no-collection state instead of guessing a scope set. +//! The dashboard resolves a named collection through the daemon application +//! transport; selection precedence and read mapping are owned by the +//! application resolver. No default collection is currently configurable — +//! the retired `query.default_collection.v1` setting fails closed in old +//! stores and no replacement setting exists — so an unnamed resolution +//! reports the typed no-collection state instead of guessing a scope set. use axum::Json; use axum::extract::{Extension, Query, State}; diff --git a/crates/tracedecay-dashboard-api/src/native_integration_api.rs b/crates/tracedecay-dashboard-api/src/native_integration_api.rs index 9fbc01633a..6ec65d851d 100644 --- a/crates/tracedecay-dashboard-api/src/native_integration_api.rs +++ b/crates/tracedecay-dashboard-api/src/native_integration_api.rs @@ -4,8 +4,7 @@ //! surfaces project (`NativeIntegrationSurfaceResultV1`), resolved through //! the daemon transport under the live request controls. No mutating //! native-integration operation is reachable here: the dashboard can observe -//! a transaction but never preflight, approve, apply, or cancel one, apply -//! edits, or mutate Git. +//! a transaction but never advance one, apply edits, or mutate Git. use axum::Json; use axum::extract::{Extension, Query, State}; diff --git a/crates/tracedecay-lsp/src/lib.rs b/crates/tracedecay-lsp/src/lib.rs index 214f6e6cea..146e7c3dc1 100644 --- a/crates/tracedecay-lsp/src/lib.rs +++ b/crates/tracedecay-lsp/src/lib.rs @@ -98,7 +98,6 @@ pub use gateway::{ strict_file_uri_path, strict_file_url, valid_raw_uri_path, }; pub use native_integration::{ - MAX_NATIVE_INTEGRATION_STATUS_BYTES, MAX_NATIVE_INTEGRATION_STATUS_PER_POLL, NativeIntegrationStatusPort, TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD, }; pub use overlay::{ diff --git a/crates/tracedecay-lsp/src/native_integration.rs b/crates/tracedecay-lsp/src/native_integration.rs index 40c7809aa7..ad5c1609e6 100644 --- a/crates/tracedecay-lsp/src/native_integration.rs +++ b/crates/tracedecay-lsp/src/native_integration.rs @@ -12,10 +12,10 @@ pub const TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD: &str = "tracedecay/nativeIntegrationStatus"; /// The most recent status projections one session flush may forward. -pub const MAX_NATIVE_INTEGRATION_STATUS_PER_POLL: usize = 16; +pub(crate) const MAX_NATIVE_INTEGRATION_STATUS_PER_POLL: usize = 16; /// Bytes reserved on the outbound queue before a status flush runs. -pub const MAX_NATIVE_INTEGRATION_STATUS_BYTES: usize = 16 * 1024; +pub(crate) const MAX_NATIVE_INTEGRATION_STATUS_BYTES: usize = 16 * 1024; /// Daemon-owned read of recently observed native-integration transaction /// statuses. Implementations return current bounded projections; each session diff --git a/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs b/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs index f3ba6de0f1..1c63537f69 100644 --- a/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs +++ b/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs @@ -179,9 +179,7 @@ where } /// Mounts the daemon-owned read of recently observed native-integration - /// transaction statuses. The session forwards them as server-to-client - /// notifications only; no client-callable native-integration method is - /// admitted through the gateway. + /// transaction statuses, forwarded as server-to-client notifications only. #[must_use] pub fn with_native_integration_status_port( mut self, diff --git a/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs b/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs index 5301b31095..b83a5c5bb8 100644 --- a/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs +++ b/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs @@ -3,9 +3,8 @@ //! One bounded flush forwards the daemon's application status projections to a //! ready session as `tracedecay/nativeIntegrationStatus` notifications. The //! session dedupes per transaction, so a port re-returning an unchanged status -//! never re-notifies. This path admits no client method: the gateway cannot -//! start, approve, apply, or cancel a native integration, apply edits, or -//! mutate Git from here. +//! never re-notifies. See [`crate::native_integration`] for the gateway +//! constraint: this path admits no client method. use tracedecay_application::NativeIntegrationStatusProjectionV1; use tracedecay_domain::NativeIntegrationTransactionId; diff --git a/crates/tracedecay-usecases/src/memory/privacy_remediation.rs b/crates/tracedecay-usecases/src/memory/privacy_remediation.rs index 204ba96596..79d99c2db9 100644 --- a/crates/tracedecay-usecases/src/memory/privacy_remediation.rs +++ b/crates/tracedecay-usecases/src/memory/privacy_remediation.rs @@ -10,7 +10,7 @@ //! scanner binary or touches the network, and no unsanitized payload is ever //! persisted back. -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::{Value, json}; use tracedecay_domain::{Confidence, FactCategoryV1, UtcMicros}; use tracedecay_runtime_core::privacy::{ @@ -29,8 +29,7 @@ use super::error::{MemoryApplicationError, MemoryMutationError}; /// Why an at-rest rescan ran. Recorded on the receipt so operators can /// distinguish daemon-adopted maintenance from an explicit request. -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PrivacyRemediationTriggerV1 { /// The daemon adopted the store under the current detector revision. DetectorRevisionAdoption, @@ -41,7 +40,7 @@ pub enum PrivacyRemediationTriggerV1 { /// Truthful outcome of one at-rest rescan. `curation_receipt` is present /// exactly when the rescan remediated at least one fact; the durable receipt /// row is owned by the fact store's curation authority. -#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectMemoryPrivacyRemediationReceiptV1 { pub detector_revision: String, pub trigger: PrivacyRemediationTriggerV1, diff --git a/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs b/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs index 514517708a..b834d6f923 100644 --- a/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs +++ b/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs @@ -48,8 +48,11 @@ impl NativeIntegrationStatusBroadcastV1 { } } +} + +impl NativeIntegrationStatusPort for NativeIntegrationStatusBroadcastV1 { /// The most recently updated projections, newest first. - pub fn recent(&self, maximum: usize) -> Vec { + fn poll_status(&self, maximum: usize) -> Vec { let Ok(statuses) = self.statuses.lock() else { return Vec::new(); }; @@ -60,12 +63,6 @@ impl NativeIntegrationStatusBroadcastV1 { } } -impl NativeIntegrationStatusPort for NativeIntegrationStatusBroadcastV1 { - fn poll_status(&self, maximum: usize) -> Vec { - self.recent(maximum) - } -} - #[cfg(test)] mod tests { use tracedecay_domain::{ @@ -101,7 +98,7 @@ mod tests { broadcast.publish(projection("transaction.broadcast.one", 3, 30)); broadcast.publish(projection("transaction.broadcast.one", 2, 40)); - let recent = broadcast.recent(8); + let recent = broadcast.poll_status(8); assert_eq!(recent.len(), 1); assert_eq!(recent[0].phase_revision, 3); assert_eq!(recent[0].updated_at, UtcMicros(30)); @@ -118,7 +115,7 @@ mod tests { )); } - let recent = broadcast.recent(MAX_BROADCAST_TRANSACTIONS + 1); + let recent = broadcast.poll_status(MAX_BROADCAST_TRANSACTIONS + 1); assert_eq!(recent.len(), MAX_BROADCAST_TRANSACTIONS); assert!( !recent diff --git a/src/daemon/privacy_remediation.rs b/src/daemon/privacy_remediation.rs index 8f3c24c914..e18ffb505e 100644 --- a/src/daemon/privacy_remediation.rs +++ b/src/daemon/privacy_remediation.rs @@ -53,9 +53,7 @@ pub(crate) fn spawn_project_memory_privacy_remediation(graph: Arc) { }); } -/// Runs one at-rest rescan over the project's persisted memory facts. This is -/// the production entry point shared by the daemon project-open background -/// task and explicit operator paths. +/// Runs one at-rest rescan over the project's persisted memory facts. pub(crate) async fn run_project_memory_privacy_remediation( graph: &TraceDecay, trigger: PrivacyRemediationTriggerV1, diff --git a/src/daemon/project_open_owners.rs b/src/daemon/project_open_owners.rs index 8150323c3e..50a8b43858 100644 --- a/src/daemon/project_open_owners.rs +++ b/src/daemon/project_open_owners.rs @@ -1086,10 +1086,8 @@ pub(super) async fn register_project_open_production_owners( delivery_settlements, ); - // At-rest privacy remediation runs as bounded background work after - // fail-closed admission: rescan persisted project-memory facts under the - // current detector revision, quarantining hits through the canonical - // curation authority. It never blocks admission or retrieval. + // At-rest privacy remediation is bounded background work after fail-closed + // admission; it never blocks admission or retrieval. crate::daemon::privacy_remediation::spawn_project_memory_privacy_remediation(Arc::clone( &graph, )); diff --git a/src/daemon/service/invocation/registrars/lsp.rs b/src/daemon/service/invocation/registrars/lsp.rs index 1540d42bac..d675738d3d 100644 --- a/src/daemon/service/invocation/registrars/lsp.rs +++ b/src/daemon/service/invocation/registrars/lsp.rs @@ -124,9 +124,8 @@ impl DaemonLspOwnerRegistrar { let diagnostic_records = Arc::new( tracedecay_usecases::feedback::diagnostics::DatabaseDiagnosticStore::new(database), ); - // Sessions from this factory forward the daemon-observed - // native-integration transaction statuses as read-only notifications; - // the invocation handler publishes into the same per-project fan-out. + // The invocation handler publishes into the same per-project fan-out + // that sessions from this factory forward as read-only notifications. let native_integration_status = self .service .native_integration_status_broadcast(&project_root) diff --git a/src/mcp/tools/handlers/dashboard.rs b/src/mcp/tools/handlers/dashboard.rs index 2a60a534f8..4d77ebc819 100644 --- a/src/mcp/tools/handlers/dashboard.rs +++ b/src/mcp/tools/handlers/dashboard.rs @@ -227,7 +227,7 @@ impl DashboardApplicationRuntime for DashboardInvocationExecutorAdapter { /// Resolves one native-integration status read over the catalog-bound /// dashboard surface, answering the same application result CLI and MCP -/// project. Read-only: only the status operation carries a dashboard binding. +/// project. pub(crate) async fn dashboard_native_integration_status( executor: &dyn crate::daemon_client::DaemonInvocationExecutor, control: &crate::dashboard::DashboardHttpRequestControlV1, From 3679c0f237ea2d94948410f8dbe7c2b66472b59e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 18:01:33 +0000 Subject: [PATCH 06/12] refactor(privacy): reuse sanitizer wire and trim unused receipt surface Co-authored-by: Zack Jackson --- .../src/memory/privacy_remediation.rs | 81 ++++++------------- .../src/memory/sanitize.rs | 64 ++++++++++----- src/daemon/privacy_remediation.rs | 29 ++----- 3 files changed, 73 insertions(+), 101 deletions(-) diff --git a/crates/tracedecay-usecases/src/memory/privacy_remediation.rs b/crates/tracedecay-usecases/src/memory/privacy_remediation.rs index 79d99c2db9..de2519112e 100644 --- a/crates/tracedecay-usecases/src/memory/privacy_remediation.rs +++ b/crates/tracedecay-usecases/src/memory/privacy_remediation.rs @@ -10,9 +10,7 @@ //! scanner binary or touches the network, and no unsanitized payload is ever //! persisted back. -use serde::Deserialize; -use serde_json::{Value, json}; -use tracedecay_domain::{Confidence, FactCategoryV1, UtcMicros}; +use tracedecay_domain::Confidence; use tracedecay_runtime_core::privacy::{ MEMORY_FACT_SANITIZER_VERSION_V1, MemoryFactSanitizationV1, sanitize_memory_fact_payload, }; @@ -26,15 +24,15 @@ use super::MemoryApplication; use super::context::MemoryOperationContext; use super::curation::{ProjectMemoryCurationMutationTarget, ProjectMemoryCurationOperation}; use super::error::{MemoryApplicationError, MemoryMutationError}; +use super::sanitize::{SanitizedFactPayloadWire, fact_payload_wire}; -/// Why an at-rest rescan ran. Recorded on the receipt so operators can -/// distinguish daemon-adopted maintenance from an explicit request. +/// Why an at-rest rescan ran. Recorded on the receipt so operators can see +/// which journey produced it; daemon store adoption is currently the only +/// production trigger. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PrivacyRemediationTriggerV1 { /// The daemon adopted the store under the current detector revision. DetectorRevisionAdoption, - /// An operator or Doctor explicitly requested a rescan. - OperatorRequest, } /// Truthful outcome of one at-rest rescan. `curation_receipt` is present @@ -49,25 +47,11 @@ pub struct ProjectMemoryPrivacyRemediationReceiptV1 { pub redacted_facts: u64, pub quarantined_facts: u64, pub curation_receipt: Option, - pub started_at: UtcMicros, - pub finished_at: UtcMicros, } /// One page of currently served facts per authority read. const RESCAN_PAGE_LIMIT: usize = 64; -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct SanitizedFactPayloadWire { - content: String, - category: FactCategoryV1, - tags: Vec, - entities: Vec, - metadata: Value, - #[serde(default)] - source_label: Option, -} - enum FactRescanDispositionV1 { Clean, Redact(ProjectMemoryFactUpdatePatchV1), @@ -83,11 +67,10 @@ impl MemoryApplication { pub async fn privacy_remediation_rescan( &self, trigger: PrivacyRemediationTriggerV1, - started_at: UtcMicros, - finished_at: impl Fn() -> UtcMicros, read_control: &FactReadControl, write_control: &FactWriteControl, ) -> Result { + let confidence = remediation_confidence()?; let mut scanned_facts = 0_u64; let mut clean_facts = 0_u64; let mut operations = Vec::new(); @@ -102,9 +85,7 @@ impl MemoryApplication { after_fact_id.take(), RESCAN_PAGE_LIMIT, )?; - let page = self - .list_project_memory_facts(query, read_control) - .await?; + let page = self.list_project_memory_facts(query, read_control).await?; for projection in page.facts() { let ProjectMemoryFactProjectionV1::Available(fact) = projection else { // A withheld projection serves no payload, so there is @@ -126,7 +107,7 @@ impl MemoryApplication { target: target.clone(), patch, evidence_facts: vec![target], - confidence: remediation_confidence()?, + confidence, reason: "at-rest privacy rescan redacted detector findings".to_owned(), }); } @@ -135,7 +116,7 @@ impl MemoryApplication { operations.push(ProjectMemoryCurationOperation::Remove { target: target.clone(), evidence_facts: vec![target], - confidence: remediation_confidence()?, + confidence, reason: "at-rest privacy rescan quarantined this fact".to_owned(), }); } @@ -149,19 +130,10 @@ impl MemoryApplication { let curation_receipt = if operations.is_empty() { None } else { - let context = MemoryOperationContext::generated( - &self.owner, - "privacy_remediation_rescan", - None, - )?; + let context = + MemoryOperationContext::generated(&self.owner, "privacy_remediation_rescan", None)?; let receipt = self - .apply_project_memory_curation( - operations, - remediation_confidence()?, - context, - None, - write_control, - ) + .apply_project_memory_curation(operations, confidence, context, None, write_control) .await .map_err(|error| match error { MemoryMutationError::Application(error) => error, @@ -177,8 +149,6 @@ impl MemoryApplication { redacted_facts, quarantined_facts, curation_receipt, - started_at, - finished_at: finished_at(), }) } } @@ -192,22 +162,17 @@ fn remediation_confidence() -> Result { /// Re-evaluates one served fact's canonical payload wire under the current /// detector. The wire mirrors the ingest sanitizer exactly, so an unchanged /// durable answer proves the persisted row already satisfies the revision. -fn rescan_fact(fact: &ProjectMemoryFactV1) -> Result { - let mut wire = json!({ - "content": fact.content(), - "category": fact.category(), - "tags": fact.tags(), - "entities": fact.entities(), - "metadata": fact.metadata(), - }); - if let Some(source_label) = fact.source_label() - && let Value::Object(object) = &mut wire - { - object.insert( - "source_label".to_owned(), - Value::String(source_label.to_owned()), - ); - } +fn rescan_fact( + fact: &ProjectMemoryFactV1, +) -> Result { + let wire = fact_payload_wire( + fact.content(), + fact.category(), + fact.tags(), + fact.entities(), + fact.metadata(), + fact.source_label(), + ); let sanitized = sanitize_memory_fact_payload(wire.clone()).map_err(|_| { MemoryApplicationError::InvalidInput { invariant: "at-rest privacy rescan detector evaluation", diff --git a/crates/tracedecay-usecases/src/memory/sanitize.rs b/crates/tracedecay-usecases/src/memory/sanitize.rs index 4a379ad64d..68680475af 100644 --- a/crates/tracedecay-usecases/src/memory/sanitize.rs +++ b/crates/tracedecay-usecases/src/memory/sanitize.rs @@ -22,16 +22,43 @@ impl SanitizedAddFactRequest { } } +/// Canonical fact payload wire shared by ingest sanitization and the at-rest +/// privacy rescan: both must present the detector with exactly this shape so +/// receipts and re-evaluations agree. #[derive(Deserialize)] #[serde(deny_unknown_fields)] -struct SanitizedFactPayloadWire { - content: String, - category: FactCategoryV1, - tags: Vec, - entities: Vec, - metadata: Value, +pub(super) struct SanitizedFactPayloadWire { + pub(super) content: String, + pub(super) category: FactCategoryV1, + pub(super) tags: Vec, + pub(super) entities: Vec, + pub(super) metadata: Value, #[serde(default)] - source_label: Option, + pub(super) source_label: Option, +} + +pub(super) fn fact_payload_wire( + content: &str, + category: FactCategoryV1, + tags: &[String], + entities: &[String], + metadata: &Value, + source_label: Option<&str>, +) -> Value { + let mut wire = json!({ + "content": content, + "category": category, + "tags": tags, + "entities": entities, + "metadata": metadata, + }); + if let (Some(source_label), Value::Object(object)) = (source_label, &mut wire) { + object.insert( + "source_label".to_owned(), + Value::String(source_label.to_owned()), + ); + } + wire } pub(super) fn sanitize_add_fact_request( @@ -48,21 +75,14 @@ pub(super) fn sanitize_add_fact_request( let Some(source_label) = sanitize_optional_memory_text(request.source_label.clone()) else { return Ok(None); }; - let mut wire = json!({ - "content": &request.content, - "category": request.category, - "tags": &request.tags, - "entities": &request.entities, - "metadata": &request.metadata, - }); - if let Some(source_label) = &source_label - && let Value::Object(wire) = &mut wire - { - wire.insert( - "source_label".to_owned(), - Value::String(source_label.clone()), - ); - } + let wire = fact_payload_wire( + &request.content, + request.category, + &request.tags, + &request.entities, + &request.metadata, + source_label.as_deref(), + ); let MemoryFactSanitizationV1::Durable { payload, receipt } = sanitize_memory_fact_payload(wire) .map_err(|_| MemoryApplicationError::InvalidInput { invariant: "project-memory add request privacy sanitizer", diff --git a/src/daemon/privacy_remediation.rs b/src/daemon/privacy_remediation.rs index e18ffb505e..3954ce9882 100644 --- a/src/daemon/privacy_remediation.rs +++ b/src/daemon/privacy_remediation.rs @@ -11,13 +11,11 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use tracedecay_domain::UtcMicros; use tracedecay_store::{FactReadControl, FactWriteControl}; use tracedecay_usecases::memory::{ PrivacyRemediationTriggerV1, ProjectMemoryPrivacyRemediationReceiptV1, }; -use crate::daemon_client::invocation_now_micros; use crate::errors::Result; use crate::tracedecay::TraceDecay; @@ -25,12 +23,7 @@ use crate::tracedecay::TraceDecay; pub(crate) fn spawn_project_memory_privacy_remediation(graph: Arc) { tokio::spawn(async move { let project = graph.project_root().display().to_string(); - match run_project_memory_privacy_remediation( - &graph, - PrivacyRemediationTriggerV1::DetectorRevisionAdoption, - ) - .await - { + match run_project_memory_privacy_remediation(&graph).await { Ok(receipt) => { tracing::info!( event = "project_memory_privacy_remediation", @@ -53,18 +46,13 @@ pub(crate) fn spawn_project_memory_privacy_remediation(graph: Arc) { }); } -/// Runs one at-rest rescan over the project's persisted memory facts. -pub(crate) async fn run_project_memory_privacy_remediation( +async fn run_project_memory_privacy_remediation( graph: &TraceDecay, - trigger: PrivacyRemediationTriggerV1, ) -> Result { let memory = graph.project_memory_application().await?; - let started_at = UtcMicros(invocation_now_micros().0); memory .privacy_remediation_rescan( - trigger, - started_at, - || UtcMicros(invocation_now_micros().0), + PrivacyRemediationTriggerV1::DetectorRevisionAdoption, &remediation_read_control(), &remediation_write_control(), ) @@ -295,16 +283,17 @@ mod tests { .expect("owner-bound memory application"); let receipt = memory .privacy_remediation_rescan( - PrivacyRemediationTriggerV1::OperatorRequest, - tracedecay_domain::UtcMicros(1), - || tracedecay_domain::UtcMicros(2), + PrivacyRemediationTriggerV1::DetectorRevisionAdoption, &remediation_read_control(), &remediation_write_control(), ) .await .expect("at-rest privacy rescan"); - assert_eq!(receipt.trigger, PrivacyRemediationTriggerV1::OperatorRequest); + assert_eq!( + receipt.trigger, + PrivacyRemediationTriggerV1::DetectorRevisionAdoption + ); assert_eq!(receipt.scanned_facts, 3); assert_eq!(receipt.clean_facts, 1); assert_eq!(receipt.redacted_facts, 1); @@ -336,8 +325,6 @@ mod tests { let second = memory .privacy_remediation_rescan( PrivacyRemediationTriggerV1::DetectorRevisionAdoption, - tracedecay_domain::UtcMicros(3), - || tracedecay_domain::UtcMicros(4), &remediation_read_control(), &remediation_write_control(), ) From b6a4d673c818ad26c3775c0191db35039bb71bd1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 18:01:40 +0000 Subject: [PATCH 07/12] refactor(multi-root): make the collection selector infallible Co-authored-by: Zack Jackson --- .../src/multi_root/collection.rs | 21 +++++++------------ .../src/multi_root_api.rs | 12 +---------- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/crates/tracedecay-application/src/multi_root/collection.rs b/crates/tracedecay-application/src/multi_root/collection.rs index 6b60c1ad50..374c5bf0a8 100644 --- a/crates/tracedecay-application/src/multi_root/collection.rs +++ b/crates/tracedecay-application/src/multi_root/collection.rs @@ -8,7 +8,7 @@ use tracedecay_domain::ScopeSetId; -use super::{AuthorizedScopeSet, MultiRootQueryError}; +use super::AuthorizedScopeSet; /// Selects the collection a read surface must resolve. /// @@ -25,16 +25,11 @@ impl MultiRootCollectionSelectorV1 { pub fn new( explicit_target: Option, default_collection: Option, - ) -> Result { - for collection in explicit_target.iter().chain(default_collection.iter()) { - collection - .validate() - .map_err(|error| MultiRootQueryError::Invalid(error.to_string()))?; - } - Ok(Self { + ) -> Self { + Self { explicit_target, default_collection, - }) + } } pub fn target(&self) -> Option<&ScopeSetId> { @@ -199,22 +194,20 @@ mod tests { let selector = MultiRootCollectionSelectorV1::new( Some(collection("scope-set.explicit")), Some(collection("scope-set.default")), - ) - .expect("selector"); + ); assert_eq!(selector.target(), Some(&collection("scope-set.explicit"))); } #[test] fn default_collection_answers_only_when_nothing_explicit_is_named() { let with_default = - MultiRootCollectionSelectorV1::new(None, Some(collection("scope-set.default"))) - .expect("selector"); + MultiRootCollectionSelectorV1::new(None, Some(collection("scope-set.default"))); assert_eq!( with_default.target(), Some(&collection("scope-set.default")) ); - let unnamed = MultiRootCollectionSelectorV1::new(None, None).expect("selector"); + let unnamed = MultiRootCollectionSelectorV1::new(None, None); assert_eq!(unnamed.target(), None); } diff --git a/crates/tracedecay-dashboard-api/src/multi_root_api.rs b/crates/tracedecay-dashboard-api/src/multi_root_api.rs index df78bb249e..a84e5396ea 100644 --- a/crates/tracedecay-dashboard-api/src/multi_root_api.rs +++ b/crates/tracedecay-dashboard-api/src/multi_root_api.rs @@ -73,17 +73,7 @@ pub(crate) async fn resolve_collection_capability( MultiRootCollectionUnavailableV1::TransportNotAdmitted.reason(), ); }; - let selector = match MultiRootCollectionSelectorV1::new(explicit_target, None) { - Ok(selector) => selector, - Err(error) => { - return MultiRootCapabilityV1::unavailable( - MultiRootCollectionUnavailableV1::AuthorityUnavailable { - detail: error.to_string(), - } - .reason(), - ); - } - }; + let selector = MultiRootCollectionSelectorV1::new(explicit_target, None); let Some(target) = selector.target().cloned() else { return MultiRootCapabilityV1::unavailable( MultiRootCollectionUnavailableV1::NoCollectionNamed.reason(), From 7b46116aa348147429eb2337d5beb8371de5a507 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 18:01:41 +0000 Subject: [PATCH 08/12] refactor(native-integration): recover poisoned locks, simplify eviction Co-authored-by: Zack Jackson --- .../tracedecay-lsp/src/native_integration.rs | 3 +- crates/tracedecay-lsp/src/protocol.rs | 2 +- .../src/protocol/lifecycle_controller.rs | 3 +- .../protocol/native_integration_controller.rs | 28 ++++++------------- .../src/lsp_support/factory.rs | 14 ++++------ .../native_integration/status_broadcast.rs | 23 ++++++++------- 6 files changed, 28 insertions(+), 45 deletions(-) diff --git a/crates/tracedecay-lsp/src/native_integration.rs b/crates/tracedecay-lsp/src/native_integration.rs index ad5c1609e6..0a6064f978 100644 --- a/crates/tracedecay-lsp/src/native_integration.rs +++ b/crates/tracedecay-lsp/src/native_integration.rs @@ -8,8 +8,7 @@ use tracedecay_application::NativeIntegrationStatusProjectionV1; -pub const TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD: &str = - "tracedecay/nativeIntegrationStatus"; +pub const TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD: &str = "tracedecay/nativeIntegrationStatus"; /// The most recent status projections one session flush may forward. pub(crate) const MAX_NATIVE_INTEGRATION_STATUS_PER_POLL: usize = 16; diff --git a/crates/tracedecay-lsp/src/protocol.rs b/crates/tracedecay-lsp/src/protocol.rs index 5dd16836fb..c9b47828b9 100644 --- a/crates/tracedecay-lsp/src/protocol.rs +++ b/crates/tracedecay-lsp/src/protocol.rs @@ -90,11 +90,11 @@ use context_controller::bind_context_document_digest; use diagnostics_controller::DiagnosticsController; use dynamic_diagnostics_controller::DynamicDiagnosticsController; use lifecycle_controller::LifecycleController; +use native_integration_controller::NativeIntegrationController; pub use outbound_controller::DaemonLspProtocolTransport; use outbound_controller::OutboundController; #[cfg(test)] use outbound_controller::QueuedFrame; -use native_integration_controller::NativeIntegrationController; use semantic_controller::SemanticController; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] diff --git a/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs b/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs index 1c63537f69..a4b880e2a6 100644 --- a/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs +++ b/crates/tracedecay-lsp/src/protocol/lifecycle_controller.rs @@ -7,8 +7,7 @@ use super::{ GatewayMethod, LifecycleError, LspCatalogAdmission, LspRequestFailure, LspRequestId, LspSessionControl, MAX_CONTEXT_PROJECTION_KINDS, Map, MethodUnavailableReason, NativeIntegrationController, OutboundController, OverlayError, OverlayStore, RpcFailure, - SemanticController, - SemanticProviderPort, SessionLifecycle, TRACEDECAY_CONTEXT_EXPAND_METHOD, + SemanticController, SemanticProviderPort, SessionLifecycle, TRACEDECAY_CONTEXT_EXPAND_METHOD, TRACEDECAY_CONTEXT_METHOD, UnavailableDiagnosticSnapshotProvider, UpstreamCapabilities, Value, error_response, initialized_workspace_uris, is_supported_context_projection, json, negotiate_capabilities, overlay_failure, request_id, request_id_value, success_response, diff --git a/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs b/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs index b83a5c5bb8..b8acfdb02f 100644 --- a/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs +++ b/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs @@ -68,18 +68,14 @@ where self.native_integration .notified .insert(projection.transaction_id.clone(), projection); - while self.native_integration.notified.len() - > MAX_TRACKED_NATIVE_INTEGRATION_TRANSACTIONS - { - let Some(oldest) = self + if self.native_integration.notified.len() > MAX_TRACKED_NATIVE_INTEGRATION_TRANSACTIONS + && let Some(oldest) = self .native_integration .notified .iter() .min_by_key(|(_, status)| status.updated_at) .map(|(transaction_id, _)| transaction_id.clone()) - else { - break; - }; + { self.native_integration.notified.remove(&oldest); } } @@ -150,19 +146,14 @@ mod tests { frames .into_iter() .map(|frame| serde_json::from_slice::(&frame).unwrap()) - .filter(|message| { - message["method"] == TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD - }) + .filter(|message| message["method"] == TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD) .collect() } #[test] fn ready_sessions_forward_each_status_change_exactly_once() { - let port = ScriptedStatusPort::holding(projection( - NativeIntegrationPhaseV1::Prepared, - 1, - None, - )); + let port = + ScriptedStatusPort::holding(projection(NativeIntegrationPhaseV1::Prepared, 1, None)); let mut session = session().with_native_integration_status_port(Arc::clone(&port) as Arc<_>); initialize(&mut session); @@ -191,11 +182,8 @@ mod tests { #[test] fn sessions_before_initialization_receive_no_native_integration_notifications() { - let port = ScriptedStatusPort::holding(projection( - NativeIntegrationPhaseV1::Prepared, - 1, - None, - )); + let port = + ScriptedStatusPort::holding(projection(NativeIntegrationPhaseV1::Prepared, 1, None)); let mut session = session().with_native_integration_status_port(port as Arc<_>); session.flush_due(1); diff --git a/crates/tracedecay-usecases/src/lsp_support/factory.rs b/crates/tracedecay-usecases/src/lsp_support/factory.rs index 4ac8249a4f..83afda9f9e 100644 --- a/crates/tracedecay-usecases/src/lsp_support/factory.rs +++ b/crates/tracedecay-usecases/src/lsp_support/factory.rs @@ -4,6 +4,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use tokio::runtime::Handle; +use tracedecay_application::NativeIntegrationStatusProjectionV1; use tracedecay_lsp::{ AdmittedRoot, AnalyzerCancellationAdapter, AnalyzerCancellationPort, AuthorizedLspWorkspace, CanonicalContextProjectionAuthority, CanonicalDiagnosticSnapshotAuthority, @@ -17,7 +18,6 @@ use tracedecay_lsp::{ NativeIntegrationStatusPort, OverlaySnapshot, SemanticProviderOutcome, SemanticProviderPort, SemanticRequest, SemanticResponse, UpstreamCapabilities, WorkspaceDiagnosticSnapshotOutcome, }; -use tracedecay_application::NativeIntegrationStatusProjectionV1; use super::runtime_adapters::runtime_spawner; @@ -294,13 +294,11 @@ impl DaemonLspSessionFactory { if native_integration_status.is_empty() { return Some(session); } - Some( - session.with_native_integration_status_port(Arc::new( - FederatedNativeIntegrationStatus { - roots: native_integration_status, - }, - )), - ) + Some(session.with_native_integration_status_port(Arc::new( + FederatedNativeIntegrationStatus { + roots: native_integration_status, + }, + ))) } } diff --git a/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs b/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs index b834d6f923..062f6109ae 100644 --- a/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs +++ b/crates/tracedecay-usecases/src/native_integration/status_broadcast.rs @@ -28,34 +28,33 @@ impl NativeIntegrationStatusBroadcastV1 { /// publication (older `phase_revision` for the same transaction) never /// overwrites newer durable evidence. pub fn publish(&self, projection: NativeIntegrationStatusProjectionV1) { - let Ok(mut statuses) = self.statuses.lock() else { - return; - }; + let mut statuses = self + .statuses + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); match statuses.get(&projection.transaction_id) { Some(current) if current.phase_revision > projection.phase_revision => return, _ => {} } statuses.insert(projection.transaction_id.clone(), projection); - while statuses.len() > MAX_BROADCAST_TRANSACTIONS { - let Some(oldest) = statuses + if statuses.len() > MAX_BROADCAST_TRANSACTIONS + && let Some(oldest) = statuses .iter() .min_by_key(|(_, status)| status.updated_at) .map(|(transaction_id, _)| transaction_id.clone()) - else { - break; - }; + { statuses.remove(&oldest); } } - } impl NativeIntegrationStatusPort for NativeIntegrationStatusBroadcastV1 { /// The most recently updated projections, newest first. fn poll_status(&self, maximum: usize) -> Vec { - let Ok(statuses) = self.statuses.lock() else { - return Vec::new(); - }; + let statuses = self + .statuses + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut recent = statuses.values().cloned().collect::>(); recent.sort_by_key(|status| std::cmp::Reverse(status.updated_at)); recent.truncate(maximum); From 5e513dc2acd956ad5e0a4dbbcb93564b1a556729 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 18:01:41 +0000 Subject: [PATCH 09/12] style(dashboard): sort imports and group daemon-read future aliases Co-authored-by: Zack Jackson --- .../src/application_surface.rs | 24 +++++++------- crates/tracedecay-dashboard-api/src/lib.rs | 32 +++++++++++-------- .../dashboard_configuration_test_runtime.rs | 4 +-- src/mcp/tools/handlers/dashboard.rs | 5 +-- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/crates/tracedecay-dashboard-api/src/application_surface.rs b/crates/tracedecay-dashboard-api/src/application_surface.rs index d13df00484..87697ea821 100644 --- a/crates/tracedecay-dashboard-api/src/application_surface.rs +++ b/crates/tracedecay-dashboard-api/src/application_surface.rs @@ -86,6 +86,18 @@ pub type DashboardScopeSetReadFuture<'a> = Pin< >, >; +pub type DashboardNativeIntegrationStatusFuture<'a> = Pin< + Box< + dyn Future< + Output = std::result::Result< + NativeIntegrationSurfaceResultV1, + DashboardDaemonReadUnavailableV1, + >, + > + Send + + 'a, + >, +>; + /// The daemon transport could not answer a dashboard read. The detail is a /// safe diagnostic, never store paths or payload content. #[derive(Clone, Debug, PartialEq, Eq)] @@ -132,18 +144,6 @@ pub trait DashboardApplicationRuntime: Send + Sync { ) -> DashboardNativeIntegrationStatusFuture<'a>; } -pub type DashboardNativeIntegrationStatusFuture<'a> = Pin< - Box< - dyn Future< - Output = std::result::Result< - NativeIntegrationSurfaceResultV1, - DashboardDaemonReadUnavailableV1, - >, - > + Send - + 'a, - >, ->; - #[cfg(test)] mod tests { use tracedecay_application::{Deadline, RequestId}; diff --git a/crates/tracedecay-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index 929554f2e8..df4db26558 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -2254,8 +2254,7 @@ mod authority_tests { /// native-integration status result. struct SingleCollectionRuntime { scope_set: tracedecay_application::AuthorizedScopeSet, - native_integration_status: - Option, + native_integration_status: Option, } impl SingleCollectionRuntime { @@ -2293,10 +2292,10 @@ mod authority_tests { tracedecay_domain::UtcMicros(1_000), scope.clone(), BTreeSet::from([ - tracedecay_tool_catalog::CapabilityId::new(capability).expect("capability"), + tracedecay_tool_catalog::CapabilityId::new(capability).expect("capability") ]), BTreeSet::from([ - tracedecay_tool_catalog::UseCaseId::new(use_case).expect("use case"), + tracedecay_tool_catalog::UseCaseId::new(use_case).expect("use case") ]), tracedecay_application::DisclosureClass::Evidence, ) @@ -2349,11 +2348,13 @@ mod authority_tests { _idempotency_key: tracedecay_domain::configuration::ConfigurationIdempotencyKey, ) -> DashboardConfigurationApplyFuture<'_> { Box::pin(async { - Err(DashboardConfigurationApplyError::ApplicationContractViolation( - tracedecay_application::ApplicationContractError::Inconsistent { - field: "single-collection test runtime configuration", - }, - )) + Err( + DashboardConfigurationApplyError::ApplicationContractViolation( + tracedecay_application::ApplicationContractError::Inconsistent { + field: "single-collection test runtime configuration", + }, + ), + ) }) } @@ -2362,8 +2363,8 @@ mod authority_tests { _control: DashboardHttpRequestControlV1, scope_set_id: tracedecay_domain::ScopeSetId, ) -> application_surface::DashboardScopeSetReadFuture<'_> { - let read = (self.scope_set.scope_set_id() == &scope_set_id) - .then(|| self.scope_set.clone()); + let read = + (self.scope_set.scope_set_id() == &scope_set_id).then(|| self.scope_set.clone()); Box::pin(async move { Ok(read) }) } @@ -2375,8 +2376,9 @@ mod authority_tests { let result = self.native_integration_status.clone(); Box::pin(async move { result.ok_or(application_surface::DashboardDaemonReadUnavailableV1 { - detail: "the single-collection test runtime scripts no native-integration status" - .to_owned(), + detail: + "the single-collection test runtime scripts no native-integration status" + .to_owned(), }) }) } @@ -2480,7 +2482,9 @@ mod authority_tests { phase: tracedecay_domain::NativeIntegrationPhaseV1::Terminal, phase_revision: 4, cancellation_requested: false, - terminal_outcome: Some(tracedecay_domain::NativeIntegrationTerminalOutcomeV1::Committed), + terminal_outcome: Some( + tracedecay_domain::NativeIntegrationTerminalOutcomeV1::Committed, + ), updated_at: tracedecay_domain::UtcMicros(9), }; state.application_invocation_executor = Some(Arc::new( diff --git a/src/daemon/dashboard_configuration_test_runtime.rs b/src/daemon/dashboard_configuration_test_runtime.rs index 73a658f308..a46ee2de6e 100644 --- a/src/daemon/dashboard_configuration_test_runtime.rs +++ b/src/daemon/dashboard_configuration_test_runtime.rs @@ -24,8 +24,8 @@ use crate::daemon_client::invocation_now_micros; use crate::daemon_contract::{DaemonInvocationOutcome, DaemonInvocationRequest}; use crate::dashboard::{ DashboardApplicationRouters, DashboardApplicationRuntime, DashboardConfigurationApplyError, - DashboardConfigurationApplyFuture, DashboardHttpRequestControlV1, DashboardScopeSetReadFuture, - DashboardDaemonReadUnavailableV1, + DashboardConfigurationApplyFuture, DashboardDaemonReadUnavailableV1, + DashboardHttpRequestControlV1, DashboardScopeSetReadFuture, }; use crate::errors::{Result, TraceDecayError}; use crate::tracedecay::TraceDecay; diff --git a/src/mcp/tools/handlers/dashboard.rs b/src/mcp/tools/handlers/dashboard.rs index 4d77ebc819..a31e9e1323 100644 --- a/src/mcp/tools/handlers/dashboard.rs +++ b/src/mcp/tools/handlers/dashboard.rs @@ -29,8 +29,9 @@ use super::support::generic_tool_result; use crate::dashboard::{ AutomationSchedulerReconciler, DEFAULT_PORT, DashboardApplicationRouters, DashboardApplicationRuntime, DashboardAutomationWriter, DashboardConfigurationApplyError, - DashboardConfigurationApplyFuture, DashboardHttpRequestControlV1, DashboardScopeSetReadFuture, - DashboardDaemonReadUnavailableV1, DashboardStateCompositionV1, bind_dashboard, + DashboardConfigurationApplyFuture, DashboardDaemonReadUnavailableV1, + DashboardHttpRequestControlV1, DashboardScopeSetReadFuture, DashboardStateCompositionV1, + bind_dashboard, build_state_with_automation_reconciler, router, validate_dashboard_host, }; From f1655c8ae3efc44c229f7ee7eab760a1aec57558 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 19 Aug 2026 20:01:53 +0000 Subject: [PATCH 10/12] fix(daemon): harden project privacy and status routing --- .../src/git/native_integration_surface.rs | 2 +- .../src/application_surface.rs | 15 ++ crates/tracedecay-dashboard-api/src/lib.rs | 47 +++++- .../protocol/native_integration_controller.rs | 90 ++++++++++-- .../src/lsp_support/factory.rs | 98 ++++++++++++- .../src/memory/privacy_remediation.rs | 94 +++++------- src/daemon/privacy_remediation.rs | 133 ++++++++++++----- src/mcp/tools/handlers/dashboard.rs | 136 +++++++++++------- 8 files changed, 451 insertions(+), 164 deletions(-) diff --git a/crates/tracedecay-application/src/git/native_integration_surface.rs b/crates/tracedecay-application/src/git/native_integration_surface.rs index ec6e99035b..2e494e94ab 100644 --- a/crates/tracedecay-application/src/git/native_integration_surface.rs +++ b/crates/tracedecay-application/src/git/native_integration_surface.rs @@ -556,7 +556,7 @@ struct NativeIntegrationSurfaceSpec { surfaces: &'static [BindingSurface], } -/// Plan 36 exposes the transaction journey through CLI and MCP only. HTTP is +/// Native-integration mutations are exposed through CLI and MCP only. HTTP is /// deliberately excluded for the same reason `git_preview`/`git_apply` are: /// apply is an authoritative native mutation and there is no transport /// fallback path. diff --git a/crates/tracedecay-dashboard-api/src/application_surface.rs b/crates/tracedecay-dashboard-api/src/application_surface.rs index 87697ea821..406cd8e4f7 100644 --- a/crates/tracedecay-dashboard-api/src/application_surface.rs +++ b/crates/tracedecay-dashboard-api/src/application_surface.rs @@ -1,7 +1,9 @@ //! Root-owned application transport injected into the dashboard adapter. use std::future::Future; +use std::path::Path; use std::pin::Pin; +use std::sync::Arc; use axum::Router; use axum::extract::Json; @@ -110,6 +112,19 @@ pub trait DashboardApplicationRuntime: Send + Sync { /// without that identity cannot advertise or dispatch profile writes. fn user_profile_id(&self) -> Option<&UserProfileId>; + /// Rebinds the daemon transport to one selected project's exact root. + /// Implementations that cannot prove such a binding fail closed instead + /// of reusing the active project's transport. + fn for_project_root( + &self, + project_root: &Path, + ) -> std::result::Result, String> { + Err(format!( + "the dashboard application runtime cannot bind selected project '{}'", + project_root.display() + )) + } + fn routers( &self, active_project_id: ProjectId, diff --git a/crates/tracedecay-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index b3ad1eed1b..ece327ab56 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -809,6 +809,10 @@ pub async fn build_selected_project_state( cg: Arc, active: &DashboardState, ) -> Result { + let application_invocation_executor = selected_project_application_runtime( + active.application_invocation_executor.as_ref(), + cg.project_root(), + )?; build_state_inner( cg.as_ref(), Some(Arc::clone(&cg)), @@ -844,13 +848,23 @@ pub async fn build_selected_project_state( explorer_semantic_reader: active.explorer_semantic_reader.clone(), feedback_status_reader: active.feedback_status_reader.clone(), code_diagnostics_broker: None, - application_invocation_executor: active.application_invocation_executor.clone(), + application_invocation_executor, delivery_settlement_authority: None, }, ) .await } +fn selected_project_application_runtime( + active: Option<&Arc>, + project_root: &std::path::Path, +) -> Result>> { + active + .map(|runtime| runtime.for_project_root(project_root)) + .transpose() + .map_err(config_error) +} + pub fn config_error(message: impl Into) -> TraceDecayError { TraceDecayError::Config { message: message.into(), @@ -2268,9 +2282,11 @@ mod authority_tests { /// scope-set read: an exact-id hit answers the frozen scope set, anything /// else is a truthful absent read. Optionally answers one scripted /// native-integration status result. + #[derive(Clone)] struct SingleCollectionRuntime { scope_set: tracedecay_application::AuthorizedScopeSet, native_integration_status: Option, + rebound_roots: Arc>>, } impl SingleCollectionRuntime { @@ -2340,6 +2356,7 @@ mod authority_tests { Self { scope_set, native_integration_status: None, + rebound_roots: Arc::new(std::sync::Mutex::new(Vec::new())), } } } @@ -2349,6 +2366,17 @@ mod authority_tests { None } + fn for_project_root( + &self, + project_root: &std::path::Path, + ) -> std::result::Result, String> { + self.rebound_roots + .lock() + .expect("selected project bindings") + .push(project_root.to_path_buf()); + Ok(Arc::new(self.clone())) + } + fn routers( &self, _active_project_id: ProjectId, @@ -2400,6 +2428,23 @@ mod authority_tests { } } + #[test] + fn selected_project_runtime_rebinds_to_the_selected_root() { + let runtime = SingleCollectionRuntime::persisted("scope-set.dashboard-selected-project"); + let rebound_roots = Arc::clone(&runtime.rebound_roots); + let active: Arc = Arc::new(runtime); + let selected_root = std::path::Path::new("/registered/selected-project"); + + let selected = selected_project_application_runtime(Some(&active), selected_root) + .expect("selected project runtime binding"); + + assert!(selected.is_some()); + assert_eq!( + *rebound_roots.lock().expect("selected project bindings"), + vec![selected_root.to_path_buf()] + ); + } + #[tokio::test] async fn capabilities_with_admitted_transport_report_the_typed_no_collection_state() { let fixture = diff --git a/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs b/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs index b8acfdb02f..55c5ea05e6 100644 --- a/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs +++ b/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs @@ -7,7 +7,7 @@ //! constraint: this path admits no client method. use tracedecay_application::NativeIntegrationStatusProjectionV1; -use tracedecay_domain::NativeIntegrationTransactionId; +use tracedecay_domain::{NativeIntegrationTransactionId, RepositoryId}; use super::{ Arc, BTreeMap, DaemonLspProtocolSession, DiagnosticSnapshotPort, FeedbackCyclePort, @@ -26,8 +26,10 @@ const MAX_TRACKED_NATIVE_INTEGRATION_TRANSACTIONS: usize = 128; #[derive(Default)] pub(super) struct NativeIntegrationController { pub(super) port: Option>, - pub(super) notified: - BTreeMap, + pub(super) notified: BTreeMap< + (RepositoryId, NativeIntegrationTransactionId), + NativeIntegrationStatusProjectionV1, + >, } impl DaemonLspProtocolSession @@ -46,12 +48,11 @@ where return; }; for projection in port.poll_status(MAX_NATIVE_INTEGRATION_STATUS_PER_POLL) { - if self - .native_integration - .notified - .get(&projection.transaction_id) - == Some(&projection) - { + let identity = ( + projection.repository_id.clone(), + projection.transaction_id.clone(), + ); + if self.native_integration.notified.get(&identity) == Some(&projection) { continue; } let Ok(params) = serde_json::to_value(&projection) else { @@ -67,14 +68,14 @@ where } self.native_integration .notified - .insert(projection.transaction_id.clone(), projection); + .insert(identity, projection); if self.native_integration.notified.len() > MAX_TRACKED_NATIVE_INTEGRATION_TRANSACTIONS && let Some(oldest) = self .native_integration .notified .iter() .min_by_key(|(_, status)| status.updated_at) - .map(|(transaction_id, _)| transaction_id.clone()) + .map(|(identity, _)| identity.clone()) { self.native_integration.notified.remove(&oldest); } @@ -113,6 +114,10 @@ mod tests { fn replace(&self, projection: NativeIntegrationStatusProjectionV1) { *self.statuses.lock().unwrap() = vec![projection]; } + + fn replace_many(&self, projections: Vec) { + *self.statuses.lock().unwrap() = projections; + } } impl NativeIntegrationStatusPort for ScriptedStatusPort { @@ -127,12 +132,28 @@ mod tests { phase: NativeIntegrationPhaseV1, phase_revision: u64, terminal_outcome: Option, + ) -> NativeIntegrationStatusProjectionV1 { + projection_for( + "repository.lsp.notify", + "transaction.lsp.notify", + phase, + phase_revision, + terminal_outcome, + ) + } + + fn projection_for( + repository_id: &str, + transaction_id: &str, + phase: NativeIntegrationPhaseV1, + phase_revision: u64, + terminal_outcome: Option, ) -> NativeIntegrationStatusProjectionV1 { NativeIntegrationStatusProjectionV1 { - transaction_id: NativeIntegrationTransactionId::new("transaction.lsp.notify").unwrap(), + transaction_id: NativeIntegrationTransactionId::new(transaction_id).unwrap(), preview_id: NativeIntegrationPreviewId::new("preview.lsp.notify").unwrap(), preview_digest: ManifestDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap(), - repository_id: RepositoryId::new("repository.lsp.notify").unwrap(), + repository_id: RepositoryId::new(repository_id).unwrap(), destination_ref: RefId::new("refs/heads/main").unwrap(), phase, phase_revision, @@ -190,4 +211,47 @@ mod tests { assert!(native_integration_notifications(session.drain_outbound()).is_empty()); } + + #[test] + fn equal_transaction_ids_from_distinct_projects_dedupe_independently() { + let shared_transaction = "transaction.shared"; + let port = ScriptedStatusPort::holding(projection_for( + "repository.first", + shared_transaction, + NativeIntegrationPhaseV1::Prepared, + 1, + None, + )); + port.replace_many(vec![ + projection_for( + "repository.first", + shared_transaction, + NativeIntegrationPhaseV1::Prepared, + 1, + None, + ), + projection_for( + "repository.second", + shared_transaction, + NativeIntegrationPhaseV1::Prepared, + 1, + None, + ), + ]); + let mut session = + session().with_native_integration_status_port(Arc::clone(&port) as Arc<_>); + initialize(&mut session); + assert_eq!( + native_integration_notifications(session.drain_outbound()).len(), + 2, + "both project-owned statuses notify once" + ); + + session.flush_due(2); + + assert!( + native_integration_notifications(session.drain_outbound()).is_empty(), + "unchanged statuses with colliding transaction ids must stay deduped" + ); + } } diff --git a/crates/tracedecay-usecases/src/lsp_support/factory.rs b/crates/tracedecay-usecases/src/lsp_support/factory.rs index 83afda9f9e..351855d2c6 100644 --- a/crates/tracedecay-usecases/src/lsp_support/factory.rs +++ b/crates/tracedecay-usecases/src/lsp_support/factory.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::runtime::Handle; use tracedecay_application::NativeIntegrationStatusProjectionV1; @@ -297,6 +298,7 @@ impl DaemonLspSessionFactory { Some(session.with_native_integration_status_port(Arc::new( FederatedNativeIntegrationStatus { roots: native_integration_status, + next_root: AtomicUsize::new(0), }, ))) } @@ -307,22 +309,108 @@ impl DaemonLspSessionFactory { /// merging discloses nothing a single-root session would not see. struct FederatedNativeIntegrationStatus { roots: Vec>, + next_root: AtomicUsize, } impl NativeIntegrationStatusPort for FederatedNativeIntegrationStatus { fn poll_status(&self, maximum: usize) -> Vec { - let mut merged = Vec::new(); - for root in &self.roots { - let remaining = maximum.saturating_sub(merged.len()); - if remaining == 0 { + if maximum == 0 || self.roots.is_empty() { + return Vec::new(); + } + let mut root_statuses = self + .roots + .iter() + .map(|root| root.poll_status(maximum).into_iter()) + .collect::>(); + let root_count = root_statuses.len(); + let first_root = self.next_root.fetch_add(1, Ordering::Relaxed) % root_count; + let mut merged = Vec::with_capacity(maximum); + loop { + let mut progressed = false; + for offset in 0..root_count { + let root_index = (first_root + offset) % root_count; + if let Some(status) = root_statuses[root_index].next() { + merged.push(status); + progressed = true; + if merged.len() == maximum { + return merged; + } + } + } + if !progressed { break; } - merged.extend(root.poll_status(remaining).into_iter().take(remaining)); } merged } } +#[cfg(test)] +mod native_integration_status_tests { + use std::sync::Arc; + + use tracedecay_application::NativeIntegrationStatusProjectionV1; + use tracedecay_domain::{ + ManifestDigest, NativeIntegrationPhaseV1, NativeIntegrationPreviewId, + NativeIntegrationTransactionId, RefId, RepositoryId, UtcMicros, + }; + use tracedecay_lsp::NativeIntegrationStatusPort; + + use super::FederatedNativeIntegrationStatus; + + struct StaticStatuses(Vec); + + impl NativeIntegrationStatusPort for StaticStatuses { + fn poll_status(&self, maximum: usize) -> Vec { + self.0.iter().take(maximum).cloned().collect() + } + } + + fn status( + repository: &str, + transaction: &str, + updated_at: i64, + ) -> NativeIntegrationStatusProjectionV1 { + NativeIntegrationStatusProjectionV1 { + transaction_id: NativeIntegrationTransactionId::new(transaction).expect("transaction"), + preview_id: NativeIntegrationPreviewId::new(format!("preview.{transaction}")) + .expect("preview"), + preview_digest: ManifestDigest::new(format!("sha256:{}", "a".repeat(64))) + .expect("digest"), + repository_id: RepositoryId::new(repository).expect("repository"), + destination_ref: RefId::new("refs/heads/main").expect("ref"), + phase: NativeIntegrationPhaseV1::Prepared, + phase_revision: 1, + cancellation_requested: false, + terminal_outcome: None, + updated_at: UtcMicros(updated_at), + } + } + + #[test] + fn bounded_federated_poll_represents_each_root_before_reusing_one_root() { + let first: Arc = Arc::new(StaticStatuses(vec![ + status("repository.first", "transaction.first-a", 3), + status("repository.first", "transaction.first-b", 2), + ])); + let second: Arc = Arc::new(StaticStatuses(vec![status( + "repository.second", + "transaction.second", + 1, + )])); + let federated = FederatedNativeIntegrationStatus { + roots: vec![first, second], + next_root: std::sync::atomic::AtomicUsize::new(0), + }; + + let statuses = federated.poll_status(2); + + assert_eq!(statuses.len(), 2); + assert_eq!(statuses[0].repository_id.as_str(), "repository.first"); + assert_eq!(statuses[1].repository_id.as_str(), "repository.second"); + } +} + struct FederatedFeedback { roots: BTreeMap>, } diff --git a/crates/tracedecay-usecases/src/memory/privacy_remediation.rs b/crates/tracedecay-usecases/src/memory/privacy_remediation.rs index de2519112e..77e43689e0 100644 --- a/crates/tracedecay-usecases/src/memory/privacy_remediation.rs +++ b/crates/tracedecay-usecases/src/memory/privacy_remediation.rs @@ -3,12 +3,12 @@ //! Ingest sanitizes before persistence, but rows written under an older //! detector revision (or under legacy paths that predate the hard cut) can //! hold values the current detector would refuse. This owner re-runs the -//! current in-process detector over every currently served fact, redacts what -//! the detector can make safe, quarantines what it cannot, and settles every -//! mutation through the one canonical curation authority so the durable -//! curation receipt records exactly what changed. Nothing here executes a -//! scanner binary or touches the network, and no unsanitized payload is ever -//! persisted back. +//! current in-process detector over every currently served fact, quarantines +//! every detector hit, and settles every mutation through the one canonical +//! curation authority so durable curation receipts record exactly what +//! changed. Quarantine is intentionally terminal: updating only the current +//! projection would leave superseded assertion payloads at rest. Nothing here +//! executes a scanner binary or touches the network. use tracedecay_domain::Confidence; use tracedecay_runtime_core::privacy::{ @@ -17,14 +17,14 @@ use tracedecay_runtime_core::privacy::{ use tracedecay_store::{ FactReadControl, FactWriteControl, ProjectMemoryFactCurationReceiptV1, ProjectMemoryFactListQueryV1, ProjectMemoryFactProjectionV1, ProjectMemoryFactStore, - ProjectMemoryFactUpdatePatchV1, ProjectMemoryFactV1, + ProjectMemoryFactV1, }; use super::MemoryApplication; use super::context::MemoryOperationContext; use super::curation::{ProjectMemoryCurationMutationTarget, ProjectMemoryCurationOperation}; use super::error::{MemoryApplicationError, MemoryMutationError}; -use super::sanitize::{SanitizedFactPayloadWire, fact_payload_wire}; +use super::sanitize::fact_payload_wire; /// Why an at-rest rescan ran. Recorded on the receipt so operators can see /// which journey produced it; daemon store adoption is currently the only @@ -35,18 +35,17 @@ pub enum PrivacyRemediationTriggerV1 { DetectorRevisionAdoption, } -/// Truthful outcome of one at-rest rescan. `curation_receipt` is present -/// exactly when the rescan remediated at least one fact; the durable receipt -/// row is owned by the fact store's curation authority. +/// Truthful outcome of one at-rest rescan. One durable curation receipt is +/// returned for each bounded page that remediated at least one fact; receipt +/// rows are owned by the fact store's curation authority. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectMemoryPrivacyRemediationReceiptV1 { pub detector_revision: String, pub trigger: PrivacyRemediationTriggerV1, pub scanned_facts: u64, pub clean_facts: u64, - pub redacted_facts: u64, pub quarantined_facts: u64, - pub curation_receipt: Option, + pub curation_receipts: Vec, } /// One page of currently served facts per authority read. @@ -54,7 +53,6 @@ const RESCAN_PAGE_LIMIT: usize = 64; enum FactRescanDispositionV1 { Clean, - Redact(ProjectMemoryFactUpdatePatchV1), Quarantine, } @@ -73,9 +71,8 @@ impl MemoryApplication { let confidence = remediation_confidence()?; let mut scanned_facts = 0_u64; let mut clean_facts = 0_u64; - let mut operations = Vec::new(); - let mut redacted_facts = 0_u64; let mut quarantined_facts = 0_u64; + let mut curation_receipts = Vec::new(); let mut after_fact_id = None; loop { let query = ProjectMemoryFactListQueryV1::new( @@ -86,6 +83,7 @@ impl MemoryApplication { RESCAN_PAGE_LIMIT, )?; let page = self.list_project_memory_facts(query, read_control).await?; + let mut operations = Vec::new(); for projection in page.facts() { let ProjectMemoryFactProjectionV1::Available(fact) = projection else { // A withheld projection serves no payload, so there is @@ -101,16 +99,6 @@ impl MemoryApplication { FactRescanDispositionV1::Clean => { clean_facts = clean_facts.saturating_add(1); } - FactRescanDispositionV1::Redact(patch) => { - redacted_facts = redacted_facts.saturating_add(1); - operations.push(ProjectMemoryCurationOperation::Update { - target: target.clone(), - patch, - evidence_facts: vec![target], - confidence, - reason: "at-rest privacy rescan redacted detector findings".to_owned(), - }); - } FactRescanDispositionV1::Quarantine => { quarantined_facts = quarantined_facts.saturating_add(1); operations.push(ProjectMemoryCurationOperation::Remove { @@ -122,33 +110,39 @@ impl MemoryApplication { } } } + if !operations.is_empty() { + let context = MemoryOperationContext::generated( + &self.owner, + "privacy_remediation_rescan", + None, + )?; + let receipt = self + .apply_project_memory_curation( + operations, + confidence, + context, + None, + write_control, + ) + .await + .map_err(|error| match error { + MemoryMutationError::Application(error) => error, + MemoryMutationError::InvalidAuthorityResult { error, .. } => error, + })?; + curation_receipts.push(receipt); + } match page.next_after_fact_id() { Some(next) => after_fact_id = Some(next.clone()), None => break, } } - let curation_receipt = if operations.is_empty() { - None - } else { - let context = - MemoryOperationContext::generated(&self.owner, "privacy_remediation_rescan", None)?; - let receipt = self - .apply_project_memory_curation(operations, confidence, context, None, write_control) - .await - .map_err(|error| match error { - MemoryMutationError::Application(error) => error, - MemoryMutationError::InvalidAuthorityResult { error, .. } => error, - })?; - Some(receipt) - }; Ok(ProjectMemoryPrivacyRemediationReceiptV1 { detector_revision: MEMORY_FACT_SANITIZER_VERSION_V1.to_owned(), trigger, scanned_facts, clean_facts, - redacted_facts, quarantined_facts, - curation_receipt, + curation_receipts, }) } } @@ -184,19 +178,5 @@ fn rescan_fact( if payload == wire { return Ok(FactRescanDispositionV1::Clean); } - let sanitized = serde_json::from_value::(payload).map_err(|_| { - MemoryApplicationError::InvalidInput { - invariant: "at-rest privacy rescan sanitized payload", - } - })?; - let patch = ProjectMemoryFactUpdatePatchV1::new( - Some(sanitized.content), - Some(sanitized.category), - Some(sanitized.source_label), - Some(sanitized.tags), - Some(sanitized.entities), - Some(sanitized.metadata), - None, - )?; - Ok(FactRescanDispositionV1::Redact(patch)) + Ok(FactRescanDispositionV1::Quarantine) } diff --git a/src/daemon/privacy_remediation.rs b/src/daemon/privacy_remediation.rs index 3954ce9882..56c9c60b36 100644 --- a/src/daemon/privacy_remediation.rs +++ b/src/daemon/privacy_remediation.rs @@ -3,13 +3,11 @@ //! Project-open spawns one bounded background rescan per adopted project //! store after fail-closed admission has finished; it never blocks admission //! or retrieval. The rescan re-runs the current in-process detector over -//! persisted project-memory facts, redacts what the detector can make safe, -//! quarantines what it cannot, and settles every mutation through the -//! canonical curation authority so a durable curation receipt records what -//! changed. No scanner binary runs and no unsanitized payload is persisted. +//! persisted project-memory facts, quarantines detector hits, and settles +//! every mutation through the canonical curation authority so durable +//! curation receipts record what changed. No scanner binary runs. use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use tracedecay_store::{FactReadControl, FactWriteControl}; use tracedecay_usecases::memory::{ @@ -31,8 +29,8 @@ pub(crate) fn spawn_project_memory_privacy_remediation(graph: Arc) { detector_revision = %receipt.detector_revision, scanned_facts = receipt.scanned_facts, clean_facts = receipt.clean_facts, - redacted_facts = receipt.redacted_facts, quarantined_facts = receipt.quarantined_facts, + curation_batches = receipt.curation_receipts.len(), ); } Err(error) => { @@ -64,18 +62,10 @@ fn remediation_read_control() -> FactReadControl { FactReadControl::new(Arc::new(|| false)) } -/// One-shot commit gate: the rescan settles exactly one curation batch, and a -/// second commit attempt under the same control is refused. +/// The owner bounds every commit to one read page; the control admits each +/// canonical page receipt until that finite scan completes. fn remediation_write_control() -> FactWriteControl { - let granted = Arc::new(AtomicBool::new(false)); - FactWriteControl::new( - Arc::new(|| false), - Arc::new(move || { - granted - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - }), - ) + FactWriteControl::new(Arc::new(|| false), Arc::new(|| true)) } #[cfg(test)] @@ -232,12 +222,27 @@ mod tests { .collect() } + async fn persisted_payload_rows_containing( + database: &crate::db::Database, + marker: &str, + ) -> i64 { + database + .query_scalar_i64_with_text( + "inspect at-rest privacy remediation payloads", + "SELECT COUNT(*) FROM memory_v2_assertion_payloads + WHERE payload_json LIKE '%' || ?1 || '%' + OR content LIKE '%' || ?1 || '%'", + marker, + ) + .await + .expect("inspect persisted memory payloads") + } + #[tokio::test] - async fn at_rest_rescan_quarantines_and_redacts_legacy_detector_hits() { + async fn at_rest_rescan_quarantines_and_erases_legacy_detector_hits() { let temp = TempDir::new().expect("privacy remediation fixture root"); let profile_root = temp.path().join("profile"); - let project_id = - ProjectId::new("project.privacy-remediation.fixture").expect("project id"); + let project_id = ProjectId::new("project.privacy-remediation.fixture").expect("project id"); let project_root = enrolled_root(temp.path(), &project_id); let _database_scope = crate::db::enter_daemon_database_scope(&profile_root, 43, "privacy remediation test") @@ -296,28 +301,25 @@ mod tests { ); assert_eq!(receipt.scanned_facts, 3); assert_eq!(receipt.clean_facts, 1); - assert_eq!(receipt.redacted_facts, 1); - assert_eq!(receipt.quarantined_facts, 1); + assert_eq!(receipt.quarantined_facts, 2); let curation = receipt - .curation_receipt - .as_ref() + .curation_receipts + .first() .expect("remediation hits settle one durable curation receipt"); - assert_eq!(curation.facts_updated(), 1); - assert_eq!(curation.facts_removed(), 1); + assert_eq!(curation.facts_updated(), 0); + assert_eq!(curation.facts_removed(), 2); - // Served content no longer carries the secret anywhere, and the - // quarantined fact stopped being served entirely. + // Detector-hit facts stopped being served entirely. let served = served_contents(&memory, &owner).await; - assert_eq!(served.len(), 2, "the quarantined fact must not serve"); + assert_eq!(served.len(), 1, "quarantined facts must not serve"); assert!( served.iter().all(|content| !content.contains(&secret())), "no served fact may retain the detector hit" ); - assert!( - served - .iter() - .any(|content| content.contains("deploys authenticate with the token")), - "the redactable fact must stay served with sanitized content" + assert_eq!( + persisted_payload_rows_containing(&database, &secret()).await, + 0, + "detector hits must be physically absent from every assertion payload row" ); // A second pass over the remediated store is clean and settles no @@ -330,10 +332,65 @@ mod tests { ) .await .expect("idempotent rescan"); - assert_eq!(second.scanned_facts, 2); - assert_eq!(second.clean_facts, 2); - assert_eq!(second.redacted_facts, 0); + assert_eq!(second.scanned_facts, 1); + assert_eq!(second.clean_facts, 1); assert_eq!(second.quarantined_facts, 0); - assert!(second.curation_receipt.is_none()); + assert!(second.curation_receipts.is_empty()); + } + + #[tokio::test] + async fn remediation_commits_more_than_one_curation_batch_without_leaving_secret_bytes() { + let home = TempDir::new().expect("isolated home"); + let profile_root = home.path().join("profile"); + let project_id = ProjectId::new("project.privacy-remediation-many").expect("project id"); + let project_root = enrolled_root(home.path(), &project_id); + let _database_scope = crate::db::enter_daemon_database_scope( + &profile_root, + 43, + "privacy remediation batch test", + ) + .expect("daemon database scope"); + let identity = profile_identity::load_or_create(&profile_root).expect("profile identity"); + let registry = DaemonSessionRuntimeRegistryV1::open(identity) + .await + .expect("daemon registry"); + let database = registry + .project_memory(project_id.clone(), [project_root]) + .await + .expect("project memory authority"); + let owner = FactOwnerV1::Project { + project_id: project_id.clone(), + }; + for index in 0..257_u16 { + seed_legacy_fact( + &database, + &owner, + &format!("dirty-{index}"), + &format!("credential {index} is {}", secret()), + json!({"fixture": "many-dirty", "index": index}), + ) + .await; + } + + let memory = MemoryApplication::new(owner, DatabaseFactStore::new(&database)) + .expect("owner-bound memory application"); + let receipt = memory + .privacy_remediation_rescan( + PrivacyRemediationTriggerV1::DetectorRevisionAdoption, + &remediation_read_control(), + &remediation_write_control(), + ) + .await + .expect("every bounded remediation batch commits"); + + assert_eq!(receipt.scanned_facts, 257); + assert_eq!(receipt.clean_facts, 0); + assert_eq!(receipt.quarantined_facts, 257); + assert_eq!(receipt.curation_receipts.len(), 5); + assert_eq!( + persisted_payload_rows_containing(&database, &secret()).await, + 0, + "no batch may leave secret-bearing assertion payloads behind" + ); } } diff --git a/src/mcp/tools/handlers/dashboard.rs b/src/mcp/tools/handlers/dashboard.rs index a31e9e1323..2177bbb8f6 100644 --- a/src/mcp/tools/handlers/dashboard.rs +++ b/src/mcp/tools/handlers/dashboard.rs @@ -31,8 +31,7 @@ use crate::dashboard::{ DashboardApplicationRuntime, DashboardAutomationWriter, DashboardConfigurationApplyError, DashboardConfigurationApplyFuture, DashboardDaemonReadUnavailableV1, DashboardHttpRequestControlV1, DashboardScopeSetReadFuture, DashboardStateCompositionV1, - bind_dashboard, - build_state_with_automation_reconciler, router, validate_dashboard_host, + bind_dashboard, build_state_with_automation_reconciler, router, validate_dashboard_host, }; struct DashboardInvocationExecutorAdapter { @@ -71,6 +70,26 @@ impl DashboardApplicationRuntime for DashboardInvocationExecutorAdapter { self.user_profile_id.as_ref() } + fn for_project_root( + &self, + project_root: &std::path::Path, + ) -> std::result::Result, String> { + let handshake = crate::daemon::DaemonHandshake::for_current_client( + Some(project_root.to_path_buf()), + None, + false, + false, + ) + .map_err(|error| error.to_string())?; + let executor: Arc = Arc::new( + crate::daemon_client::DaemonInvocationClient::for_current(handshake) + .map_err(|error| error.to_string())?, + ); + Self::new(executor, self.user_profile_id.clone()) + .map(|runtime| Arc::new(runtime) as Arc) + .map_err(|error| error.to_string()) + } + fn routers( &self, active_project_id: ProjectId, @@ -165,13 +184,14 @@ impl DashboardApplicationRuntime for DashboardInvocationExecutorAdapter { .map_err(|error| DashboardDaemonReadUnavailableV1 { detail: error.to_string(), })?; - let invocation = crate::daemon_contract::DaemonInvocationRequest::multi_root_scope_set_read( - control.request_id().as_str(), - request, - control.observed_at(), - control.deadline(), - control.cancellation().context(), - ); + let invocation = + crate::daemon_contract::DaemonInvocationRequest::multi_root_scope_set_read( + control.request_id().as_str(), + request, + control.observed_at(), + control.deadline(), + control.cancellation().context(), + ); let response = executor .invoke_controlled( invocation, @@ -193,22 +213,21 @@ impl DashboardApplicationRuntime for DashboardInvocationExecutorAdapter { detail: "the daemon multi-root read returned no evidence payload" .to_owned(), }), - crate::daemon_contract::DaemonInvocationOutcome::ApplicationProblem { - problem, - } => Err(DashboardDaemonReadUnavailableV1 { - detail: format!( - "the daemon rejected the multi-root read: {}", - problem.safe_message() - ), - }), + crate::daemon_contract::DaemonInvocationOutcome::ApplicationProblem { problem } => { + Err(DashboardDaemonReadUnavailableV1 { + detail: format!( + "the daemon rejected the multi-root read: {}", + problem.safe_message() + ), + }) + } crate::daemon_contract::DaemonInvocationOutcome::Problem { problem } => { Err(DashboardDaemonReadUnavailableV1 { detail: format!("the daemon refused the multi-root read: {problem:?}"), }) } _ => Err(DashboardDaemonReadUnavailableV1 { - detail: "the daemon multi-root read answered with a foreign outcome" - .to_owned(), + detail: "the daemon multi-root read answered with a foreign outcome".to_owned(), }), } }) @@ -239,40 +258,59 @@ pub(crate) async fn dashboard_native_integration_status( > { use crate::dashboard::DashboardDaemonReadUnavailableV1; - let result = crate::application_surface::resolve_dashboard_application_surface( + let request = crate::daemon_contract::DaemonInvocationRequest::native_integration( + control.request_id().as_str(), crate::application_surface::ApplicationSurfaceOperation::NativeIntegrationStatus, - control.request_id(), - crate::application_surface::ApplicationSurfaceRequest::NativeIntegration( - crate::application_surface::NativeIntegrationSurfaceRequest::Status( - tracedecay_application::NativeIntegrationStatusSurfaceRequest { transaction_id }, - ), + crate::application_surface::NativeIntegrationSurfaceRequest::Status( + tracedecay_application::NativeIntegrationStatusSurfaceRequest { transaction_id }, ), - crate::daemon_client::RequestedOutputFormat::Json, - Some(executor), - ) - .await - .map_err(|error| DashboardDaemonReadUnavailableV1 { - detail: format!("the dashboard native-integration surface is unavailable: {error}"), - })?; - let envelope = result - .result - .map_err(|problem| DashboardDaemonReadUnavailableV1 { - detail: format!( - "the daemon rejected the native-integration status read: {}", - problem.problem.message - ), + control.observed_at(), + control.deadline(), + control.cancellation().context(), + ); + let response = executor + .invoke_controlled( + request, + control.deadline(), + control.cancellation().clone(), + crate::daemon_client::InvocationCancellationPolicy::ReadOnly, + ) + .await + .map_err(|error| DashboardDaemonReadUnavailableV1 { + detail: format!("the dashboard native-integration transport failed: {error:?}"), })?; - let tracedecay_application::ApplicationOutcome::Evidence(packet) = envelope.outcome else { - return Err(DashboardDaemonReadUnavailableV1 { - detail: "the native-integration status read answered with a foreign outcome" - .to_owned(), - }); + let payload = match response.outcome { + crate::daemon_contract::DaemonInvocationOutcome::NativeIntegration { + outcome: tracedecay_application::ApplicationOutcome::Evidence(packet), + .. + } => packet + .payload + .ok_or_else(|| DashboardDaemonReadUnavailableV1 { + detail: "the native-integration status read returned no evidence payload" + .to_owned(), + })?, + crate::daemon_contract::DaemonInvocationOutcome::ApplicationProblem { problem } => { + return Err(DashboardDaemonReadUnavailableV1 { + detail: format!( + "the daemon rejected the native-integration status read: {}", + problem.safe_message() + ), + }); + } + crate::daemon_contract::DaemonInvocationOutcome::Problem { problem } => { + return Err(DashboardDaemonReadUnavailableV1 { + detail: format!( + "the daemon refused the native-integration status read: {problem:?}" + ), + }); + } + _ => { + return Err(DashboardDaemonReadUnavailableV1 { + detail: "the native-integration status read answered with a foreign outcome" + .to_owned(), + }); + } }; - let payload = packet - .payload - .ok_or_else(|| DashboardDaemonReadUnavailableV1 { - detail: "the native-integration status read returned no evidence payload".to_owned(), - })?; serde_json::from_value(payload).map_err(|_| DashboardDaemonReadUnavailableV1 { detail: "the native-integration status payload violated its wire contract".to_owned(), }) From a19bc5a11597bf22a1c3b6aea39d9f7f9a73e651 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 19 Aug 2026 20:07:46 +0000 Subject: [PATCH 11/12] fix(privacy): scan labels and drain retained statuses --- .../protocol/native_integration_controller.rs | 42 ++++++++++++++++++- .../src/memory/privacy_remediation.rs | 11 ++++- src/daemon/privacy_remediation.rs | 27 ++++++++++-- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs b/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs index 55c5ea05e6..d6eff5014a 100644 --- a/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs +++ b/crates/tracedecay-lsp/src/protocol/native_integration_controller.rs @@ -47,7 +47,8 @@ where let Some(port) = self.native_integration.port.clone() else { return; }; - for projection in port.poll_status(MAX_NATIVE_INTEGRATION_STATUS_PER_POLL) { + let mut emitted = 0_usize; + for projection in port.poll_status(MAX_TRACKED_NATIVE_INTEGRATION_TRANSACTIONS) { let identity = ( projection.repository_id.clone(), projection.transaction_id.clone(), @@ -55,6 +56,9 @@ where if self.native_integration.notified.get(&identity) == Some(&projection) { continue; } + if emitted == MAX_NATIVE_INTEGRATION_STATUS_PER_POLL { + break; + } let Ok(params) = serde_json::to_value(&projection) else { continue; }; @@ -69,6 +73,7 @@ where self.native_integration .notified .insert(identity, projection); + emitted = emitted.saturating_add(1); if self.native_integration.notified.len() > MAX_TRACKED_NATIVE_INTEGRATION_TRANSACTIONS && let Some(oldest) = self .native_integration @@ -97,7 +102,8 @@ mod tests { use super::super::tests::{initialize, session}; use crate::native_integration::{ - NativeIntegrationStatusPort, TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD, + MAX_NATIVE_INTEGRATION_STATUS_PER_POLL, NativeIntegrationStatusPort, + TRACEDECAY_NATIVE_INTEGRATION_STATUS_METHOD, }; struct ScriptedStatusPort { @@ -254,4 +260,36 @@ mod tests { "unchanged statuses with colliding transaction ids must stay deduped" ); } + + #[test] + fn retained_statuses_beyond_one_flush_are_eventually_notified() { + let statuses = (0..=MAX_NATIVE_INTEGRATION_STATUS_PER_POLL) + .map(|index| { + projection_for( + "repository.lsp.notify", + &format!("transaction.lsp.notify.{index}"), + NativeIntegrationPhaseV1::Prepared, + 1, + None, + ) + }) + .collect::>(); + let port = ScriptedStatusPort::holding(statuses[0].clone()); + port.replace_many(statuses); + let mut session = + session().with_native_integration_status_port(Arc::clone(&port) as Arc<_>); + + initialize(&mut session); + assert_eq!( + native_integration_notifications(session.drain_outbound()).len(), + MAX_NATIVE_INTEGRATION_STATUS_PER_POLL + ); + + session.flush_due(2); + assert_eq!( + native_integration_notifications(session.drain_outbound()).len(), + 1, + "the next flush must advance beyond the first bounded notification page" + ); + } } diff --git a/crates/tracedecay-usecases/src/memory/privacy_remediation.rs b/crates/tracedecay-usecases/src/memory/privacy_remediation.rs index 77e43689e0..d9ebdc9f87 100644 --- a/crates/tracedecay-usecases/src/memory/privacy_remediation.rs +++ b/crates/tracedecay-usecases/src/memory/privacy_remediation.rs @@ -24,7 +24,7 @@ use super::MemoryApplication; use super::context::MemoryOperationContext; use super::curation::{ProjectMemoryCurationMutationTarget, ProjectMemoryCurationOperation}; use super::error::{MemoryApplicationError, MemoryMutationError}; -use super::sanitize::fact_payload_wire; +use super::sanitize::{fact_payload_wire, sanitize_optional_memory_text}; /// Why an at-rest rescan ran. Recorded on the receipt so operators can see /// which journey produced it; daemon store adoption is currently the only @@ -159,13 +159,20 @@ fn remediation_confidence() -> Result { fn rescan_fact( fact: &ProjectMemoryFactV1, ) -> Result { + let Some(source_label) = sanitize_optional_memory_text(fact.source_label().map(str::to_owned)) + else { + return Ok(FactRescanDispositionV1::Quarantine); + }; + if source_label.as_deref() != fact.source_label() { + return Ok(FactRescanDispositionV1::Quarantine); + } let wire = fact_payload_wire( fact.content(), fact.category(), fact.tags(), fact.entities(), fact.metadata(), - fact.source_label(), + source_label.as_deref(), ); let sanitized = sanitize_memory_fact_payload(wire.clone()).map_err(|_| { MemoryApplicationError::InvalidInput { diff --git a/src/daemon/privacy_remediation.rs b/src/daemon/privacy_remediation.rs index 56c9c60b36..322e7d9a52 100644 --- a/src/daemon/privacy_remediation.rs +++ b/src/daemon/privacy_remediation.rs @@ -143,6 +143,7 @@ mod tests { owner: &FactOwnerV1, label: &str, content: &str, + source_label: Option<&str>, metadata: Value, ) { let mut tags = Vec::new(); @@ -180,7 +181,7 @@ mod tests { owner.clone(), content.to_owned(), FactCategoryV1::Project, - None, + source_label.map(str::to_owned), tags, entities, metadata, @@ -264,6 +265,7 @@ mod tests { &owner, "clean", "the retry budget is three attempts", + None, json!({"fixture": "clean"}), ) .await; @@ -272,6 +274,7 @@ mod tests { &owner, "redactable", &format!("deploys authenticate with the token {}", secret()), + None, json!({"fixture": "redactable"}), ) .await; @@ -280,9 +283,19 @@ mod tests { &owner, "quarantinable", "the staging credentials map is keyed by raw token", + None, json!({ secret(): "staging" }), ) .await; + seed_legacy_fact( + &database, + &owner, + "structured-source-label", + "the deployment source is recorded", + Some("provider:\n vault_passphrase: ordinary-value\n"), + json!({"fixture": "structured-source-label"}), + ) + .await; let memory = MemoryApplication::new(owner.clone(), DatabaseFactStore::new(&database)) .expect("owner-bound memory application"); @@ -299,15 +312,15 @@ mod tests { receipt.trigger, PrivacyRemediationTriggerV1::DetectorRevisionAdoption ); - assert_eq!(receipt.scanned_facts, 3); + assert_eq!(receipt.scanned_facts, 4); assert_eq!(receipt.clean_facts, 1); - assert_eq!(receipt.quarantined_facts, 2); + assert_eq!(receipt.quarantined_facts, 3); let curation = receipt .curation_receipts .first() .expect("remediation hits settle one durable curation receipt"); assert_eq!(curation.facts_updated(), 0); - assert_eq!(curation.facts_removed(), 2); + assert_eq!(curation.facts_removed(), 3); // Detector-hit facts stopped being served entirely. let served = served_contents(&memory, &owner).await; @@ -321,6 +334,11 @@ mod tests { 0, "detector hits must be physically absent from every assertion payload row" ); + assert_eq!( + persisted_payload_rows_containing(&database, "ordinary-value").await, + 0, + "structured source-label findings must be erased from assertion payload rows" + ); // A second pass over the remediated store is clean and settles no // further mutation: the rescan is idempotent. @@ -367,6 +385,7 @@ mod tests { &owner, &format!("dirty-{index}"), &format!("credential {index} is {}", secret()), + None, json!({"fixture": "many-dirty", "index": index}), ) .await; From b4f4fe8e9b06097cdaf8c881f4b3ed470352c516 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 19 Aug 2026 20:21:37 +0000 Subject: [PATCH 12/12] test(privacy): bind structured label fixture receipt --- src/daemon/privacy_remediation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/daemon/privacy_remediation.rs b/src/daemon/privacy_remediation.rs index 322e7d9a52..7125de3c45 100644 --- a/src/daemon/privacy_remediation.rs +++ b/src/daemon/privacy_remediation.rs @@ -154,7 +154,7 @@ mod tests { &mut tags, &mut entities, &metadata, - None, + source_label, ) .expect("legacy payload reference"); let sanitizer_version = ComponentVersion::new( @@ -292,7 +292,7 @@ mod tests { &owner, "structured-source-label", "the deployment source is recorded", - Some("provider:\n vault_passphrase: ordinary-value\n"), + Some(r#"{"provider":{"vault_passphrase":"ordinary-value"}}"#), json!({"fixture": "structured-source-label"}), ) .await;