From 347c83c6b52ebd62abeccb34e4dc2599eaf01611 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 01:49:12 +0000 Subject: [PATCH] refactor: derive duplicated contracts from their authorities Hand-maintained copies of the SQLite schema, response budget, and native staging page size were kept in sync by comments. Read them from the owning inventory or constant, and refuse hint specs whose credited tools are not exact tokens in the hint text. Co-authored-by: Zack Jackson --- .../src/hooks/tool_hints.rs | 123 ++++++++++++++---- .../src/tracedecay.rs | 6 +- crates/tracedecay-graph-db/Cargo.toml | 5 +- .../src/generation_runtime.rs | 4 +- crates/tracedecay-graph-db/src/lib.rs | 6 +- crates/tracedecay-graph-db/src/limits.rs | 2 +- .../tests/at_rest_snapshot.rs | 22 ++-- .../staging_footprint.rs | 22 ++-- .../tests/tiered_storage_rss.rs | 13 +- crates/tracedecay-lcm/src/types.rs | 5 +- .../src/handlers/analysis/circular.rs | 13 +- crates/tracedecay-mcp/src/server/protocol.rs | 2 +- .../src/db/migrations.rs | 5 +- .../src/db/migrations/final_shape.rs | 96 ++++++++++++++ crates/tracedecay/src/mcp/server/requests.rs | 111 ++-------------- crates/tracedecay/src/mcp/server/rmcp.rs | 14 +- .../mcp_handler_test/bounded_analysis_test.rs | 5 +- crates/tracedecay/tests/mcp_suite/support.rs | 2 +- 18 files changed, 261 insertions(+), 195 deletions(-) diff --git a/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs b/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs index 6a780a9eab..2fa35e280c 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs @@ -84,15 +84,84 @@ struct HintCategorySpec { skill: &'static str, message: &'static str, context: &'static str, - /// Machine-readable list of the tracedecay MCP tools this hint steers the - /// model toward, derived from the tools named in `context`. The - /// hint-outcome correlator (`super::hint_outcomes`) treats a hint as - /// "acted" when one of these tools fires in the session after the hint, - /// instead of re-parsing the prose. Keep in sync with `context`. + /// Tools a later call credits as acting on this hint. Each name must be an + /// exact token in `message` or `context`; [`HintCategorySpec::checked`] + /// refuses the spec otherwise, so a slash-joined shorthand such as + /// `tracedecay_context/search` cannot silently drop `tracedecay_search`. expected_tools: &'static [&'static str], nonblocking: bool, } +const fn is_tool_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +/// True when `needle` appears in `haystack` as a whole tool token, not as a +/// slash-joined shorthand or a prefix of a longer name. +const fn contains_tool_token(haystack: &str, needle: &str) -> bool { + let haystack = haystack.as_bytes(); + let needle = needle.as_bytes(); + if needle.is_empty() || haystack.len() < needle.len() { + return false; + } + let mut start = 0; + while start + needle.len() <= haystack.len() { + let mut offset = 0; + while offset < needle.len() && haystack[start + offset] == needle[offset] { + offset += 1; + } + if offset == needle.len() { + let preceded = start > 0 && is_tool_token_byte(haystack[start - 1]); + let followed = start + needle.len() < haystack.len() + && is_tool_token_byte(haystack[start + needle.len()]); + if !preceded && !followed { + return true; + } + } + start += 1; + } + false +} + +impl HintCategorySpec { + const fn names_every_expected_tool(&self) -> bool { + let mut index = 0; + while index < self.expected_tools.len() { + let tool = self.expected_tools[index]; + if !contains_tool_token(self.message, tool) && !contains_tool_token(self.context, tool) + { + return false; + } + index += 1; + } + true + } + + const fn checked(self) -> Self { + assert!( + self.names_every_expected_tool(), + "hint expected_tools must appear as exact tool tokens in message or context" + ); + self + } +} + +#[cfg(test)] +mod expected_tool_token_tests { + use super::contains_tool_token; + + #[test] + fn slash_joined_shorthand_does_not_name_the_second_tool() { + let prose = + "pass project_path to tracedecay_context/search before scanning parent directories"; + assert!(contains_tool_token(prose, "tracedecay_context")); + assert!( + !contains_tool_token(prose, "tracedecay_search"), + "a slash-joined shorthand must not count as naming tracedecay_search" + ); + } +} + const CATEGORY_SPECS: &[HintCategorySpec] = &[ HintCategorySpec { category: HintCategory::Search, @@ -108,7 +177,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_context", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::SemanticSearch, key: "semantic_search", @@ -118,7 +187,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_context answers concept-level queries from the pre-built code graph (add keywords to expand synonyms); tracedecay_search ranks symbols by name/keyword; tracedecay_grep matches a literal or regex string when you want exact text, not a concept.", expected_tools: &["tracedecay_context", "tracedecay_search", "tracedecay_grep"], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::FileRead, key: "file_read", @@ -133,7 +202,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_grep", ], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::ToolDescriptorRead, key: "tool_descriptor_read", @@ -147,7 +216,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_callees", ], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::BroadRead, key: "broad_read", @@ -157,7 +226,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_context gathers relevant code slices without reading entire directories or the whole repository; tracedecay_grep sweeps the indexed tree for a literal or regex string when you are hunting for exact text rather than a concept.", expected_tools: &["tracedecay_context", "tracedecay_grep"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::CallGraph, key: "call_graph", @@ -173,7 +242,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_impact", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::Impact, key: "impact", @@ -188,7 +257,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_test_map", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::SymbolLookup, key: "symbol_lookup", @@ -198,7 +267,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_context and tracedecay_node can locate definitions and nearby relationships from the code graph.", expected_tools: &["tracedecay_context", "tracedecay_node"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::FileLookup, key: "file_lookup", @@ -208,14 +277,14 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_files can list indexed files and narrow file lookup before opening individual files.", expected_tools: &["tracedecay_files"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::ProjectContext, key: "project_context", label: "project context", skill: "code-health", message: "For other repos or registered projects, consider TraceDecay project registry tools.", - context: "tracedecay_project_list shows known projects; tracedecay_project_search can find a sibling repo by name/path/remote; pass project_path or project_id to tracedecay_context/search for cross-project code context before scanning parent directories.", + context: "tracedecay_project_list shows known projects; tracedecay_project_search can find a sibling repo by name/path/remote; pass project_path or project_id to tracedecay_context or tracedecay_search for cross-project code context before scanning parent directories.", expected_tools: &[ "tracedecay_project_list", "tracedecay_project_search", @@ -223,7 +292,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_search", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::SessionRecall, key: "session_recall", @@ -233,14 +302,14 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_message_search searches ingested agent transcripts across providers; tracedecay_lcm_grep can search bounded raw-message snippets and summaries when you need session-level recall before re-discovering context.", expected_tools: &["tracedecay_message_search", "tracedecay_lcm_grep"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::AtomicEdit, key: "atomic_edit", label: "atomic edit", skill: "editing-safely", message: "For safe mechanical edits, use TraceDecay's anchored edit tools.", - context: "Use tracedecay_str_replace for one exact swap, tracedecay_multi_str_replace for an all-or-nothing batch, tracedecay_insert_at or tracedecay_insert_at_symbol for anchored insertion, tracedecay_replace_symbol for one resolved symbol, and tracedecay_ast_grep_rewrite for structural rewrites.", + context: "Use tracedecay_str_replace for one exact swap, tracedecay_multi_str_replace for an all-or-nothing batch, tracedecay_insert_at or tracedecay_insert_at_symbol for anchored insertion, tracedecay_replace_symbol for one resolved symbol, tracedecay_move_symbol to relocate one resolved symbol, and tracedecay_ast_grep_rewrite for structural rewrites.", expected_tools: &[ "tracedecay_str_replace", "tracedecay_multi_str_replace", @@ -251,7 +320,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_move_symbol", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::TypeOrientation, key: "type_orientation", @@ -267,7 +336,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_type_hierarchy", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::ExploreSubagent, key: "explore_subagent", @@ -282,7 +351,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_impact", ], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::SubagentStartContext, key: "subagent_start_context", @@ -296,7 +365,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_impact", ], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::BuildDiagnostics, key: "build_diagnostics", @@ -306,7 +375,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_diagnostics runs (or reads) the project's diagnostics and maps each error to its enclosing symbol; tracedecay_diagnose adds caller/impact context for a specific failure so you fix the root cause, not just the line the compiler points at.", expected_tools: &["tracedecay_diagnostics", "tracedecay_diagnose"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::ReviewChanges, key: "review_changes", @@ -316,7 +385,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_diff_context maps local changed files to touched symbols, dependents, and tests; tracedecay_pr_context does the same for a PR branch when available, so use GitHub only for review comments, metadata, and CI state.", expected_tools: &["tracedecay_diff_context", "tracedecay_pr_context"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::MemoryStore, key: "memory_store", @@ -326,7 +395,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_fact_store_add persists a trust-ranked project/user fact that survives across sessions and is recalled by tracedecay_context and tracedecay_recall; a memory markdown edit is only visible to the current harness. Keep secrets and unnecessary PII out of stored facts.", expected_tools: &["tracedecay_fact_store_add"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::EditRedundancy, key: "edit_redundancy", @@ -336,7 +405,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_search finds existing helpers by name, signature, or body text and tracedecay_similar finds similarly named symbols; if a match exists, reuse or refactor the existing helper instead of keeping a second copy.", expected_tools: &["tracedecay_search", "tracedecay_similar"], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::UnexpectedChanges, key: "unexpected_changes", @@ -351,7 +420,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_message_search", ], nonblocking: false, - }, + }.checked(), ]; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/tracedecay-dashboard-api/src/tracedecay.rs b/crates/tracedecay-dashboard-api/src/tracedecay.rs index ce5d59bd82..282efa79d9 100644 --- a/crates/tracedecay-dashboard-api/src/tracedecay.rs +++ b/crates/tracedecay-dashboard-api/src/tracedecay.rs @@ -24,9 +24,7 @@ pub struct DashboardProjectContext { } pub mod facts { - // The shared resolvers live in `tracedecay_session_memory::memory` — the crate - // that owns `MemoryApplication`/`MemoryApplicationError` — rather than a - // copy kept in sync by hand here. `tracedecay::facts::memory_application_for_db` - // remains the stable call-site path for this crate's ~20 dashboard routes. + // Resolvers live with `MemoryApplication`. This re-export is the stable + // call-site path for this crate's dashboard routes. pub use tracedecay_session_memory::memory::memory_application_for_db; } diff --git a/crates/tracedecay-graph-db/Cargo.toml b/crates/tracedecay-graph-db/Cargo.toml index a6c55f357a..8d56ec7dac 100644 --- a/crates/tracedecay-graph-db/Cargo.toml +++ b/crates/tracedecay-graph-db/Cargo.toml @@ -67,8 +67,9 @@ graph-disk-tier = ["grafeo-engine/spill"] # a flat address over the epoch's chunks, so an epoch grows to the 4 GiB a # u32 can reach and exhaustion is a typed error. # -# The fix holds -- `tests/tiered_storage_rss.rs` commits 65,536-row pages, -# twice the old ceiling, without panicking. It just does not pay off. The +# The fix holds -- `tests/tiered_storage_rss.rs` commits full native staging +# pages (`MAX_NATIVE_GENERATION_STAGE_MUTATIONS`), twice the old ~32 Ki +# ceiling, without panicking. It just does not pay off. The # arena costs resident memory rather than saving it, because nothing here is # ever written to disk: measured with that harness on the fork, live VmRSS # with the store open runs +22% at 100k rows, +32% at 500k, and +28% at 1M diff --git a/crates/tracedecay-graph-db/src/generation_runtime.rs b/crates/tracedecay-graph-db/src/generation_runtime.rs index df82f3ef63..9467c245c3 100644 --- a/crates/tracedecay-graph-db/src/generation_runtime.rs +++ b/crates/tracedecay-graph-db/src/generation_runtime.rs @@ -3705,7 +3705,7 @@ mod tests { SourceGeneration::new("source-wide-native-stage").unwrap(), GraphWatermark::new("watermark-wide-native-stage").unwrap(), vec![], - (0..65_536) + (0..MAX_NATIVE_GENERATION_STAGE_MUTATIONS) .map(|index| { GraphEntity::new( GraphEntityId::new(format!("entity:{index:05}")).unwrap(), @@ -3723,7 +3723,7 @@ mod tests { assert_eq!( pages.len(), 1, - "native generation staging must avoid a full-graph Grafeo commit scan every 4,096 rows" + "native generation staging must avoid a full-graph Grafeo commit scan every {MAX_VERIFIED_GENERATION_BATCH_MUTATIONS} rows" ); let temp = TempDir::new().unwrap(); diff --git a/crates/tracedecay-graph-db/src/lib.rs b/crates/tracedecay-graph-db/src/lib.rs index 6f81102df6..baf03eab9d 100644 --- a/crates/tracedecay-graph-db/src/lib.rs +++ b/crates/tracedecay-graph-db/src/lib.rs @@ -46,9 +46,9 @@ pub use lease::{VerifiedGraphSnapshot, VerifiedTraversalResult, VerifiedTraversa pub use limits::{ MAX_GRAPH_BATCH_CANONICAL_BYTES, MAX_GRAPH_ENTITY_LABEL_BYTES, MAX_GRAPH_ENTITY_LABELS, MAX_GRAPH_IDENTIFIER_BYTES, MAX_GRAPH_PROPERTIES, MAX_GRAPH_PROPERTY_AGGREGATE_BYTES, - MAX_GRAPH_PROPERTY_VALUE_BYTES, MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, - MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, MAX_VERIFIED_GENERATION_ENTITIES, - MAX_VERIFIED_GENERATION_RELATIONS, + MAX_GRAPH_PROPERTY_VALUE_BYTES, MAX_NATIVE_GENERATION_STAGE_MUTATIONS, + MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, + MAX_VERIFIED_GENERATION_ENTITIES, MAX_VERIFIED_GENERATION_RELATIONS, }; #[cfg(any(feature = "test-helpers", feature = "eval-helpers"))] pub use location::{GraphDbLocation, GraphDbOpenOptions, GraphDurability, GraphFormatVersion}; diff --git a/crates/tracedecay-graph-db/src/limits.rs b/crates/tracedecay-graph-db/src/limits.rs index 1072f01b0a..e09a5cdfb3 100644 --- a/crates/tracedecay-graph-db/src/limits.rs +++ b/crates/tracedecay-graph-db/src/limits.rs @@ -22,7 +22,7 @@ pub const MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES: usize = 32 * 1024 * 1024; /// quadratic in its page count. The daemon's resident-memory admission owns /// the full manifest; this additional page clone remains bounded independently /// and cancellation is observed before and after every durable page. -pub(crate) const MAX_NATIVE_GENERATION_STAGE_MUTATIONS: usize = 65_536; +pub const MAX_NATIVE_GENERATION_STAGE_MUTATIONS: usize = 65_536; pub(crate) const MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES: usize = 128 * 1024 * 1024; pub const MAX_GRAPH_IDENTIFIER_BYTES: usize = 1_024; pub const MAX_GRAPH_ENTITY_LABELS: usize = 128; diff --git a/crates/tracedecay-graph-db/tests/at_rest_snapshot.rs b/crates/tracedecay-graph-db/tests/at_rest_snapshot.rs index 7ec041bf63..72833441dc 100644 --- a/crates/tracedecay-graph-db/tests/at_rest_snapshot.rs +++ b/crates/tracedecay-graph-db/tests/at_rest_snapshot.rs @@ -63,17 +63,13 @@ use tracedecay_graph_db::{ GraphCancellation, GraphDbLeaseV1, GraphEntity, GraphEntityId, GraphLabel, GraphMutation, GraphNamespace, GraphProjectionId, GraphProperty, GraphPropertyName, GraphRelation, GraphRelationId, GraphRelationKind, GraphTraversalDirection, GraphWatermark, GraphWriteBatch, - NeverCancelled, SourceGeneration, TraversalRequest, + MAX_NATIVE_GENERATION_STAGE_MUTATIONS, NeverCancelled, SourceGeneration, TraversalRequest, }; mod support; use support::RegisteredGraph; -/// Mirrors the crate-private `limits::MAX_NATIVE_GENERATION_STAGE_MUTATIONS`, -/// the page size the native generation runtime actually flushes at. -const PRODUCTION_STAGE_PAGE: usize = 65_536; - /// Default row count when `TRACEDECAY_ATREST_ROWS` is unset. const DEFAULT_ROWS: usize = 500_000; @@ -195,8 +191,11 @@ impl Mode { /// Stages `rows` entities and `rows / RELATION_DIVISOR` relations in /// production-sized pages. fn stage(db: &GraphDbLeaseV1, rows: usize) { - for (page, start) in (0..rows).step_by(PRODUCTION_STAGE_PAGE).enumerate() { - let end = (start + PRODUCTION_STAGE_PAGE).min(rows); + for (page, start) in (0..rows) + .step_by(MAX_NATIVE_GENERATION_STAGE_MUTATIONS) + .enumerate() + { + let end = (start + MAX_NATIVE_GENERATION_STAGE_MUTATIONS).min(rows); let mutations = (start..end) .map(|index| GraphMutation::UpsertEntity(entity(index))) .collect(); @@ -205,8 +204,11 @@ fn stage(db: &GraphDbLeaseV1, rows: usize) { } let relations = rows / RELATION_DIVISOR; - for (page, start) in (0..relations).step_by(PRODUCTION_STAGE_PAGE).enumerate() { - let end = (start + PRODUCTION_STAGE_PAGE).min(relations); + for (page, start) in (0..relations) + .step_by(MAX_NATIVE_GENERATION_STAGE_MUTATIONS) + .enumerate() + { + let end = (start + MAX_NATIVE_GENERATION_STAGE_MUTATIONS).min(relations); let mutations = (start..end) .map(|index| GraphMutation::UpsertRelation(relation(index))) .collect(); @@ -361,7 +363,7 @@ fn at_rest_reopen_probe() { println!("=== at-rest reopen probe ==="); println!("mode : {}", mode.label()); println!( - "rows : {rows} entities + {relations} relations, pages of {PRODUCTION_STAGE_PAGE}" + "rows : {rows} entities + {relations} relations, pages of {MAX_NATIVE_GENERATION_STAGE_MUTATIONS}" ); println!("--- staging ---"); println!("stage wall : {:.2}s", stage_wall.as_secs_f64()); diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs index 66a5b073ce..f2ec241b09 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs @@ -7,7 +7,9 @@ use std::path::{Path, PathBuf}; use std::sync::mpsc; use tracedecay_graph_db::{ - GraphGenerationManifestProvider, GraphTraversalDirection, TraversalRequest, + GraphGenerationManifestProvider, GraphTraversalDirection, + MAX_NATIVE_GENERATION_STAGE_MUTATIONS, MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, + TraversalRequest, }; use super::*; @@ -1649,15 +1651,6 @@ fn a_cancelled_release_never_leaves_a_row_set_recovery_cannot_serve() { eprintln!("cancelled_release_partial_row_sets_observed={observed_partial}"); } -/// Mirrors the crate-private `limits::MAX_NATIVE_GENERATION_STAGE_MUTATIONS`, -/// the mutation ceiling one native staging page flushes at. Kept in sync by -/// hand; the geometry assertion below fails loudly if it drifts. -const NATIVE_STAGE_PAGE_MUTATIONS: usize = 65_536; - -/// Mirrors `limits::MAX_VERIFIED_GENERATION_BATCH_MUTATIONS`, the rows one -/// durable release transaction removes. -const RELEASE_PAGE_MUTATIONS: usize = 4_096; - /// A manifest in the two-page staging geometry an interrupted release can /// misrepresent: one full native page plus a second page wider than a single /// release transaction. Carries the same readable evidence entities and @@ -1732,7 +1725,8 @@ fn two_page_manifest( /// leaves the partial row set in the container. #[test] fn a_partially_released_generation_is_repaired_from_its_manifest_and_swept() { - let entity_count = NATIVE_STAGE_PAGE_MUTATIONS + 2 * RELEASE_PAGE_MUTATIONS; + let entity_count = + MAX_NATIVE_GENERATION_STAGE_MUTATIONS + 2 * MAX_VERIFIED_GENERATION_BATCH_MUTATIONS; let temp = TempDir::new().unwrap(); let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); let mut authority = RelationalAuthority::default(); @@ -1768,7 +1762,7 @@ fn a_partially_released_generation_is_repaired_from_its_manifest_and_swept() { // bounded page. // // The budget is measured in cancellation *polls*, and a release page polls - // once per row it enumerates before it commits `RELEASE_PAGE_MUTATIONS` of + // once per row it enumerates before it commits `MAX_VERIFIED_GENERATION_BATCH_MUTATIONS` of // them -- so the first durable page costs on the order of the whole row // count in polls, not the few dozen a short linear walk reaches. Start at // that scale and double: the first budget that commits cannot reach a @@ -1801,9 +1795,9 @@ fn a_partially_released_generation_is_repaired_from_its_manifest_and_swept() { let counts = partial.expect("an interrupted release must commit at least one durable entity page"); assert!( - counts.0 >= NATIVE_STAGE_PAGE_MUTATIONS && counts.0 < full.0, + counts.0 >= MAX_NATIVE_GENERATION_STAGE_MUTATIONS && counts.0 < full.0, "this fixture must reach the state the count heuristic misreads: \ - {counts:?} of {full:?}, first stage page ends at {NATIVE_STAGE_PAGE_MUTATIONS}" + {counts:?} of {full:?}, first stage page ends at {MAX_NATIVE_GENERATION_STAGE_MUTATIONS}" ); assert!(registered.close().unwrap()); diff --git a/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs b/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs index 4aba12cfb6..38356b3ae8 100644 --- a/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs +++ b/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs @@ -57,7 +57,7 @@ //! records) and panicked. The fork this workspace patches in spans an epoch //! arena across chunks, so the page commits instead of aborting. //! -//! [`MAX_NATIVE_GENERATION_STAGE_MUTATIONS`]: (crate-private; mirrored below) +//! [`MAX_NATIVE_GENERATION_STAGE_MUTATIONS`]: tracedecay_graph_db::MAX_NATIVE_GENERATION_STAGE_MUTATIONS use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -66,7 +66,8 @@ use std::time::Instant; use tempfile::TempDir; use tracedecay_graph_db::{ GraphCancellation, GraphEntity, GraphEntityId, GraphMutation, GraphNamespace, - GraphProjectionId, GraphWatermark, GraphWriteBatch, NeverCancelled, SourceGeneration, + GraphProjectionId, GraphWatermark, GraphWriteBatch, MAX_NATIVE_GENERATION_STAGE_MUTATIONS, + NeverCancelled, SourceGeneration, }; mod support; @@ -80,12 +81,6 @@ const ENTITY_COUNT: usize = 100_000; /// production staging page so the two probes bracket the arena behaviour. const SMALL_BATCH_SIZE: usize = 5_000; -/// Mirrors the crate-private `limits::MAX_NATIVE_GENERATION_STAGE_MUTATIONS`. -/// The native generation runtime flushes a staging page once it reaches this -/// many mutations, so this is the largest single-epoch commit production ever -/// performs. Kept in sync by hand; it is a measurement input, not a contract. -const PRODUCTION_STAGE_PAGE: usize = 65_536; - /// Total entities in the production-shaped probe. Override with /// `TRACEDECAY_RSS_ENTITIES` to scale the sweep without a rebuild. const PRODUCTION_ENTITY_COUNT: usize = 500_000; @@ -266,6 +261,6 @@ fn production_page_generation_rss() { measure( "tiered storage peak RSS probe (production staging page)", entity_count, - PRODUCTION_STAGE_PAGE, + MAX_NATIVE_GENERATION_STAGE_MUTATIONS, ); } diff --git a/crates/tracedecay-lcm/src/types.rs b/crates/tracedecay-lcm/src/types.rs index 901e81c5de..436b10c6a1 100644 --- a/crates/tracedecay-lcm/src/types.rs +++ b/crates/tracedecay-lcm/src/types.rs @@ -367,15 +367,12 @@ pub struct LcmSummaryConvergenceStatus { } /// Default fresh-tail size applied when the host omits `fresh_tail_count`. -/// Mirrors `compression.rs` `DEFAULT_FRESH_TAIL_COUNT`; keep in sync. pub const LCM_DEFAULT_FRESH_TAIL_COUNT: usize = 2; /// Default condensation fan-in applied when the host omits `summary_fan_in`. -/// Mirrors `compression.rs` `DEFAULT_SUMMARY_FAN_IN`; keep in sync. pub const LCM_DEFAULT_SUMMARY_FAN_IN: usize = 4; -/// Compression-boundary skip cooldown in seconds. Mirrors `compression.rs` -/// `COMPRESSION_BOUNDARY_COOLDOWN_SECONDS`; keep in sync. +/// Compression-boundary skip cooldown in seconds. pub const LCM_COMPRESSION_BOUNDARY_COOLDOWN_SECONDS: i64 = 60; /// Raw-store size diagnostics mirroring the hermes-lcm `lcm_status` `store` diff --git a/crates/tracedecay-mcp/src/handlers/analysis/circular.rs b/crates/tracedecay-mcp/src/handlers/analysis/circular.rs index eeedebffed..7a58686a94 100644 --- a/crates/tracedecay-mcp/src/handlers/analysis/circular.rs +++ b/crates/tracedecay-mcp/src/handlers/analysis/circular.rs @@ -178,10 +178,7 @@ fn render_circular_md( #[cfg(test)] mod circular_render_tests { use super::{CIRCULAR_DEFAULT_MEMBER_LIMIT, bound_cycles, circular_output, render_circular_md}; - - /// Mirrors [`tracedecay_mcp_catalog::MAX_RESPONSE_CHARS`], the point at which a - /// response is replaced by a preview envelope plus a retrieval handle. - const RESPONSE_BUDGET: usize = 15_000; + use crate::MAX_RESPONSE_CHARS; fn cycle(files: &[&str]) -> Vec { files.iter().map(|file| (*file).to_string()).collect() @@ -235,15 +232,15 @@ mod circular_render_tests { let payload = circular_output(&page, 1, omitted, 3, CIRCULAR_DEFAULT_MEMBER_LIMIT); let serialized = serde_json::to_string_pretty(&payload).expect("payload serializes"); assert!( - serialized.len() <= RESPONSE_BUDGET, - "bounded payload is {} chars, over the {RESPONSE_BUDGET} budget", + serialized.len() <= MAX_RESPONSE_CHARS, + "bounded payload is {} chars, over the {MAX_RESPONSE_CHARS} budget", serialized.len() ); let markdown = render_circular_md(&page, 1, omitted, 3); assert!( - markdown.len() <= RESPONSE_BUDGET, - "bounded markdown is {} chars, over the {RESPONSE_BUDGET} budget", + markdown.len() <= MAX_RESPONSE_CHARS, + "bounded markdown is {} chars, over the {MAX_RESPONSE_CHARS} budget", markdown.len() ); assert!( diff --git a/crates/tracedecay-mcp/src/server/protocol.rs b/crates/tracedecay-mcp/src/server/protocol.rs index 0844a04ebf..8e4382f555 100644 --- a/crates/tracedecay-mcp/src/server/protocol.rs +++ b/crates/tracedecay-mcp/src/server/protocol.rs @@ -87,7 +87,7 @@ pub fn resources_list_result() -> Value { { "uri": "tracedecay://schema", "name": "SQLite Schema", - "description": "Documentation for the .tracedecay/tracedecay.db schema: tables, columns, indexes, and common query recipes. Use when MCP tools don't cover your query and you need to drop down to raw SQL.", + "description": "SQLite tables, indexes, triggers, and views this binary creates, rendered from the migration inventory.", "mimeType": "text/markdown" } ] diff --git a/crates/tracedecay-runtime-core/src/db/migrations.rs b/crates/tracedecay-runtime-core/src/db/migrations.rs index 779457133c..8982a31fd2 100644 --- a/crates/tracedecay-runtime-core/src/db/migrations.rs +++ b/crates/tracedecay-runtime-core/src/db/migrations.rs @@ -15,7 +15,10 @@ use tracedecay_rusqlite_runtime::runtime_ledger; mod final_shape; mod released_shape; -pub use final_shape::{expected_final_schema_fingerprint, fingerprint_schema_objects}; +pub use final_shape::{ + expected_final_schema_fingerprint, fingerprint_schema_objects, + render_expected_final_schema_markdown, +}; const ROOT_SCHEMA: &str = "CREATE TABLE IF NOT EXISTS metadata ( key TEXT PRIMARY KEY, diff --git a/crates/tracedecay-runtime-core/src/db/migrations/final_shape.rs b/crates/tracedecay-runtime-core/src/db/migrations/final_shape.rs index 5f28211419..989de7e9c0 100644 --- a/crates/tracedecay-runtime-core/src/db/migrations/final_shape.rs +++ b/crates/tracedecay-runtime-core/src/db/migrations/final_shape.rs @@ -1,6 +1,7 @@ //! Query-only admission for the exact relational shape created by this binary. use std::collections::BTreeMap; +use std::fmt::Write as _; use std::sync::LazyLock; use crate::db::engine::QueryExecutor; @@ -149,6 +150,66 @@ where canonical_framed_sha256(SCHEMA_SHAPE_FINGERPRINT_DOMAIN, &parts) } +/// Markdown listing of the canonical final `SQLite` shape this binary creates. +/// +/// The body is the `sqlite_master` inventory admission already builds from the +/// migration DDL. Callers that need the schema read this instead of keeping a +/// second document. +pub fn render_expected_final_schema_markdown() -> Result { + let inventory = EXPECTED_FINAL_SHAPE + .as_ref() + .map_err(|error| database_error(error.clone()))?; + Ok(render_schema_markdown(inventory)) +} + +fn render_schema_markdown(inventory: &SchemaInventory) -> String { + let mut tables = Vec::new(); + let mut indexes = Vec::new(); + let mut triggers = Vec::new(); + let mut views = Vec::new(); + for (name, object) in inventory { + match object.object_type.as_str() { + "table" => tables.push((name.as_str(), object)), + "index" => indexes.push((name.as_str(), object)), + "trigger" => triggers.push((name.as_str(), object)), + "view" => views.push((name.as_str(), object)), + _ => {} + } + } + let mut out = String::new(); + out.push_str("# tracedecay SQLite schema\n\n"); + out.push_str( + "Relational shape this binary creates and admits. Code topology lives in the verified graph generation, not in these tables.\n\n", + ); + let _ = writeln!(out, "schema_version: {}\n", super::SCHEMA_VERSION); + write_schema_section(&mut out, "Tables", &tables); + write_schema_section(&mut out, "Indexes", &indexes); + write_schema_section(&mut out, "Triggers", &triggers); + write_schema_section(&mut out, "Views", &views); + out +} + +fn write_schema_section(out: &mut String, title: &str, objects: &[(&str, &SchemaObject)]) { + let _ = writeln!(out, "## {title}\n"); + if objects.is_empty() { + out.push_str("None.\n\n"); + return; + } + for (name, object) in objects { + let _ = writeln!(out, "### {title} `{name}`\n"); + if object.object_type != "table" && !object.table.is_empty() { + let _ = writeln!(out, "table: `{}`\n", object.table); + } + let sql = object.sql.trim(); + if sql.is_empty() { + out.push_str("no stored SQL\n\n"); + continue; + } + let fence = if sql.contains("```") { "~~~~" } else { "```" }; + let _ = writeln!(out, "{fence}sql\n{sql}\n{fence}\n"); + } +} + /// The DDL this binary creates for one schema object, or `None` when the /// object is not part of the final shape. /// @@ -412,6 +473,41 @@ fn require_final_shape_inventory( Ok(shipped_trigger_found) } +#[cfg(test)] +mod render_tests { + use std::collections::BTreeSet; + + use super::{EXPECTED_FINAL_SHAPE, render_expected_final_schema_markdown}; + + #[test] + fn rendered_schema_markdown_is_the_admission_inventory() { + let inventory = EXPECTED_FINAL_SHAPE + .as_ref() + .expect("canonical schema inventory"); + let markdown = render_expected_final_schema_markdown().expect("schema markdown"); + let headings: BTreeSet<&str> = markdown + .lines() + .filter_map(|line| { + let rest = line.strip_prefix("### Tables `")?; + rest.strip_suffix('`') + }) + .collect(); + let tables: BTreeSet<&str> = inventory + .iter() + .filter(|(_, object)| object.object_type == "table") + .map(|(name, _)| name.as_str()) + .collect(); + assert_eq!( + headings, tables, + "schema markdown table headings must be exactly the admission inventory" + ); + assert!( + markdown.contains("schema_version: "), + "schema markdown must name the stamp the migrator writes" + ); + } +} + #[cfg(test)] mod fingerprint_tests { use super::fingerprint_schema_objects; diff --git a/crates/tracedecay/src/mcp/server/requests.rs b/crates/tracedecay/src/mcp/server/requests.rs index 8b3ca94d09..6df6d34333 100644 --- a/crates/tracedecay/src/mcp/server/requests.rs +++ b/crates/tracedecay/src/mcp/server/requests.rs @@ -13,6 +13,7 @@ use tracedecay_mcp::{ ToolResult, mark_semantic_tool_error, semantic_failure_reason, server::resources_list_result, tool_error_response, tool_result_has_semantic_error, }; +use tracedecay_runtime_core::db::migrations::render_expected_final_schema_markdown; use tracedecay_tool_catalog::ApplicationSurfaceOperation; /// Prefix of the out-of-band token-accounting block appended after a tool's @@ -241,106 +242,6 @@ impl McpServer { } } -/// Hand-maintained schema documentation for the `tracedecay://schema` resource. -/// Mirrors `src/db/migrations.rs::create_schema`. Update both together. -const SCHEMA_MARKDOWN: &str = r"# tracedecay SQLite schema - -The active project database lives in the user-level TraceDecay profile store -(`~/.tracedecay/projects//tracedecay.db` by default), scoped to the -current project. Linked worktrees share this durable project store. All tables -are plain SQLite; safe to query with any client. WAL mode is used, so readers -do not block writers. - -## Tables - -### `nodes` — every indexed symbol -- `id` TEXT PRIMARY KEY — content-hashed identifier (changes when symbol moves or renames) -- `kind` TEXT — e.g. `function`, `struct`, `trait`, `impl`, `method`, `module`, `file` -- `name` TEXT — local identifier -- `qualified_name` TEXT — language-style path (e.g. `crate::module::Type::method`) -- `file_path` TEXT — relative to the project root -- `start_line`, `end_line` INTEGER — 1-based inclusive line range of the symbol -- `start_column`, `end_column` INTEGER — 0-based column range -- `attrs_start_line` INTEGER — first line of leading doc-comments / attributes (or `start_line` if none) -- `signature` TEXT NULL — extracted source-level signature -- `docstring` TEXT NULL — leading doc-comment -- `visibility` TEXT — one of `public`, `pub_crate`, `pub_super`, `private` -- `is_async` INTEGER (0/1) -- `branches`, `loops`, `returns`, `max_nesting`, `unsafe_blocks`, `unchecked_calls`, `assertions` INTEGER — complexity metrics -- `updated_at` INTEGER — UNIX epoch seconds - -Indexes: `kind`, `name`, `qualified_name`, `file_path`, `(file_path,start_line)`, `lower(name)`. - -### `edges` — directed relationships between nodes -- `id` INTEGER PRIMARY KEY AUTOINCREMENT -- `source` TEXT — FK → `nodes.id` (CASCADE DELETE) -- `target` TEXT — FK → `nodes.id` (CASCADE DELETE) -- `kind` TEXT — one of `contains`, `calls`, `returns`, `type_of`, `uses`, `implements`, `extends`, `annotates`, `derives_macro`, `receives` -- `line` INTEGER NULL — source line of the relationship - -Unique constraint: `(source, target, kind, COALESCE(line, -1))`. Indexes on `source`, `target`, `kind`, `(source,kind)`, `(target,kind)`. - -### `files` — index bookkeeping -- `path` TEXT PRIMARY KEY -- `content_hash` TEXT — sha256 of file contents at index time -- `size` INTEGER — file size in bytes -- `modified_at`, `indexed_at` INTEGER — UNIX epoch seconds -- `node_count` INTEGER — number of nodes extracted from this file - -### `unresolved_refs` — references the resolver could not bind -- `from_node_id` FK → `nodes.id` -- `reference_name` TEXT -- `reference_kind` TEXT -- `line`, `col` INTEGER -- `file_path` TEXT - -### `metadata` — key/value store -Common keys: `tokens_saved`, schema-version markers. - -### `read_cache` — rendered `tracedecay_read` responses -- primary key: `(project_id, session_id, file_path, mode, args_hash)` -- stores `mtime_ns`, `digest`, rendered `body` BLOB, token count, and `created_at` - -## Recipes - -### Find every impl block of a trait -```sql -SELECT n.id, n.qualified_name, n.file_path, n.start_line -FROM nodes n -JOIN edges e ON e.source = n.id -WHERE e.kind = 'implements' - AND e.target IN (SELECT id FROM nodes WHERE qualified_name = ?1); -``` - -### Top callers of a node -```sql -SELECT n.qualified_name, COUNT(*) AS call_count -FROM edges e -JOIN nodes n ON n.id = e.source -WHERE e.target = ?1 AND e.kind = 'calls' -GROUP BY n.qualified_name -ORDER BY call_count DESC -LIMIT 20; -``` - -### Files modified since last index -Compare `files.modified_at` against the live filesystem mtime — `tracedecay_affected` does this with extra git plumbing. - -### Largest functions by line span -```sql -SELECT qualified_name, file_path, end_line - start_line + 1 AS lines -FROM nodes -WHERE kind IN ('function', 'method') -ORDER BY lines DESC -LIMIT 20; -``` - -## Gotchas -- `nodes.id` is a content hash, so it changes when the symbol moves. For cross-run lookups use `qualified_name` (or `tracedecay_by_qualified_name`). -- `edges.kind = 'calls'` may reference a *trait method* node rather than the resolved concrete impl — trait dispatch is not currently rewritten. -- `derives_macro` edges record `#[derive(...)]` usage but generated impls are not in the graph. -"; - impl McpServer { pub(crate) fn cancel_application_surface_request( &self, @@ -824,10 +725,14 @@ impl McpServer { ) } - /// Returns the `SQLite` schema documentation as a markdown resource. - /// Sourced from `src/db/migrations.rs::create_schema` — keep in sync. + /// Returns the `SQLite` shape this binary creates, rendered from the migration inventory. pub(crate) fn read_resource_schema(id: Value) -> JsonRpcResponse { - Self::resource_contents(id, "tracedecay://schema", "text/markdown", SCHEMA_MARKDOWN) + match render_expected_final_schema_markdown() { + Ok(markdown) => { + Self::resource_contents(id, "tracedecay://schema", "text/markdown", &markdown) + } + Err(error) => JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()), + } } /// Returns typed file-inventory availability for the active project. diff --git a/crates/tracedecay/src/mcp/server/rmcp.rs b/crates/tracedecay/src/mcp/server/rmcp.rs index 475dd7e002..bc75bc6b0d 100644 --- a/crates/tracedecay/src/mcp/server/rmcp.rs +++ b/crates/tracedecay/src/mcp/server/rmcp.rs @@ -496,7 +496,8 @@ mod tests { large_envelope["original_chars"] .as_u64() .unwrap_or_default() - >= 15_000, + >= u64::try_from(tracedecay_mcp::MAX_RESPONSE_CHARS) + .expect("response budget fits u64"), "large response must cross the production response budget", ); assert!( @@ -581,6 +582,17 @@ mod tests { ) && schema.ends_with(r#","uri":"tracedecay://schema"}]}}"#), "legacy resources/read frame shape changed: {schema}", ); + let parsed: Value = serde_json::from_str(&schema).expect("schema frame"); + let text = parsed["result"]["contents"][0]["text"] + .as_str() + .expect("schema text"); + let authority = + tracedecay_runtime_core::db::migrations::render_expected_final_schema_markdown() + .expect("canonical schema"); + assert_eq!( + text, authority, + "schema resource must be the migration inventory, not a second document" + ); // Notifications stay responseless on the legacy transport. for notification in [ diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/bounded_analysis_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/bounded_analysis_test.rs index d4012e71ca..46fb58895a 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/bounded_analysis_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/bounded_analysis_test.rs @@ -14,10 +14,7 @@ use crate::support::*; use serde_json::{Value, json}; use std::fmt::Write as _; use std::fs; - -/// Mirrors `crate::mcp::tools::MAX_RESPONSE_CHARS`, the point at which a tool -/// response is replaced by a preview envelope plus a retrieval handle. -const MAX_RESPONSE_CHARS: usize = 15_000; +use tracedecay_mcp::MAX_RESPONSE_CHARS; fn assert_not_truncated(text: &str, tool: &str) { let payload: Value = serde_json::from_str(text) diff --git a/crates/tracedecay/tests/mcp_suite/support.rs b/crates/tracedecay/tests/mcp_suite/support.rs index 6d3e43dfa4..b5a9382279 100644 --- a/crates/tracedecay/tests/mcp_suite/support.rs +++ b/crates/tracedecay/tests/mcp_suite/support.rs @@ -68,7 +68,7 @@ use tracedecay_temporal_query::ports::ExecutionControl; pub(crate) static GLOBAL_DB_ENV_LOCK: Mutex<()> = Mutex::const_new(()); #[cfg(feature = "test-transport")] -pub(crate) const MCP_TEST_RESPONSE_CHAR_LIMIT: usize = 15_000; +pub(crate) const MCP_TEST_RESPONSE_CHAR_LIMIT: usize = tracedecay_mcp::MAX_RESPONSE_CHARS; #[cfg(feature = "test-transport")] static NEXT_SOURCE_EDIT_TEST_KEY: AtomicU64 = AtomicU64::new(1);