diff --git a/crates/tracedecay-global-db/src/schema_stages.rs b/crates/tracedecay-global-db/src/schema_stages.rs index e327ff2b3d..e4fbc3156c 100644 --- a/crates/tracedecay-global-db/src/schema_stages.rs +++ b/crates/tracedecay-global-db/src/schema_stages.rs @@ -35,6 +35,7 @@ use tracedecay_rusqlite_runtime::workflow::{ use tracedecay_sessions::runtime::git_correlation::{ GIT_CORRELATION_SCHEMA_VERSION, recorded_git_correlation_schema_version, }; +use tracedecay_store::StoreShardScopeV1; const REGISTRY_SCHEMA: &str = " CREATE TABLE IF NOT EXISTS projects ( @@ -400,7 +401,31 @@ impl RegisteredSchemaConvergence { struct RegisteredSchemaAdmissionClassification { configuration_fresh: Option, temporal_admission: session_temporal_schema::SessionTemporalSchemaAdmission, - workflow_admission: WorkflowSchemaAdmission, + session_features: SessionFeatureSchema, +} + +/// Whether admission gates, installs, and converges the version-gated +/// session-feature schemas (LCM, git correlation, workflows) of a store. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionFeatureSchema { + Hosted { + workflow: WorkflowSchemaAdmission, + }, + /// An existing profile authority: these schemas are left exactly as + /// found and never read. + NotHosted, +} + +/// The profile authority (`global.db`) holds the project registry, usage +/// accounting, and remote-deletion records, never session rows: sessions, +/// LCM, git correlation, and workflows live in the profile and project +/// session stores, and LCM refuses writes to any other store. A fresh +/// profile authority still receives the whole registered schema, whose +/// authority triggers reference the LCM tables, but an existing one never +/// admits, refuses, re-ensures, or converges those features, so a +/// session-feature schema change cannot reset the registry. +fn hosts_session_features(scope: &StoreShardScopeV1) -> bool { + !matches!(scope, StoreShardScopeV1::Profile) } /// A store's admission verdict. A store whose other authorities admit but @@ -426,20 +451,27 @@ enum RegisteredSchemaAdmission { #[hotpath::measure(future = true, label = "global_db.schema.query.classify")] async fn classify_registered_schema_admission( connection: &impl QueryExecutor, + scope: &StoreShardScopeV1, ) -> tracedecay_domain::errors::Result { - Box::pin(classify_registered_schema_authorities(connection)).await + Box::pin(classify_registered_schema_authorities(connection, scope)).await } async fn classify_registered_schema_authorities( connection: &impl QueryExecutor, + scope: &StoreShardScopeV1, ) -> tracedecay_domain::errors::Result { + let hosts_session_features = hosts_session_features(scope); // The LCM authority classifies profile content first. Whenever a later // authority also fails, the earliest session refusal is the store's hard // verdict: a legacy or version-skewed session store surfaces its own // reset identity instead of being masked by the coarser configuration // schema resets, which would also flag a store those features were simply // never installed in. - let refused_lcm = lcm_schema_refusal(connection).await?; + let refused_lcm = if hosts_session_features { + lcm_schema_refusal(connection).await? + } else { + None + }; let surface = |refused: Option| { move |error| refused.map_or(error, RefusedAuthorityV1::error) }; @@ -456,13 +488,22 @@ async fn classify_registered_schema_authorities( .await .map_err(surface(refused_lcm))?; let refused = refused_lcm.or(temporal_admission.err()); - let workflow_admission = inspect_workflow_schema_for_admission(connection) - .await - .map_err(surface(refused))?; - let refused = refused.or(workflow_admission.err()); - let refused_git_correlation = git_correlation_schema_refusal(connection) - .await - .map_err(surface(refused))?; + let session_features = if hosts_session_features || configuration_fresh.is_some() { + inspect_workflow_schema_for_admission(connection) + .await + .map_err(surface(refused))? + .map(|workflow| SessionFeatureSchema::Hosted { workflow }) + } else { + Ok(SessionFeatureSchema::NotHosted) + }; + let refused = refused.or(session_features.err()); + let refused_git_correlation = if hosts_session_features { + git_correlation_schema_refusal(connection) + .await + .map_err(surface(refused))? + } else { + None + }; let refused = refused.or(refused_git_correlation); configuration::admit_configuration_schema(connection, configuration_fresh.as_ref()) .await @@ -482,12 +523,12 @@ async fn classify_registered_schema_authorities( ), )); } - Ok(match (refused, temporal_admission, workflow_admission) { - (None, Ok(temporal_admission), Ok(workflow_admission)) => { + Ok(match (refused, temporal_admission, session_features) { + (None, Ok(temporal_admission), Ok(session_features)) => { RegisteredSchemaAdmission::Admissible(RegisteredSchemaAdmissionClassification { configuration_fresh, temporal_admission, - workflow_admission, + session_features, }) } (Some(refused), _, _) | (None, Err(refused), _) | (None, _, Err(refused)) => { @@ -588,8 +629,13 @@ pub async fn ensure_registered_schema_for_admission( let RegisteredSchemaAdmissionClassification { configuration_fresh, temporal_admission, - workflow_admission, - } = match classify_registered_schema_admission(installation).await? { + session_features, + } = match classify_registered_schema_admission( + installation, + &installation.binding().shard_id.scope, + ) + .await? + { RegisteredSchemaAdmission::Admissible(classification) => classification, RegisteredSchemaAdmission::SessionAuthorityRefused(refused) => { return Err(refused.error()); @@ -610,7 +656,7 @@ pub async fn ensure_registered_schema_for_admission( }, configuration_fresh.as_ref(), temporal_admission, - workflow_admission, + session_features, force_exhaustive, "commit registered global schema", "roll back registered global schema", @@ -629,13 +675,15 @@ pub async fn ensure_registered_schema_for_admission( // independently durable outside the shared schema transaction so a // real-scale index build gets the long lease without holding every other // installation stage open. - for sql in tracedecay_lcm::schema::LCM_STATUS_PERFORMANCE_INDEX_SQL { - installation - .execute_authority_revalidated_batch(sql) - .await - .map_err(|error| { - global_db_operation_error("initialize LCM status performance indexes", error) - })?; + if session_features != SessionFeatureSchema::NotHosted { + for sql in tracedecay_lcm::schema::LCM_STATUS_PERFORMANCE_INDEX_SQL { + installation + .execute_authority_revalidated_batch(sql) + .await + .map_err(|error| { + global_db_operation_error("initialize LCM status performance indexes", error) + })?; + } } validate_admitted_authority_schema(installation, is_fresh).await?; Ok(RegisteredSchemaConvergence { @@ -655,14 +703,14 @@ async fn install_registered_schema_stages( transaction: &(impl Executor + Sync), configuration_fresh: Option<&configuration::FreshConfigurationStoreEvidence>, temporal_admission: session_temporal_schema::SessionTemporalSchemaAdmission, - workflow_admission: WorkflowSchemaAdmission, + session_features: SessionFeatureSchema, force_exhaustive: bool, ) -> tracedecay_domain::errors::Result> { Box::pin(install_registered_schema_stage_sequence( transaction, configuration_fresh, temporal_admission, - workflow_admission, + session_features, force_exhaustive, )) .await @@ -672,7 +720,7 @@ async fn install_and_commit_registered_schema( install: CancellableSchemaTransaction<'_, T>, configuration_fresh: Option<&configuration::FreshConfigurationStoreEvidence>, temporal_admission: session_temporal_schema::SessionTemporalSchemaAdmission, - workflow_admission: WorkflowSchemaAdmission, + session_features: SessionFeatureSchema, force_exhaustive: bool, commit_operation: &'static str, rollback_operation: &'static str, @@ -684,7 +732,7 @@ where &install, configuration_fresh, temporal_admission, - workflow_admission, + session_features, force_exhaustive, ) .await @@ -809,7 +857,7 @@ async fn install_registered_schema_stage_sequence( transaction: &(impl Executor + Sync), configuration_fresh: Option<&configuration::FreshConfigurationStoreEvidence>, temporal_admission: session_temporal_schema::SessionTemporalSchemaAdmission, - workflow_admission: WorkflowSchemaAdmission, + session_features: SessionFeatureSchema, force_exhaustive: bool, ) -> tracedecay_domain::errors::Result> { crate::hotpath_observe::record_transaction_rows(1); @@ -880,7 +928,11 @@ async fn install_registered_schema_stage_sequence( .map_err(|error| { global_db_operation_error("initialize observability rollup schema", error) })?; - if workflow_admission == WorkflowSchemaAdmission::Create { + if session_features + == (SessionFeatureSchema::Hosted { + workflow: WorkflowSchemaAdmission::Create, + }) + { for table in WORKFLOW_TABLE_CONTRACTS_V1 { transaction .execute_batch(table.sql) @@ -952,19 +1004,21 @@ async fn install_registered_schema_stage_sequence( // Projection raw-twin triggers sit on `lcm_raw_messages`. The table has to // exist before those triggers are created, including on a fresh store // whose authority triggers are installed in this same transaction. - tracedecay_lcm::schema::ensure_lcm_schema_in_transaction(transaction) - .await - .map_err(|error| match error { - tracedecay_lcm::LcmError::ProfileResetRequired { - found_version, - required_version, - } => tracedecay_domain::errors::TraceDecayError::ProfileResetRequired { - component: "LCM", - found_version, - required_version, - }, - error => global_db_operation_error("initialize LCM schema", error), - })?; + if session_features != SessionFeatureSchema::NotHosted { + tracedecay_lcm::schema::ensure_lcm_schema_in_transaction(transaction) + .await + .map_err(|error| match error { + tracedecay_lcm::LcmError::ProfileResetRequired { + found_version, + required_version, + } => tracedecay_domain::errors::TraceDecayError::ProfileResetRequired { + component: "LCM", + found_version, + required_version, + }, + error => global_db_operation_error("initialize LCM schema", error), + })?; + } // `force_exhaustive` means admission observed damaged or missing guard // triggers (for example a dropped guarded table takes its triggers with // it). Reinstall them here so the post-commit contract validation sees a @@ -982,14 +1036,18 @@ async fn install_registered_schema_stage_sequence( )); } } - tracedecay_sessions::runtime::git_correlation::ensure_git_correlation_receipt_schema_in_transaction( + if session_features != SessionFeatureSchema::NotHosted { + tracedecay_sessions::runtime::git_correlation::ensure_git_correlation_receipt_schema_in_transaction( transaction, ) .await .map_err(|error| global_db_operation_error("initialize git correlation schema", error))?; - tracedecay_sessions::runtime::workflow_index::ensure_workflow_index_schema(transaction) - .await - .map_err(|error| global_db_operation_error("initialize workflow index schema", error))?; + tracedecay_sessions::runtime::workflow_index::ensure_workflow_index_schema(transaction) + .await + .map_err(|error| { + global_db_operation_error("initialize workflow index schema", error) + })?; + } Ok(refused_authority) } @@ -1076,12 +1134,14 @@ async fn converge_registered_schema_on( pub async fn converge_attached_registered_schema( database: &Database, ) -> tracedecay_domain::errors::Result<()> { - converge_migration_batches( - database, - "converge LCM status performance indexes", - tracedecay_lcm::schema::LCM_STATUS_PERFORMANCE_INDEX_SQL, - ) - .await?; + if hosts_session_features(&database.registered_binding().shard_id.scope) { + converge_migration_batches( + database, + "converge LCM status performance indexes", + tracedecay_lcm::schema::LCM_STATUS_PERFORMANCE_INDEX_SQL, + ) + .await?; + } let force_exhaustive = !authority_invariant_triggers_intact(&database.read_connection()).await?; converge_registered_schema_on( @@ -1130,8 +1190,13 @@ pub(crate) async fn ensure_attached_registered_schema( let RegisteredSchemaAdmissionClassification { configuration_fresh, temporal_admission, - workflow_admission, - } = match classify_registered_schema_admission(&read_connection).await? { + session_features, + } = match classify_registered_schema_admission( + &read_connection, + &database.registered_binding().shard_id.scope, + ) + .await? + { RegisteredSchemaAdmission::Admissible(classification) => classification, RegisteredSchemaAdmission::SessionAuthorityRefused(refused) => { return Ok(RegisteredSchemaAttachmentV1::SessionsRefused(refused)); @@ -1147,7 +1212,7 @@ pub(crate) async fn ensure_attached_registered_schema( }, configuration_fresh.as_ref(), temporal_admission, - workflow_admission, + session_features, force_exhaustive, "commit attached registered global schema", "roll back attached registered global schema", @@ -1171,7 +1236,7 @@ pub(crate) async fn ensure_attached_registered_schema( None => RegisteredSchemaAttachmentV1::Admitted(RegisteredSchemaConvergence { force_exhaustive, is_fresh: configuration_fresh.is_some(), - lcm_status_performance_indexes: true, + lcm_status_performance_indexes: session_features != SessionFeatureSchema::NotHosted, }), }) } @@ -1198,8 +1263,13 @@ pub(crate) async fn attached_registered_schema_reset_refusal( let RegisteredSchemaAdmissionClassification { configuration_fresh, temporal_admission, - workflow_admission, - } = match classify_registered_schema_admission(&read_connection).await { + session_features, + } = match classify_registered_schema_admission( + &read_connection, + &database.registered_binding().shard_id.scope, + ) + .await + { Ok(RegisteredSchemaAdmission::Admissible(classification)) => classification, Ok(RegisteredSchemaAdmission::SessionAuthorityRefused(refused)) => { return Ok(Some(refused.error())); @@ -1212,7 +1282,7 @@ pub(crate) async fn attached_registered_schema_reset_refusal( &transaction, configuration_fresh.as_ref(), temporal_admission, - workflow_admission, + session_features, force_exhaustive, ) .await; @@ -1430,6 +1500,108 @@ mod tests { } } + /// Every registered store a released binary wrote carries the same + /// session-feature markers, `global.db` included. A session store with an + /// older LCM, git correlation, or workflow schema is refused for its + /// sessions, while the profile authority, which holds no session rows, + /// keeps serving its registry and leaves those markers exactly as found. + #[tokio::test] + async fn released_session_feature_markers_refuse_session_stores_but_not_the_profile_authority() + { + const AGE_SESSION_FEATURES: &str = " + UPDATE session_schema_migrations SET version = 13 WHERE name = 'lcm'; + UPDATE session_schema_migrations SET version = 5 WHERE name = 'git_correlation'; + UPDATE workflow_schema SET definition_digest = + 'sha256:0000000000000000000000000000000000000000000000000000000000000000';"; + let directory = TempDir::new().unwrap(); + let project_root = directory.path().join("project"); + std::fs::create_dir_all(&project_root).unwrap(); + let mut verdicts = Vec::new(); + for (file, scope) in [ + ("global.db", TestDatabaseRuntimeScope::Profile), + ( + "user-sessions.db", + TestDatabaseRuntimeScope::ProfileSessions, + ), + ] { + let path = directory.path().join(file); + let (lease, owner) = open_registered_test_database_fixture(&path, scope.clone()) + .await + .unwrap(); + lease + .upsert_code_project( + "project.registered", + &project_root, + None, + None, + Some("main"), + ) + .await + .unwrap(); + drop((lease, owner)); + rusqlite::Connection::open(&path) + .unwrap() + .execute_batch(AGE_SESSION_FEATURES) + .unwrap(); + + let (lease, owner) = open_registered_test_database_fixture(&path, scope) + .await + .unwrap_or_else(|error| panic!("{file} must stay admissible: {error}")); + let refusal = owner.reset_required().map(|error| match error { + tracedecay_domain::errors::TraceDecayError::ProfileResetRequired { + component, + found_version, + required_version, + } => (component, found_version, required_version), + other => panic!("{file} refused with an untyped reset: {other}"), + }); + let projects: Vec = lease + .list_code_projects(10) + .await + .unwrap() + .into_iter() + .map(|project| project.project_id) + .collect(); + drop((lease, owner)); + let markers: (i64, i64, String) = rusqlite::Connection::open(&path) + .unwrap() + .query_row( + "SELECT + (SELECT version FROM session_schema_migrations WHERE name = 'lcm'), + (SELECT version FROM session_schema_migrations + WHERE name = 'git_correlation'), + (SELECT definition_digest FROM workflow_schema)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + verdicts.push((file, refusal, projects, markers)); + } + + let aged_markers = ( + 13, + 5, + "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_owned(), + ); + assert_eq!( + verdicts, + vec![ + ( + "global.db", + None, + vec!["project.registered".to_owned()], + aged_markers.clone(), + ), + ( + "user-sessions.db", + Some(("LCM", Some(13), 14)), + vec!["project.registered".to_owned()], + aged_markers, + ), + ] + ); + } + #[tokio::test] async fn existing_store_reinstalls_the_project_leading_session_lookup_index() { let directory = TempDir::new().unwrap(); diff --git a/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_project_sessions_census.rs b/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_project_sessions_census.rs index 205fa64f97..b811e40527 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_project_sessions_census.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_project_sessions_census.rs @@ -198,6 +198,133 @@ fn one_scoped_reset_clears_the_profile_authority_and_every_project_sessions_stor let _ = daemon.kill_and_wait(); } +/// The shape v1.0.0-beta.65 left: every registered store, `global.db` +/// included, records LCM schema 13. One census names the session stores +/// only; the profile authority holds no session rows, so its registry keeps +/// both projects through the scoped reset without another `tracedecay init`. +#[test] +fn lcm_13_profile_resets_its_session_stores_and_keeps_the_registry() { + let home = tempfile::TempDir::new().expect("isolated home"); + let home_path = canonical_existing_path(home.path()); + let profile_root = home_path.join(".tracedecay"); + let projects: Vec<(tempfile::TempDir, PathBuf, String)> = (0..2) + .map(|_| { + let project = tempfile::TempDir::new().expect("project"); + let path = canonical_existing_path(project.path()); + let id = tracedecay_runtime_core::storage::default_profile_project_id(&path); + (project, path, id) + }) + .collect(); + let mut ids: Vec<&str> = projects.iter().map(|(_, _, id)| id.as_str()).collect(); + ids.sort_unstable(); + let first_project = projects[0].1.as_path(); + + let mut daemon = spawn_tracedecay_daemon_with(&home_path, |_| {}); + for (_, path, _) in &projects { + super::initialize_project(&home_path, path, "lcm-13-profile"); + wait_for_code_index_hit(&home_path, path, "probe"); + } + // The profile session store is mounted by the first session read. + super::tool_call( + &home_path, + first_project, + "tracedecay_lcm_status", + &serde_json::json!({ "storage_scope": "user", "format": "json" }), + ); + daemon + .kill_and_wait() + .expect("stop the daemon that wrote the profile"); + let mut released_stores = vec![ + PathBuf::from("global.db"), + PathBuf::from("user-sessions.db"), + ]; + released_stores.extend( + ids.iter() + .map(|id| PathBuf::from("projects").join(id).join("sessions.db")), + ); + for store in &released_stores { + let rewritten = rusqlite::Connection::open(profile_root.join(store)) + .expect("open a registered store") + .execute( + "UPDATE session_schema_migrations SET version = 13 WHERE name = 'lcm'", + [], + ) + .expect("record the released LCM schema"); + assert_eq!(rewritten, 1, "{} records one LCM marker", store.display()); + } + + let mut daemon = spawn_tracedecay_daemon_with(&home_path, |_| {}); + let lcm_reset = |store: &str| { + format!( + "Store {store} requires reset (LCM profile schema 13 is incompatible with required \ + schema 14; reset the profile). Pending operator action: run `{STALE_STORE_RESET}`" + ) + }; + let (exit, mut pending) = doctor_store_resets(&home_path, first_project); + pending.sort_unstable(); + let mut expected = vec![ + lcm_reset("profile sessions"), + lcm_reset(&format!("project sessions {}", ids[0])), + lcm_reset(&format!("project sessions {}", ids[1])), + ]; + expected.sort_unstable(); + assert_eq!( + (exit, pending), + (Some(75), expected), + "one census names every session store and never the profile authority" + ); + let mut registered = registered_project_ids(&home_path, first_project); + registered.sort_unstable(); + assert_eq!(registered, ids, "the registry serves over refused sessions"); + + let mut before_reset = BTreeMap::new(); + let (reset_status, reset_output) = + super::run_scoped_reset(&home_path, first_project, &mut daemon, || { + before_reset = file_digests(&profile_root); + }); + assert!( + reset_status.success(), + "the scoped reset failed:\n{reset_output}" + ); + let after_reset = file_digests(&profile_root); + let removed_databases: Vec<&PathBuf> = before_reset + .keys() + .filter(|relative| !after_reset.contains_key(*relative)) + .filter(|relative| { + relative + .extension() + .is_some_and(|extension| extension == "db") + }) + .collect(); + let mut expected_removed = released_stores[1..].to_vec(); + expected_removed.sort(); + assert_eq!( + removed_databases, + expected_removed.iter().collect::>(), + "the scoped reset deletes exactly the session stores:\n{reset_output}" + ); + assert_eq!( + after_reset.get(Path::new("global.db")), + before_reset.get(Path::new("global.db")), + "the profile authority stays byte-identical:\n{reset_output}" + ); + + let mut daemon = spawn_tracedecay_daemon_with(&home_path, |_| {}); + let mut registered = registered_project_ids(&home_path, first_project); + registered.sort_unstable(); + assert_eq!( + registered, ids, + "both projects stay registered without another `tracedecay init`" + ); + assert_eq!( + doctor_store_resets(&home_path, first_project).1, + Vec::::new(), + "no store requires reset after one scoped reset" + ); + + let _ = daemon.kill_and_wait(); +} + /// A project sessions store admitted by this binary, by its census or its own /// attach, records that on its manifest, so a later census opens only stores /// whose stamp is missing or stale. diff --git a/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_sessions_store_reset.rs b/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_sessions_store_reset.rs index 9925445dc8..438cf78f88 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_sessions_store_reset.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/typed_terminal_restart_acceptance/stale_sessions_store_reset.rs @@ -6,7 +6,10 @@ //! and project session store. Stores are then given a shape a released binary //! left behind: observation rows written before the unified identity, an LCM //! schema version, a git correlation schema version, or a workflow schema -//! identity other than the one this binary writes. Over that profile the +//! identity other than the one this binary writes. A released binary marked +//! the profile authority (`global.db`) with the same versions, so the +//! version cases age it too; it holds no session rows and keeps serving its +//! registry. Over that profile the //! project must still open, the MCP host must initialize and list tools, code //! search and callers must answer, session reads against a refused store must //! return the typed `reset_required` refusal naming @@ -24,6 +27,7 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; +use super::stale_profile_authority_reset::registered_project_ids; use crate::common::{ TestChildProcess, canonical_existing_path, spawn_tracedecay_daemon_with, tracedecay_command_with_home, @@ -323,6 +327,9 @@ struct SessionStoreRefusal { age: fn(&Path), /// Also ages the profile session store, not only the project's. ages_profile_store: bool, + /// Also ages the profile authority (`global.db`), which a released binary + /// marked with the same session-feature schema versions. + ages_profile_authority: bool, authority: &'static str, found_version: Value, required_version: Value, @@ -372,6 +379,9 @@ fn refused_session_stores_serve_code_until_their_scoped_reset(refusal: &SessionS if refusal.ages_profile_store { (refusal.age)(&profile_root.join("user-sessions.db")); } + if refusal.ages_profile_authority { + (refusal.age)(&profile_root.join("global.db")); + } let mut daemon = spawn_tracedecay_daemon_with(&home_path, |_| {}); let (initialize, tools) = mcp_initialize_and_list_tools(&home_path, &project_path); @@ -433,6 +443,11 @@ fn refused_session_stores_serve_code_until_their_scoped_reset(refusal: &SessionS }) .collect(); wait_for_reset_required_stores(&home_path, &project_path, &expected_census); + assert_eq!( + registered_project_ids(&home_path, &project_path), + vec![project_id.clone()], + "the profile authority keeps serving its registry over refused session stores" + ); let project_open = find_key(&status(&home_path, &project_path), "project_open"); assert!( project_open @@ -625,6 +640,7 @@ fn stale_session_stores_refuse_sessions_only_until_their_scoped_reset() { refused_session_stores_serve_code_until_their_scoped_reset(&SessionStoreRefusal { age: seed_pre_unified_observation_rows, ages_profile_store: true, + ages_profile_authority: false, authority: "observations", found_version: Value::Null, required_version: Value::Null, @@ -644,6 +660,7 @@ fn session_stores_at_shipped_lcm_schema_13_refuse_sessions_only_until_their_scop ); }, ages_profile_store: true, + ages_profile_authority: true, authority: "LCM", found_version: json!(13), required_version: json!(14), @@ -663,6 +680,7 @@ fn project_session_store_at_another_git_correlation_version_refuses_sessions_onl ); }, ages_profile_store: false, + ages_profile_authority: true, authority: "git correlation", found_version: json!(5), required_version: json!(6), @@ -685,6 +703,7 @@ fn project_session_store_with_another_workflow_schema_identity_refuses_sessions_ ); }, ages_profile_store: false, + ages_profile_authority: true, authority: "workflow", found_version: Value::Null, required_version: Value::Null, diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 7a7f18914a..3a7367bb27 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -1133,6 +1133,10 @@ empty. Nothing is migrated or backed up. The stores it resets on their own: | project sessions `` | `~/.tracedecay/projects//sessions.db` family | that project's session history and stored configuration | | profile / project hook admissions | the Hook V2 admission ledgers | pending hook admissions | +The profile authority holds no session data, so a session-feature schema +change (LCM, git correlation, workflows) resets only the session stores and +never the registry. + A profile authority reset keeps every project store (code index, graph, sessions, memory) byte-identical. The registry it recreates is empty, so the reset prints one `tracedecay init ` command per project store whose root