From 4ac4eec9f068c4302993501b4e55214b1658e93a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 01:27:53 +0000 Subject: [PATCH 1/3] refactor(retrieval): drop similar cutover wire aliases The family schema is the only current similar/redundancy contract. Retired shapes and field aliases were cutover compatibility, not a delegating released facade. Co-authored-by: Zack Jackson --- .../src/retrieval/catalog.rs | 54 ++------- .../tracedecay-contracts/src/retrieval/mod.rs | 28 +++-- .../src/retrieval/primitive_surface.rs | 110 ++++++++---------- .../contracts_suite/catalog_contributions.rs | 10 +- .../src/handlers/graph/search.rs | 33 +----- sdks/typescript/src/operations.ts | 4 +- 6 files changed, 82 insertions(+), 157 deletions(-) diff --git a/crates/tracedecay-contracts/src/retrieval/catalog.rs b/crates/tracedecay-contracts/src/retrieval/catalog.rs index 3d5e5e5884..8dd5961dc4 100644 --- a/crates/tracedecay-contracts/src/retrieval/catalog.rs +++ b/crates/tracedecay-contracts/src/retrieval/catalog.rs @@ -202,40 +202,6 @@ fn primitive_read_surfaces(spec: &PrimitiveReadSpec) -> &'static [BindingSurface } } -/// Similar/redundancy cut over to the family wire schema. Protocol revisions -/// share one (surface, operation) key; `ProtocolRevisionRange` carries the -/// accepted revisions. Do not mint a second binding for the same spelling; -/// `index_bindings` rejects duplicate surface-operation keys. -fn clone_family_surface_bindings( - capability_id: &CapabilityId, - operation: &str, - surfaces: &[BindingSurface], -) -> Result<(Vec, Vec), ApplicationContractError> { - use crate::surface_binding::surface_name; - - let mut bindings = Vec::with_capacity(surfaces.len()); - let mut binding_ids = Vec::with_capacity(surfaces.len()); - for surface in surfaces.iter().copied() { - let binding_id = BindingId::new(format!( - "binding.{}.{}.v1", - surface_name(surface), - operation - ))?; - bindings.push(SurfaceBindingV1::new(SurfaceBindingInputV1 { - binding_id: binding_id.clone(), - capability_id: capability_id.clone(), - surface, - operation: SurfaceOperationName::new(operation)?, - protocol_revisions: ProtocolRevisionRange::new(1, 2)?, - required_features: Vec::new(), - status: BindingStatus::Current, - alias_of: None, - })?); - binding_ids.push(binding_id); - } - Ok((bindings, binding_ids)) -} - fn primitive_read_description(operation: &str) -> &'static str { match operation { "code_signature_search" => { @@ -380,19 +346,13 @@ pub fn primitive_read_contribution() -> Result current_application_bindings( - &capability_id, - operation, - surfaces.iter().copied(), - )?, - None => { - current_bindings(&capability_id, spec.operation, surfaces.iter().copied())? - } - } + match ApplicationSurfaceOperation::from_catalog_name(spec.operation) { + Some(operation) => current_application_bindings( + &capability_id, + operation, + surfaces.iter().copied(), + )?, + None => current_bindings(&capability_id, spec.operation, surfaces.iter().copied())?, }; bindings.extend(surface_bindings); binding_ids.reserve(primitive_lsp_methods(spec.operation).len()); diff --git a/crates/tracedecay-contracts/src/retrieval/mod.rs b/crates/tracedecay-contracts/src/retrieval/mod.rs index d1c598fd45..d2f663eab1 100644 --- a/crates/tracedecay-contracts/src/retrieval/mod.rs +++ b/crates/tracedecay-contracts/src/retrieval/mod.rs @@ -96,23 +96,21 @@ pub use ports::{ pub use primitive_surface::{ CalleeV1, CalleesResultV1, CalleesSurfaceRequestV1, ContextCodeBlockV1, ContextModeV1, ContextResultV1, ContextSearchMatchV1, ContextSurfaceRequestV1, ImpactNodeV1, ImpactResultV1, - ImpactSurfaceRequestV1, LegacyRedundancySurfaceRequestV1, LegacySimilarSurfaceRequestV1, - MAX_REDUNDANCY_FAMILIES_V1, MAX_REDUNDANCY_PULL_REQUEST_PATHS_V1, MAX_REDUNDANCY_WORK_V1, - NodeDepthSurfaceRequestV1, NodeDetailsV1, NodeExpansionCostV1, NodeResultV1, - NodeSurfaceRequestV1, PortCycleAnchorV1, PortCycleFileV1, PortCycleSymbolV1, PortCycleV1, - PortMatchedSymbolV1, PortOrderLevelV1, PortOrderResultV1, PortOrderSurfaceRequestV1, - PortOrderSymbolV1, PortStatusResultV1, PortStatusSurfaceRequestV1, PortTargetOnlySymbolV1, - PortUnmatchedSymbolV1, PrimitiveFreshnessStateV1, PrimitiveIndexingStateV1, - PrimitiveLaneCompleteV1, PrimitiveLaneStateV1, PrimitiveLaneStatusV1, PrimitiveNotFoundV1, - PrimitiveRecallV1, PrimitiveSearchCoverageV1, PrimitiveSearchFreshnessV1, + ImpactSurfaceRequestV1, MAX_REDUNDANCY_FAMILIES_V1, MAX_REDUNDANCY_PULL_REQUEST_PATHS_V1, + MAX_REDUNDANCY_WORK_V1, NodeDepthSurfaceRequestV1, NodeDetailsV1, NodeExpansionCostV1, + NodeResultV1, NodeSurfaceRequestV1, PortCycleAnchorV1, PortCycleFileV1, PortCycleSymbolV1, + PortCycleV1, PortMatchedSymbolV1, PortOrderLevelV1, PortOrderResultV1, + PortOrderSurfaceRequestV1, PortOrderSymbolV1, PortStatusResultV1, PortStatusSurfaceRequestV1, + PortTargetOnlySymbolV1, PortUnmatchedSymbolV1, PrimitiveFreshnessStateV1, + PrimitiveIndexingStateV1, PrimitiveLaneCompleteV1, PrimitiveLaneStateV1, PrimitiveLaneStatusV1, + PrimitiveNotFoundV1, PrimitiveRecallV1, PrimitiveSearchCoverageV1, PrimitiveSearchFreshnessV1, PrimitiveSymbolLocationV1, PrimitiveUnavailableEvidenceV1, PrimitiveUnavailableStatusV1, RedundancyCoverageV1, RedundancyFamilyV1, RedundancyPartialReasonV1, RedundancyRankingV1, - RedundancyResultV1, RedundancyScopeV1, RedundancySurfaceRequestV1, - RedundancySurfaceRequestWireV1, RenamePreviewNodeV1, RenamePreviewPrimitiveOutcomeV1, - RenamePreviewPrimitiveRequestV1, RenamePreviewPrimitiveResultV1, RenamePreviewReferenceV1, - RenamePreviewTextOnlyMatchV1, SimilarCoverageV1, SimilarFamilyV1, SimilarMatchClassV1, - SimilarOccurrenceV1, SimilarResultV1, SimilarSurfaceRequestV1, SimilarSurfaceRequestWireV1, - SimilarTargetV1, TodoMarkerV1, TodosResultV1, TodosSurfaceRequestV1, + RedundancyResultV1, RedundancyScopeV1, RedundancySurfaceRequestV1, RenamePreviewNodeV1, + RenamePreviewPrimitiveOutcomeV1, RenamePreviewPrimitiveRequestV1, + RenamePreviewPrimitiveResultV1, RenamePreviewReferenceV1, RenamePreviewTextOnlyMatchV1, + SimilarCoverageV1, SimilarFamilyV1, SimilarMatchClassV1, SimilarOccurrenceV1, SimilarResultV1, + SimilarSurfaceRequestV1, SimilarTargetV1, TodoMarkerV1, TodosResultV1, TodosSurfaceRequestV1, }; pub use requests::{ AffectedTestAttributionV1, AffectedTestsRequest, AffectedTestsResult, AnchorExpandRequest, diff --git a/crates/tracedecay-contracts/src/retrieval/primitive_surface.rs b/crates/tracedecay-contracts/src/retrieval/primitive_surface.rs index 0acfd5c1e5..2be98a3624 100644 --- a/crates/tracedecay-contracts/src/retrieval/primitive_surface.rs +++ b/crates/tracedecay-contracts/src/retrieval/primitive_surface.rs @@ -156,31 +156,12 @@ pub struct SimilarSurfaceRequestV1 { pub repository_id: RepositoryId, pub target: SimilarTargetV1, pub match_classes: Vec, - /// Preferred result page size. Accepts legacy `limit` as a wire alias. - #[serde(alias = "limit")] + /// Preferred result page size on the family schema. pub result_limit: u32, pub work_limit: u32, pub cursor: Option, } -/// Pre-family similar request shape (`{symbol, limit}`) retained for decode -/// so cutover clients get a typed migration error instead of opaque serde noise. -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct LegacySimilarSurfaceRequestV1 { - pub symbol: String, - pub limit: Option, -} - -/// Runtime decode envelope for `tracedecay_similar`: current family schema or -/// the retired `{symbol, limit}` spelling. -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] -#[serde(untagged)] -pub enum SimilarSurfaceRequestWireV1 { - Current(SimilarSurfaceRequestV1), - Legacy(LegacySimilarSurfaceRequestV1), -} - #[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] pub struct RenamePreviewPrimitiveRequestV1 { @@ -227,35 +208,13 @@ pub struct RedundancySurfaceRequestV1 { pub match_classes: Vec, pub scope: RedundancyScopeV1, pub include_generated_paths: bool, - /// Preferred family page size. Accepts legacy `max_pairs` / `limit` aliases. - #[serde(alias = "max_pairs", alias = "limit")] + /// Preferred family page size on the family schema. pub family_limit: u32, pub member_limit: u32, pub work_limit: u32, pub cursor: Option, } -/// Pre-family redundancy request shape retained for decode so cutover clients -/// get a typed migration error instead of opaque serde noise. -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct LegacyRedundancySurfaceRequestV1 { - pub path: Option, - pub min_lines: Option, - pub max_pairs: Option, - pub similarity_threshold: Option, - pub include_naming_only: Option, - pub include_generated_paths: Option, -} - -/// Runtime decode envelope for `tracedecay_redundancy`. -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(untagged)] -pub enum RedundancySurfaceRequestWireV1 { - Current(RedundancySurfaceRequestV1), - Legacy(LegacyRedundancySurfaceRequestV1), -} - #[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] pub struct TodosSurfaceRequestV1 { @@ -764,7 +723,7 @@ mod tests { ContextModeV1, ContextResultV1, ContextSurfaceRequestV1, PrimitiveFreshnessStateV1, PrimitiveIndexingStateV1, PrimitiveLaneCompleteV1, PrimitiveLaneStatusV1, PrimitiveRecallV1, PrimitiveSearchCoverageV1, PrimitiveSearchFreshnessV1, - RedundancySurfaceRequestWireV1, SimilarSurfaceRequestWireV1, + RedundancySurfaceRequestV1, SimilarSurfaceRequestV1, }; use crate::memory::{FactSearchGraphCoverageV1, FactSearchGraphDegradationV1}; @@ -901,11 +860,39 @@ mod tests { } #[test] - fn similar_and_redundancy_accept_legacy_request_shapes_on_the_wire() { + fn similar_and_redundancy_reject_retired_request_shapes() { let legacy_similar = json!({"symbol": "foo", "limit": 5}); - let wire: SimilarSurfaceRequestWireV1 = - serde_json::from_value(legacy_similar).expect("legacy similar decodes"); - assert!(matches!(wire, SimilarSurfaceRequestWireV1::Legacy(_))); + assert!( + serde_json::from_value::(legacy_similar).is_err(), + "retired {{symbol, limit}} is not a family-schema request" + ); + + let alias_similar = json!({ + "project_id": "project.demo", + "repository_id": "repo.demo", + "target": {"kind": "symbol_occurrence", "symbol_occurrence_id": "symbol.v1.demo"}, + "match_classes": ["conservative_exact"], + "limit": 3, + "work_limit": 100, + "cursor": null + }); + assert!( + serde_json::from_value::(alias_similar).is_err(), + "limit is not an alias for result_limit" + ); + + let current_similar = json!({ + "project_id": "project.demo", + "repository_id": "repo.demo", + "target": {"kind": "symbol_occurrence", "symbol_occurrence_id": "symbol.v1.demo"}, + "match_classes": ["conservative_exact"], + "result_limit": 3, + "work_limit": 100, + "cursor": null + }); + let request: SimilarSurfaceRequestV1 = + serde_json::from_value(current_similar).expect("family similar schema decodes"); + assert_eq!(request.result_limit, 3); let legacy_redundancy = json!({ "path": "src/", @@ -915,24 +902,25 @@ mod tests { "include_naming_only": false, "include_generated_paths": true }); - let wire: RedundancySurfaceRequestWireV1 = - serde_json::from_value(legacy_redundancy).expect("legacy redundancy decodes"); - assert!(matches!(wire, RedundancySurfaceRequestWireV1::Legacy(_))); + assert!( + serde_json::from_value::(legacy_redundancy).is_err(), + "retired path/max_pairs shape is not a family-schema request" + ); - let current_similar = json!({ + let alias_redundancy = json!({ "project_id": "project.demo", "repository_id": "repo.demo", - "target": {"kind": "symbol_occurrence", "symbol_occurrence_id": "symbol.v1.demo"}, "match_classes": ["conservative_exact"], - "limit": 3, - "work_limit": 100, + "scope": {"kind": "repository"}, + "include_generated_paths": false, + "max_pairs": 4, + "member_limit": 2, + "work_limit": 10, "cursor": null }); - let wire: SimilarSurfaceRequestWireV1 = serde_json::from_value(current_similar) - .expect("current similar decodes with limit alias"); - match wire { - SimilarSurfaceRequestWireV1::Current(request) => assert_eq!(request.result_limit, 3), - SimilarSurfaceRequestWireV1::Legacy(_) => panic!("expected current family schema"), - } + assert!( + serde_json::from_value::(alias_redundancy).is_err(), + "max_pairs is not an alias for family_limit" + ); } } diff --git a/crates/tracedecay-contracts/tests/contracts_suite/catalog_contributions.rs b/crates/tracedecay-contracts/tests/contracts_suite/catalog_contributions.rs index a45624dd9b..b4c5cfa258 100644 --- a/crates/tracedecay-contracts/tests/contracts_suite/catalog_contributions.rs +++ b/crates/tracedecay-contracts/tests/contracts_suite/catalog_contributions.rs @@ -185,7 +185,7 @@ fn verified_graph_mcp_reads_have_application_primitive_admission_identity() { } #[test] -fn similar_and_redundancy_cover_protocol_revisions_on_one_surface_operation() { +fn similar_and_redundancy_keep_one_current_protocol_revision() { use tracedecay_tool_catalog::BindingStatus; let contribution = primitive_read_contribution().unwrap(); @@ -208,14 +208,14 @@ fn similar_and_redundancy_cover_protocol_revisions_on_one_surface_operation() { assert_eq!(binding.alias_of(), None); assert!( binding.protocol_revisions().contains(1), - "{operation} must accept protocol revision 1" + "{operation} must accept the family-schema protocol revision" ); assert!( - binding.protocol_revisions().contains(2), - "{operation} must accept protocol revision 2" + !binding.protocol_revisions().contains(2), + "{operation} must not advertise a retired cutover revision" ); assert_eq!(binding.protocol_revisions().minimum(), 1); - assert_eq!(binding.protocol_revisions().maximum(), 2); + assert_eq!(binding.protocol_revisions().maximum(), 1); } } diff --git a/crates/tracedecay-mcp/src/handlers/graph/search.rs b/crates/tracedecay-mcp/src/handlers/graph/search.rs index 100477fcde..21be7bec82 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/search.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/search.rs @@ -10,11 +10,10 @@ use serde_json::{Value, json}; use tracedecay_code_index::graph_projection::CodeGraphSymbolSummaryV1; use tracedecay_contracts::retrieval::{ ContextCodeBlockV1, ContextModeV1, ContextResultV1, ContextSearchMatchV1, - ContextSurfaceRequestV1, RedundancyScopeV1, RedundancySurfaceRequestWireV1, - RenamePreviewNodeV1, RenamePreviewPrimitiveRequestV1, RenamePreviewPrimitiveResultV1, - RenamePreviewReferenceV1, RenamePreviewTextOnlyMatchV1, SimilarCoverageV1, SimilarFamilyV1, - SimilarMatchClassV1, SimilarOccurrenceV1, SimilarResultV1, SimilarSurfaceRequestWireV1, - SimilarTargetV1, + ContextSurfaceRequestV1, RedundancyScopeV1, RedundancySurfaceRequestV1, RenamePreviewNodeV1, + RenamePreviewPrimitiveRequestV1, RenamePreviewPrimitiveResultV1, RenamePreviewReferenceV1, + RenamePreviewTextOnlyMatchV1, SimilarCoverageV1, SimilarFamilyV1, SimilarMatchClassV1, + SimilarOccurrenceV1, SimilarResultV1, SimilarSurfaceRequestV1, SimilarTargetV1, }; use tracedecay_domain::ExactClass; use tracedecay_domain::errors::{Result, TraceDecayError}; @@ -1038,17 +1037,7 @@ pub async fn handle_find_exact_symbol( #[hotpath::measure(label = "mcp.graph.similar.total")] pub async fn handle_similar(ctx: &McpToolContext<'_>, args: Value) -> Result { - let wire: SimilarSurfaceRequestWireV1 = decode_primitive_request(&args, "tracedecay_similar")?; - let request = match wire { - SimilarSurfaceRequestWireV1::Current(request) => request, - SimilarSurfaceRequestWireV1::Legacy(_) => { - return Err(TraceDecayError::ProjectRoute { - reason_code: "similar-request-schema-retired".to_owned(), - retryable: false, - detail: "tracedecay_similar retired the {symbol, limit} request shape; use the family schema (project_id, repository_id, target, match_classes, result_limit, work_limit). Catalog binding.mcp.similar.v1 covers protocol revisions 1..=2 on one surface-operation key.".to_owned(), - }); - } - }; + let request: SimilarSurfaceRequestV1 = decode_primitive_request(&args, "tracedecay_similar")?; let project_id = request.project_id; let repository_id = request.repository_id; let target = match request.target { @@ -1222,18 +1211,8 @@ fn clone_lane_unavailable_error( #[hotpath::measure(label = "mcp.graph.redundancy.total")] pub async fn handle_redundancy(ctx: &McpToolContext<'_>, args: Value) -> Result { - let wire: RedundancySurfaceRequestWireV1 = + let request: RedundancySurfaceRequestV1 = decode_primitive_request(&args, "tracedecay_redundancy")?; - let request = match wire { - RedundancySurfaceRequestWireV1::Current(request) => request, - RedundancySurfaceRequestWireV1::Legacy(_) => { - return Err(TraceDecayError::ProjectRoute { - reason_code: "redundancy-request-schema-retired".to_owned(), - retryable: false, - detail: "tracedecay_redundancy retired the path/min_lines/max_pairs request shape; use the family schema (project_id, repository_id, match_classes, scope, family_limit, member_limit, work_limit). Catalog binding.mcp.redundancy.v1 covers protocol revisions 1..=2 on one surface-operation key.".to_owned(), - }); - } - }; if request.project_id != ctx.admitted_scope().project_id || request.repository_id != ctx.admitted_scope().repository_id { diff --git a/sdks/typescript/src/operations.ts b/sdks/typescript/src/operations.ts index 3666ae04bb..a4ffcc19e4 100644 --- a/sdks/typescript/src/operations.ts +++ b/sdks/typescript/src/operations.ts @@ -2369,7 +2369,7 @@ const SCHEMAS: readonly CanonicalJsonSchema[] = [ {"$defs":{"BranchStackEdgeV1":DEFINITIONS.BranchStackEdgeV1,"BranchStackId":DEFINITIONS.BranchStackId,"BranchStackNodeV1":DEFINITIONS.BranchStackNodeV1,"BranchStackRevisionId":DEFINITIONS.BranchStackRevisionId,"BranchStackRevisionV1":DEFINITIONS.BranchStackRevisionV1,"BranchStackSourceV1":DEFINITIONS.BranchStackSourceV1,"CommitId":DEFINITIONS.CommitId,"ManifestDigest":DEFINITIONS.ManifestDigest,"MechanicalIntegrationModeV1":DEFINITIONS.MechanicalIntegrationModeV1,"NativeIntegrationDirectionV1":DEFINITIONS.NativeIntegrationDirectionV1,"NativeIntegrationSealedStackSnapshotV1":DEFINITIONS.NativeIntegrationSealedStackSnapshotV1,"NativeIntegrationSelectionBindingV1":DEFINITIONS.NativeIntegrationSelectionBindingV1,"ProjectId":DEFINITIONS.ProjectId,"RefId":DEFINITIONS.RefId,"RepositoryId":DEFINITIONS.RepositoryId,"ResolvedScope":DEFINITIONS.ResolvedScope,"ScopeSetId":DEFINITIONS.ScopeSetId,"ScopeSetRevision":DEFINITIONS.ScopeSetRevision,"StackNodeId":DEFINITIONS.StackNodeId,"WorktreeId":DEFINITIONS.WorktreeId,"WorktreeInventoryEpoch":DEFINITIONS.WorktreeInventoryEpoch,"WorktreeInventorySnapshotId":DEFINITIONS.WorktreeInventorySnapshotId},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"description":"Read-only preflight over one frozen snapshot identity.\n\n`preferred_mode` selects only one of the three fixed mechanical encodings.\nIt cannot change topology, commit order, or the commit set.","properties":{"preferred_mode":{"anyOf":[{"$ref":"#/$defs/MechanicalIntegrationModeV1"},{"type":"null"}],"default":null},"snapshot":{"$ref":"#/$defs/NativeIntegrationSealedStackSnapshotV1"}},"required":["snapshot"],"title":"NativeIntegrationPreflightSurfaceRequest","type":"object"}, {"$defs":{"PageRequest":DEFINITIONS.PageRequest},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"page":{"$ref":"#/$defs/PageRequest"},"qualified_name":{"type":"string"}},"required":["qualified_name","page"],"title":"QualifiedNamePrimitiveRequest","type":"object"}, {"$defs":{"SymbolPrimitiveRecord":DEFINITIONS.SymbolPrimitiveRecord},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"next_cursor":{"description":"Opaque resume token; its bounded string is the public wire form.","type":["string","null"]},"symbols":{"items":{"$ref":"#/$defs/SymbolPrimitiveRecord"},"type":"array"},"total":{"format":"uint64","minimum":0,"type":["integer","null"]}},"required":["symbols"],"title":"QualifiedNamePrimitiveResult","type":"object"}, - {"$defs":{"CommitId":DEFINITIONS.CommitId,"ProjectId":DEFINITIONS.ProjectId,"ProviderId":DEFINITIONS.ProviderId,"RedundancyScopeV1":DEFINITIONS.RedundancyScopeV1,"RepositoryId":DEFINITIONS.RepositoryId,"SimilarMatchClassV1":DEFINITIONS.SimilarMatchClassV1},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"cursor":{"type":["string","null"]},"family_limit":{"description":"Preferred family page size. Accepts legacy `max_pairs` / `limit` aliases.","format":"uint32","minimum":0,"type":"integer"},"include_generated_paths":{"type":"boolean"},"match_classes":{"items":{"$ref":"#/$defs/SimilarMatchClassV1"},"type":"array"},"member_limit":{"format":"uint32","minimum":0,"type":"integer"},"project_id":{"$ref":"#/$defs/ProjectId"},"repository_id":{"$ref":"#/$defs/RepositoryId"},"scope":{"$ref":"#/$defs/RedundancyScopeV1"},"work_limit":{"format":"uint32","minimum":0,"type":"integer"}},"required":["project_id","repository_id","match_classes","scope","include_generated_paths","family_limit","member_limit","work_limit"],"title":"RedundancySurfaceRequestV1","type":"object"}, + {"$defs":{"CommitId":DEFINITIONS.CommitId,"ProjectId":DEFINITIONS.ProjectId,"ProviderId":DEFINITIONS.ProviderId,"RedundancyScopeV1":DEFINITIONS.RedundancyScopeV1,"RepositoryId":DEFINITIONS.RepositoryId,"SimilarMatchClassV1":DEFINITIONS.SimilarMatchClassV1},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"cursor":{"type":["string","null"]},"family_limit":{"description":"Preferred family page size on the family schema.","format":"uint32","minimum":0,"type":"integer"},"include_generated_paths":{"type":"boolean"},"match_classes":{"items":{"$ref":"#/$defs/SimilarMatchClassV1"},"type":"array"},"member_limit":{"format":"uint32","minimum":0,"type":"integer"},"project_id":{"$ref":"#/$defs/ProjectId"},"repository_id":{"$ref":"#/$defs/RepositoryId"},"scope":{"$ref":"#/$defs/RedundancyScopeV1"},"work_limit":{"format":"uint32","minimum":0,"type":"integer"}},"required":["project_id","repository_id","match_classes","scope","include_generated_paths","family_limit","member_limit","work_limit"],"title":"RedundancySurfaceRequestV1","type":"object"}, {"$defs":{"CodeGenerationId":DEFINITIONS.CodeGenerationId,"ManifestDigest":DEFINITIONS.ManifestDigest,"ProjectId":DEFINITIONS.ProjectId,"RedundancyCoverageV1":DEFINITIONS.RedundancyCoverageV1,"RedundancyFamilyV1":DEFINITIONS.RedundancyFamilyV1,"RedundancyPartialReasonV1":DEFINITIONS.RedundancyPartialReasonV1,"RedundancyRankingV1":DEFINITIONS.RedundancyRankingV1,"RepositoryId":DEFINITIONS.RepositoryId,"SimilarFamilyV1":DEFINITIONS.SimilarFamilyV1,"SimilarMatchClassV1":DEFINITIONS.SimilarMatchClassV1,"SimilarOccurrenceV1":DEFINITIONS.SimilarOccurrenceV1,"SourceSpan":DEFINITIONS.SourceSpan,"SymbolOccurrenceId":DEFINITIONS.SymbolOccurrenceId,"WorktreeId":DEFINITIONS.WorktreeId},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"coverage":{"$ref":"#/$defs/RedundancyCoverageV1"},"families":{"items":{"$ref":"#/$defs/RedundancyFamilyV1"},"type":"array"},"next_cursor":{"type":["string","null"]},"ranked_by":{"$ref":"#/$defs/RedundancyRankingV1"},"source_generation":{"$ref":"#/$defs/CodeGenerationId"}},"required":["source_generation","ranked_by","families","coverage"],"title":"RedundancyResultV1","type":"object"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"new_name":{"type":["string","null"]},"node_id":{"type":"string"}},"required":["node_id"],"title":"RenamePreviewPrimitiveRequestV1","type":"object"}, {"$defs":{"PrimitiveNotFoundV1":DEFINITIONS.PrimitiveNotFoundV1,"RenamePreviewNodeV1":DEFINITIONS.RenamePreviewNodeV1,"RenamePreviewPrimitiveResultV1":DEFINITIONS.RenamePreviewPrimitiveResultV1,"RenamePreviewReferenceV1":DEFINITIONS.RenamePreviewReferenceV1,"RenamePreviewTextOnlyMatchV1":DEFINITIONS.RenamePreviewTextOnlyMatchV1},"$schema":"https://json-schema.org/draft/2020-12/schema","anyOf":[{"$ref":"#/$defs/RenamePreviewPrimitiveResultV1"},{"$ref":"#/$defs/PrimitiveNotFoundV1"}],"title":"RenamePreviewPrimitiveOutcomeV1"}, @@ -2383,7 +2383,7 @@ const SCHEMAS: readonly CanonicalJsonSchema[] = [ {"$defs":{"ClosedUtcIntervalV1":DEFINITIONS.ClosedUtcIntervalV1,"RetainedErrorV1":DEFINITIONS.RetainedErrorV1,"RetainedOutcomeStatusV1":DEFINITIONS.RetainedOutcomeStatusV1,"SessionCoverageIntervalV1":DEFINITIONS.SessionCoverageIntervalV1,"SessionCoverageModeV1":DEFINITIONS.SessionCoverageModeV1,"SessionCoverageReasonV1":DEFINITIONS.SessionCoverageReasonV1,"SessionCoverageRequestV1":DEFINITIONS.SessionCoverageRequestV1,"SessionCoverageStateV1":DEFINITIONS.SessionCoverageStateV1,"SessionRefreshFrontierResultV1":DEFINITIONS.SessionRefreshFrontierResultV1,"SessionRefreshProgressV1":DEFINITIONS.SessionRefreshProgressV1,"SessionRefreshReceiptV1":DEFINITIONS.SessionRefreshReceiptV1,"SessionRefreshTerminalStateResultV1":DEFINITIONS.SessionRefreshTerminalStateResultV1,"SessionSourceCoverageV1":DEFINITIONS.SessionSourceCoverageV1,"TemporalCoverageV1":DEFINITIONS.TemporalCoverageV1,"ValidCoverageIntervalV1":DEFINITIONS.ValidCoverageIntervalV1},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"error":{"anyOf":[{"$ref":"#/$defs/RetainedErrorV1"},{"type":"null"}]},"outcome":{"$ref":"#/$defs/RetainedOutcomeStatusV1"},"progress":{"anyOf":[{"$ref":"#/$defs/SessionRefreshProgressV1"},{"type":"null"}]},"receipt":{"anyOf":[{"$ref":"#/$defs/SessionRefreshReceiptV1"},{"type":"null"}]},"scope":{"type":"string"},"tool":{"type":"string"}},"required":["outcome","scope","tool"],"title":"SessionRefreshStatusResultV1","type":"object"}, {"$defs":{"RetainedOutputFormatV1":DEFINITIONS.RetainedOutputFormatV1,"RetainedTimeFilterV1":DEFINITIONS.RetainedTimeFilterV1,"SessionGitRefV1":DEFINITIONS.SessionGitRefV1,"SessionGitRelationV1":DEFINITIONS.SessionGitRelationV1},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"format":{"anyOf":[{"$ref":"#/$defs/RetainedOutputFormatV1"},{"type":"null"}]},"git_ref":{"$ref":"#/$defs/SessionGitRefV1"},"limit":{"format":"uint64","minimum":0,"type":["integer","null"]},"relation":{"anyOf":[{"$ref":"#/$defs/SessionGitRelationV1"},{"type":"null"}]},"since":{"anyOf":[{"$ref":"#/$defs/RetainedTimeFilterV1"},{"type":"null"}]},"until":{"anyOf":[{"$ref":"#/$defs/RetainedTimeFilterV1"},{"type":"null"}]},"value":{"type":"string"}},"required":["git_ref","value"],"title":"SessionsForRequestV1","type":"object"}, {"$defs":{"CorrelationIndexCountModeV1":DEFINITIONS.CorrelationIndexCountModeV1,"CorrelationIndexV1":DEFINITIONS.CorrelationIndexV1,"RetainedOutcomeStatusV1":DEFINITIONS.RetainedOutcomeStatusV1,"SessionCorrelationHitV1":DEFINITIONS.SessionCorrelationHitV1},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"count":{"format":"uint","minimum":0,"type":"integer"},"git_ref":{"type":["string","null"]},"index":{"anyOf":[{"$ref":"#/$defs/CorrelationIndexV1"},{"type":"null"}]},"index_empty":{"type":["boolean","null"]},"message":{"type":["string","null"]},"observed_count":{"format":"uint","minimum":0,"type":["integer","null"]},"observed_sessions":{"items":{"$ref":"#/$defs/SessionCorrelationHitV1"},"type":["array","null"]},"problem_code":{"type":["string","null"]},"relation":{"type":["string","null"]},"results":{"items":{"$ref":"#/$defs/SessionCorrelationHitV1"},"type":"array"},"since":{"format":"int64","type":["integer","null"]},"status":{"$ref":"#/$defs/RetainedOutcomeStatusV1"},"until":{"format":"int64","type":["integer","null"]},"value":{"type":["string","null"]}},"required":["count","results","status"],"title":"SessionsForResultV1","type":"object"}, - {"$defs":{"ProjectId":DEFINITIONS.ProjectId,"RepositoryId":DEFINITIONS.RepositoryId,"SimilarMatchClassV1":DEFINITIONS.SimilarMatchClassV1,"SimilarTargetV1":DEFINITIONS.SimilarTargetV1,"SourceSpan":DEFINITIONS.SourceSpan,"SymbolOccurrenceId":DEFINITIONS.SymbolOccurrenceId},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"cursor":{"type":["string","null"]},"match_classes":{"items":{"$ref":"#/$defs/SimilarMatchClassV1"},"type":"array"},"project_id":{"$ref":"#/$defs/ProjectId"},"repository_id":{"$ref":"#/$defs/RepositoryId"},"result_limit":{"description":"Preferred result page size. Accepts legacy `limit` as a wire alias.","format":"uint32","minimum":0,"type":"integer"},"target":{"$ref":"#/$defs/SimilarTargetV1"},"work_limit":{"format":"uint32","minimum":0,"type":"integer"}},"required":["project_id","repository_id","target","match_classes","result_limit","work_limit"],"title":"SimilarSurfaceRequestV1","type":"object"}, + {"$defs":{"ProjectId":DEFINITIONS.ProjectId,"RepositoryId":DEFINITIONS.RepositoryId,"SimilarMatchClassV1":DEFINITIONS.SimilarMatchClassV1,"SimilarTargetV1":DEFINITIONS.SimilarTargetV1,"SourceSpan":DEFINITIONS.SourceSpan,"SymbolOccurrenceId":DEFINITIONS.SymbolOccurrenceId},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"cursor":{"type":["string","null"]},"match_classes":{"items":{"$ref":"#/$defs/SimilarMatchClassV1"},"type":"array"},"project_id":{"$ref":"#/$defs/ProjectId"},"repository_id":{"$ref":"#/$defs/RepositoryId"},"result_limit":{"description":"Preferred result page size on the family schema.","format":"uint32","minimum":0,"type":"integer"},"target":{"$ref":"#/$defs/SimilarTargetV1"},"work_limit":{"format":"uint32","minimum":0,"type":"integer"}},"required":["project_id","repository_id","target","match_classes","result_limit","work_limit"],"title":"SimilarSurfaceRequestV1","type":"object"}, {"$defs":{"CodeGenerationId":DEFINITIONS.CodeGenerationId,"ManifestDigest":DEFINITIONS.ManifestDigest,"ProjectId":DEFINITIONS.ProjectId,"RepositoryId":DEFINITIONS.RepositoryId,"SimilarCoverageV1":DEFINITIONS.SimilarCoverageV1,"SimilarFamilyV1":DEFINITIONS.SimilarFamilyV1,"SimilarMatchClassV1":DEFINITIONS.SimilarMatchClassV1,"SimilarOccurrenceV1":DEFINITIONS.SimilarOccurrenceV1,"SourceSpan":DEFINITIONS.SourceSpan,"SymbolOccurrenceId":DEFINITIONS.SymbolOccurrenceId,"WorktreeId":DEFINITIONS.WorktreeId},"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"coverage":{"$ref":"#/$defs/SimilarCoverageV1"},"families":{"items":{"$ref":"#/$defs/SimilarFamilyV1"},"type":"array"},"source":{"$ref":"#/$defs/SimilarOccurrenceV1"},"source_generation":{"$ref":"#/$defs/CodeGenerationId"}},"required":["source","families","source_generation","coverage"],"title":"SimilarResultV1","type":"object"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"node_id":{"type":"string"}},"required":["node_id"],"title":"SourceBodyPrimitiveRequest","type":"object"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"body":{"type":"string"},"end_line":{"format":"uint32","minimum":0,"type":"integer"},"file":{"type":"string"},"node_id":{"type":"string"},"start_line":{"format":"uint32","minimum":0,"type":"integer"}},"required":["node_id","file","start_line","end_line","body"],"title":"SourceBodyPrimitiveResult","type":"object"}, From ebf2773f952ebebc7614769b19738979378c7df9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 01:27:58 +0000 Subject: [PATCH 2/3] fix(global-db): refuse non-final temporal schemas Published v3 and unreleased pre-recovery v4 stores return ResetRequired before conversion. Only the final temporal shape is admitted. Co-authored-by: Zack Jackson --- .../tracedecay-global-db/src/schema_stages.rs | 7 - .../src/session_temporal_schema.rs | 240 ---------------- .../src/session_temporal_schema/admission.rs | 24 +- .../lcm_schema/temporal_catalog/admission.rs | 265 +++++++----------- 4 files changed, 116 insertions(+), 420 deletions(-) diff --git a/crates/tracedecay-global-db/src/schema_stages.rs b/crates/tracedecay-global-db/src/schema_stages.rs index 915f3b13d6..3cdb8b59fc 100644 --- a/crates/tracedecay-global-db/src/schema_stages.rs +++ b/crates/tracedecay-global-db/src/schema_stages.rs @@ -833,13 +833,6 @@ async fn install_registered_schema_stage_sequence( session_temporal_schema::SessionTemporalSchemaAdmission::Fresh => { session_temporal_schema::install_session_temporal_schema(transaction).await?; } - session_temporal_schema::SessionTemporalSchemaAdmission::ReleasedV3 => { - session_temporal_schema::migrate_released_v3_session_temporal_schema(transaction) - .await?; - } - session_temporal_schema::SessionTemporalSchemaAdmission::WithoutReceiptRecovery => { - session_temporal_schema::migrate_session_relation_receipt_recovery(transaction).await?; - } session_temporal_schema::SessionTemporalSchemaAdmission::Current => {} } observation::ensure_observation_schema(transaction).await?; diff --git a/crates/tracedecay-global-db/src/session_temporal_schema.rs b/crates/tracedecay-global-db/src/session_temporal_schema.rs index b60c3f9903..e33df3667c 100644 --- a/crates/tracedecay-global-db/src/session_temporal_schema.rs +++ b/crates/tracedecay-global-db/src/session_temporal_schema.rs @@ -584,246 +584,6 @@ const TEMPORAL_SCHEMA_DDL: &str = r" pub(crate) use tracedecay_session_temporal_store::TEMPORAL_TABLE_COLUMNS; -/// Additive receipt-recovery step shared by every store that predates it: -/// retained receipts keep their rows and take the contract defaults. -const SESSION_RELATION_RECEIPT_RECOVERY_DDL: &str = " - ALTER TABLE session_relation_receipts - ADD COLUMN recovery_state TEXT NOT NULL DEFAULT 'pending' - CHECK(recovery_state IN ('pending', 'retryable', 'permanent')); - ALTER TABLE session_relation_receipts - ADD COLUMN recovery_failure_code TEXT; - ALTER TABLE session_relation_receipts - ADD COLUMN recovery_failure_count INTEGER NOT NULL DEFAULT 0 - CHECK(recovery_failure_count >= 0); - ALTER TABLE session_relation_receipts - ADD COLUMN recovery_next_attempt_at INTEGER NOT NULL DEFAULT 0; - CREATE INDEX IF NOT EXISTS idx_session_relation_receipts_recovery_due - ON session_relation_receipts( - state, recovery_state, recovery_next_attempt_at, - created_at, session_id, generation - );"; - -/// Converges a v4 store persisted before receipt recovery onto the final v4 -/// contract inside the caller's admission transaction. The marker stays at -/// v4 — this shape never shipped as its own version — but its `applied_at` -/// is compare-and-swapped so a marker that moved underneath the migration -/// rolls the whole step back instead of stamping a shape it did not verify. -#[hotpath::measure( - future = true, - label = "session_temporal.schema.migrate_receipt_recovery" -)] -pub(crate) async fn migrate_session_relation_receipt_recovery( - conn: &impl Executor, -) -> tracedecay_domain::errors::Result<()> { - admission::validate_without_receipt_recovery_session_temporal_schema(conn).await?; - conn.execute_batch(SESSION_RELATION_RECEIPT_RECOVERY_DDL) - .await - .map_err(|error| global_db_operation_error(OPERATION, error))?; - let updated = conn - .execute( - "UPDATE session_temporal_schema_migrations - SET applied_at = unixepoch() - WHERE name = ?1 AND version = ?2", - params![MIGRATION_NAME, SESSION_TEMPORAL_SCHEMA_VERSION], - ) - .await - .map_err(|error| global_db_operation_error(OPERATION, error))?; - if updated != 1 { - return Err(admission::session_temporal_reset_required( - "v4 temporal schema marker changed during receipt recovery migration", - )); - } - validate_temporal_table_shapes(conn).await?; - admission::validate_current_session_temporal_schema(conn).await -} - -#[hotpath::measure(future = true, label = "session_temporal.schema.migrate")] -pub(crate) async fn migrate_released_v3_session_temporal_schema( - conn: &impl Executor, -) -> tracedecay_domain::errors::Result<()> { - admission::validate_released_v3_session_temporal_schema(conn).await?; - conn.execute_batch( - "DROP TRIGGER session_temporal_projection_receipts_immutable_update_v1; - ALTER TABLE session_temporal_projection_receipts - ADD COLUMN batch_item_count INTEGER NOT NULL DEFAULT 0 - CHECK(batch_item_count >= 0); - ALTER TABLE session_temporal_projection_receipts - ADD COLUMN committed_item_count INTEGER NOT NULL DEFAULT 0 - CHECK(committed_item_count >= 0); - ALTER TABLE session_temporal_projection_receipts - ADD COLUMN committed_copy_count INTEGER NOT NULL DEFAULT 0 - CHECK(committed_copy_count >= 0);", - ) - .await - .map_err(|error| global_db_operation_error(OPERATION, error))?; - conn.execute_batch(SESSION_RELATION_RECEIPT_RECOVERY_DDL) - .await - .map_err(|error| global_db_operation_error(OPERATION, error))?; - - let mut ambiguous = conn - .query( - "SELECT receipt.session_id, receipt.generation, receipt.batch_ordinal - FROM session_temporal_projection_receipts AS receipt - LEFT JOIN session_refresh_batch_bindings AS batch_binding - ON batch_binding.session_id = receipt.session_id - AND batch_binding.generation = receipt.generation - AND batch_binding.batch_ordinal = receipt.batch_ordinal - LEFT JOIN session_refresh_progress AS progress - ON progress.session_id = batch_binding.session_id - AND progress.operation_id = batch_binding.operation_id - AND progress.progress_ordinal = batch_binding.progress_ordinal - LEFT JOIN session_refresh_bindings AS refresh_binding - ON refresh_binding.session_id = batch_binding.session_id - AND refresh_binding.operation_id = batch_binding.operation_id - AND refresh_binding.generation = batch_binding.generation - WHERE json_type(receipt.frozen_watermarks_json, '$.active_generation') - IS NOT 'integer' - OR json_extract(receipt.frozen_watermarks_json, '$.active_generation') <= 0 - OR json_extract(receipt.frozen_watermarks_json, '$.active_generation') - > receipt.generation - OR batch_binding.session_id IS NULL - OR ( - batch_binding.session_id IS NOT NULL - AND ( - batch_binding.progress_ordinal <> receipt.batch_ordinal - OR progress.session_id IS NULL - OR refresh_binding.session_id IS NULL - OR refresh_binding.frozen_watermarks_json - <> receipt.frozen_watermarks_json - OR progress.committed_batches <> receipt.batch_ordinal + 1 - OR progress.committed_records <> - receipt.occurrence_count + receipt.copy_count - + receipt.assertion_count - OR json_extract(progress.frontier_json, '$.committed_through') - <> receipt.projection_through - ) - ) - LIMIT 1", - (), - ) - .await - .map_err(|error| global_db_operation_error(OPERATION, error))?; - if ambiguous - .next() - .await - .map_err(|error| global_db_operation_error(OPERATION, error))? - .is_some() - { - return Err(admission::session_temporal_reset_required( - "released v3 projection receipt batch accounting is unbound or ambiguous", - )); - } - drop(ambiguous); - - let mut invalid = conn - .query( - "SELECT current.session_id, current.generation, current.batch_ordinal - FROM session_temporal_projection_receipts AS current - LEFT JOIN session_temporal_projection_receipts AS previous - ON previous.session_id = current.session_id - AND previous.generation = current.generation - AND previous.batch_ordinal = current.batch_ordinal - 1 - WHERE ( - current.batch_ordinal > 0 - AND ( - previous.batch_ordinal IS NULL - OR current.occurrence_count + current.copy_count + current.assertion_count - < previous.occurrence_count + previous.copy_count - + previous.assertion_count - ) - ) - OR ( - current.batch_ordinal = 0 - AND json_extract( - current.frozen_watermarks_json, '$.active_generation' - ) <> current.generation - AND current.occurrence_count + current.copy_count + current.assertion_count - < COALESCE(( - SELECT baseline.occurrence_count + baseline.copy_count - + baseline.assertion_count - FROM session_temporal_projection_receipts AS baseline - WHERE baseline.session_id = current.session_id - AND baseline.generation = json_extract( - current.frozen_watermarks_json, '$.active_generation' - ) - ORDER BY baseline.batch_ordinal DESC - LIMIT 1 - ), 0) - ) - LIMIT 1", - (), - ) - .await - .map_err(|error| global_db_operation_error(OPERATION, error))?; - if invalid - .next() - .await - .map_err(|error| global_db_operation_error(OPERATION, error))? - .is_some() - { - return Err(admission::session_temporal_reset_required( - "released v3 projection receipt progress is non-monotonic or noncontiguous", - )); - } - drop(invalid); - - conn.execute( - "UPDATE session_temporal_projection_receipts AS current - SET batch_item_count = - current.occurrence_count + current.copy_count + current.assertion_count - - CASE - WHEN current.batch_ordinal > 0 THEN COALESCE(( - SELECT previous.occurrence_count + previous.copy_count - + previous.assertion_count - FROM session_temporal_projection_receipts AS previous - WHERE previous.session_id = current.session_id - AND previous.generation = current.generation - AND previous.batch_ordinal = current.batch_ordinal - 1 - ), 0) - WHEN json_extract( - current.frozen_watermarks_json, '$.active_generation' - ) <> current.generation THEN COALESCE(( - SELECT baseline.occurrence_count + baseline.copy_count - + baseline.assertion_count - FROM session_temporal_projection_receipts AS baseline - WHERE baseline.session_id = current.session_id - AND baseline.generation = json_extract( - current.frozen_watermarks_json, '$.active_generation' - ) - ORDER BY baseline.batch_ordinal DESC - LIMIT 1 - ), 0) - ELSE 0 - END, - committed_item_count = - current.occurrence_count + current.copy_count + current.assertion_count, - committed_copy_count = current.copy_count", - (), - ) - .await - .map_err(|error| global_db_operation_error(OPERATION, error))?; - let updated = conn - .execute( - "UPDATE session_temporal_schema_migrations - SET version = ?1, applied_at = unixepoch() - WHERE name = ?2 AND version = ?3", - params![ - SESSION_TEMPORAL_SCHEMA_VERSION, - MIGRATION_NAME, - RELEASED_SESSION_TEMPORAL_SCHEMA_VERSION, - ], - ) - .await - .map_err(|error| global_db_operation_error(OPERATION, error))?; - if updated != 1 { - return Err(admission::session_temporal_reset_required( - "released v3 temporal schema marker changed during migration", - )); - } - validate_temporal_table_shapes(conn).await?; - admission::validate_current_session_temporal_schema(conn).await -} - /// Installs the final schema into a store already proven fresh by admission. #[hotpath::measure(future = true, label = "session_temporal.schema.install")] pub(crate) async fn install_session_temporal_schema( diff --git a/crates/tracedecay-global-db/src/session_temporal_schema/admission.rs b/crates/tracedecay-global-db/src/session_temporal_schema/admission.rs index a91137812d..27477c7cde 100644 --- a/crates/tracedecay-global-db/src/session_temporal_schema/admission.rs +++ b/crates/tracedecay-global-db/src/session_temporal_schema/admission.rs @@ -150,18 +150,14 @@ const RELEASED_V3_TEMPORAL_TABLE_DIGESTS: &[(&str, &str)] = &[ ]; /// Read-only admission result for the final session-temporal schema. +/// +/// Non-final shapes, including the published v3 marker and the unreleased +/// pre-recovery v4 receipt table, are not variants: admission returns +/// `ResetRequired` before any conversion. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum SessionTemporalSchemaAdmission { /// The persisted schema and its objects exactly match the final contract. Current, - /// The store carries the final marker and contract except that - /// `session_relation_receipts` still has the exact shape persisted before - /// receipt recovery added its columns and index. - WithoutReceiptRecovery, - /// The store carries the exact session-temporal schema every release that - /// persisted marker 3 published: v0.1.0-beta.25 through v0.1.0-beta.37 all - /// shipped one identical table and authority-trigger inventory. - ReleasedV3, /// The registered store is proven empty and may receive the final contract. Fresh, } @@ -179,14 +175,22 @@ pub(crate) async fn require_admissible_session_temporal_schema( Some(SESSION_TEMPORAL_SCHEMA_VERSION) => { if session_relation_receipts_lack_recovery_columns(conn).await? { validate_without_receipt_recovery_session_temporal_schema(conn).await?; - return Ok(SessionTemporalSchemaAdmission::WithoutReceiptRecovery); + return Err(session_temporal_reset_required( + "session_relation_receipts carries the unreleased pre-recovery v4 shape; \ + that shape never shipped as its own version and there is no sanctioned \ + conversion, reset the session temporal authority to recreate the final schema", + )); } validate_current_session_temporal_schema(conn).await?; Ok(SessionTemporalSchemaAdmission::Current) } Some(RELEASED_SESSION_TEMPORAL_SCHEMA_VERSION) => { validate_released_v3_session_temporal_schema(conn).await?; - Ok(SessionTemporalSchemaAdmission::ReleasedV3) + Err(session_temporal_reset_required( + "persisted session temporal schema is the published v3 shape; the final shape \ + is required and there is no sanctioned conversion, reset the session temporal \ + authority", + )) } Some(version) => Err(session_temporal_reset_required(format!( "persisted schema version {version} does not match final version {SESSION_TEMPORAL_SCHEMA_VERSION}" diff --git a/crates/tracedecay-global-db/src/tests/lcm_schema/temporal_catalog/admission.rs b/crates/tracedecay-global-db/src/tests/lcm_schema/temporal_catalog/admission.rs index b4c2cd3fe3..9caa0cf11b 100644 --- a/crates/tracedecay-global-db/src/tests/lcm_schema/temporal_catalog/admission.rs +++ b/crates/tracedecay-global-db/src/tests/lcm_schema/temporal_catalog/admission.rs @@ -595,34 +595,8 @@ async fn insert_seeded_active_released_v3_refresh_receipts( restore_schema_triggers(db_path, &triggers).await; } -async fn projection_receipt_progress_counts(db_path: &Path) -> Vec<(i64, i64, i64, i64)> { - let raw_db = TestConnection::open(db_path); - let conn = (*raw_db).clone(); - let mut rows = conn - .query( - "SELECT batch_ordinal, batch_item_count, committed_item_count, - committed_copy_count - FROM session_temporal_projection_receipts - WHERE session_id = 'released-v3' AND generation = 2 - ORDER BY batch_ordinal", - (), - ) - .await - .unwrap(); - let mut counts = Vec::new(); - while let Some(row) = rows.next().await.unwrap() { - counts.push(( - row.get(0).unwrap(), - row.get(1).unwrap(), - row.get(2).unwrap(), - row.get(3).unwrap(), - )); - } - counts -} - #[tokio::test] -async fn released_v3_temporal_receipts_migrate_to_v4_with_exact_progress_counts() { +async fn released_v3_temporal_receipts_are_refused_without_conversion() { let tmp = TempDir::new().unwrap(); let db_path = tmp.path().join(".tracedecay").join("sessions.db"); let db = open_global_db(&db_path) @@ -631,41 +605,46 @@ async fn released_v3_temporal_receipts_migrate_to_v4_with_exact_progress_counts( drop(db); convert_final_temporal_schema_to_released_v3(&db_path).await; insert_seeded_active_released_v3_refresh_receipts(&db_path, (8, 2, 2)).await; + let before_sql = + schema_object_sql(&db_path, "table", "session_temporal_projection_receipts").await; - let reopened = open_global_db(&db_path) - .await - .expect("the exact published v3 temporal shape should migrate atomically"); - drop(reopened); - - assert_eq!(temporal_schema_version(&db_path).await, 4); - assert_eq!( - projection_receipt_progress_counts(&db_path).await, - [(0, 4, 9, 1), (1, 3, 12, 2)] + let error = match open_global_db(&db_path).await { + Ok(_) => panic!("the published v3 temporal shape must not be converted"), + Err(error) => error, + }; + let (authority, reason) = error + .reset_required_context() + .expect("published v3 must return typed reset-required"); + assert_eq!(authority, "session temporal"); + assert!( + reason.contains("no sanctioned conversion"), + "unexpected reason: {reason}" ); assert!( - normalized_trigger_sql(&db_path, "session_refresh_progress_insert_guard_v1") - .await - .contains("new.committed_records=receipt.committed_item_count"), - "migration must install the v4 refresh accounting guard before commit" + reason.contains("published v3"), + "unexpected reason: {reason}" + ); + assert_eq!(temporal_schema_version(&db_path).await, 3); + assert_eq!( + schema_object_sql(&db_path, "table", "session_temporal_projection_receipts").await, + before_sql, + "typed refusal must not add v4 batch-count columns" ); assert!( - schema_object_exists( - &db_path, - "trigger", - "session_temporal_projection_receipts_immutable_update_v1" - ) - .await, - "migration must restore projection receipt immutability before commit" + !persisted_column_names(&db_path, "session_temporal_projection_receipts") + .await + .iter() + .any(|column| column == "batch_item_count") ); } /// A profile written by a released v3 binary carries the authority triggers -/// that release published, not the bodies the tip contracts. Triggers are -/// derived objects holding no data, so admission must classify the store as the -/// shipped v3 shape and the migration must replace them — a reset would destroy -/// the operator's sessions over a trigger body. +/// that release published, not the bodies the tip contracts. That shape is not +/// the final contract, so admission returns `ResetRequired` before rewriting +/// triggers or session rows. The operator resets explicitly; bytes stay for +/// inspection. #[tokio::test] -async fn published_v3_authority_triggers_migrate_and_retain_every_session() { +async fn published_v3_authority_triggers_are_refused_without_rewriting_sessions() { let tmp = TempDir::new().unwrap(); let fresh_path = tmp.path().join(".tracedecay").join("fresh.db"); let db = open_global_db(&fresh_path) @@ -703,61 +682,53 @@ async fn published_v3_authority_triggers_migrate_and_retain_every_session() { "the fixture must present the published bodies, not the current contract" ); - let reopened = open_global_db(&db_path) - .await - .expect("a store carrying the published v3 triggers must migrate, not reset"); - drop(reopened); - - // Admission's own `authority_invariant_triggers_intact` gate runs inside - // the migration transaction, so a successful open already proves all - // eighty-one triggers converged; these read back the three that drifted. - assert_eq!(temporal_schema_version(&db_path).await, 4); - for (trigger, current) in RELEASED_V3_DRIFTED_TRIGGERS.iter().zip(¤t_drifted) { + let before_catalog = temporal_schema_object_catalog(&db_path).await; + let error = match open_global_db(&db_path).await { + Ok(_) => panic!("a store carrying the published v3 triggers must reset, not migrate"), + Err(error) => error, + }; + let (authority, reason) = error + .reset_required_context() + .expect("published v3 triggers must return typed reset-required"); + assert_eq!(authority, "session temporal"); + assert!( + reason.contains("no sanctioned conversion"), + "unexpected reason: {reason}" + ); + assert_eq!(temporal_schema_version(&db_path).await, 3); + for (trigger, published) in RELEASED_V3_DRIFTED_TRIGGERS.iter().zip(&published_drifted) { assert_eq!( &normalized_trigger_sql(&db_path, trigger).await, - current, - "the migration must leave '{trigger}' at the current contract" + published, + "refusal must leave '{trigger}' at the published body" ); } + assert!( + published_drifted + .iter() + .zip(¤t_drifted) + .all(|(published, current)| published != current), + "refusal must not rewrite published trigger bodies onto the current contract" + ); assert_eq!( temporal_schema_object_catalog(&db_path).await, - fresh_catalog, - "a migrated store must carry exactly the fresh store's temporal objects" + before_catalog, + "typed refusal must not rewrite the published temporal catalog" ); assert_eq!( retained_sessions_and_messages(&db_path).await, retained, "every retained session and message row must survive byte-exact" ); - assert_eq!( - projection_receipt_progress_counts(&db_path).await, - [(0, 4, 9, 1), (1, 3, 12, 2)] - ); - - let restart_path = tmp.path().join(".tracedecay").join("restart.db"); - copy_database_for_temporal_restart(&db_path, &restart_path).await; - let reopened = open_global_db(&restart_path) - .await - .expect("a migrated store must reopen as exactly current"); - drop(reopened); - assert_eq!(temporal_schema_version(&restart_path).await, 4); - assert_eq!( - temporal_schema_object_catalog(&restart_path).await, + assert_ne!( + temporal_schema_object_catalog(&db_path).await, fresh_catalog, - "the second open must be a no-op on the schema" - ); - assert_eq!( - retained_sessions_and_messages(&restart_path).await, - retained - ); - assert_eq!( - projection_receipt_progress_counts(&restart_path).await, - [(0, 4, 9, 1), (1, 3, 12, 2)] + "refusing v3 must not install the fresh temporal catalog" ); } #[tokio::test] -async fn v4_receipts_without_recovery_columns_migrate_in_place_and_reopen() { +async fn v4_receipts_without_recovery_columns_are_refused_without_conversion() { let tmp = TempDir::new().unwrap(); let fresh_path = tmp.path().join(".tracedecay").join("fresh.db"); let db = open_global_db(&fresh_path) @@ -776,34 +747,37 @@ async fn v4_receipts_without_recovery_columns_migrate_in_place_and_reopen() { ); assert_eq!(temporal_schema_version(&db_path).await, 4); - let reopened = open_global_db(&db_path) - .await - .expect("the exact pre-recovery v4 receipt shape should migrate in place"); - drop(reopened); - + let before_catalog = temporal_schema_object_catalog(&db_path).await; + let error = match open_global_db(&db_path).await { + Ok(_) => panic!("the unreleased pre-recovery v4 receipt shape must not be converted"), + Err(error) => error, + }; + let (authority, reason) = error + .reset_required_context() + .expect("pre-recovery v4 must return typed reset-required"); + assert_eq!(authority, "session temporal"); + assert!( + reason.contains("no sanctioned conversion"), + "unexpected reason: {reason}" + ); + assert!( + reason.contains("pre-recovery"), + "unexpected reason: {reason}" + ); assert_eq!(temporal_schema_version(&db_path).await, 4); assert_eq!( persisted_column_names(&db_path, "session_relation_receipts").await, - [ - "session_id", - "generation", - "scope_kind", - "scope_id", - "expected_graph_watermark", - "state", - "graph_watermark", - "created_at", - "applied_at", - "recovery_state", - "recovery_failure_code", - "recovery_failure_count", - "recovery_next_attempt_at", - ] + SESSION_RELATION_RECEIPT_COLUMNS_WITHOUT_RECOVERY ); - assert_eq!( + assert_ne!( temporal_schema_object_catalog(&db_path).await, fresh_catalog, - "a migrated store must carry exactly the fresh store's temporal objects" + "refusing the pre-recovery shape must not install recovery columns" + ); + assert_eq!( + temporal_schema_object_catalog(&db_path).await, + before_catalog, + "typed refusal must leave the pre-recovery catalog unchanged" ); assert_eq!( retained_relation_receipts(&db_path).await, @@ -813,55 +787,14 @@ async fn v4_receipts_without_recovery_columns_migrate_in_place_and_reopen() { row_count(&db_path, "session_relation_effect_journal").await, 1 ); - - let raw_db = TestConnection::open(&db_path); - let conn = (*raw_db).clone(); - let mut rows = conn - .query( - "SELECT generation, recovery_state, recovery_failure_code, - recovery_failure_count, recovery_next_attempt_at - FROM session_relation_receipts ORDER BY generation", - (), + assert!( + !schema_object_exists( + &db_path, + "index", + "idx_session_relation_receipts_recovery_due" ) - .await - .unwrap(); - let mut recovery = Vec::new(); - while let Some(row) = rows.next().await.unwrap() { - recovery.push(( - row.get::(0).unwrap(), - row.get::(1).unwrap(), - row.get::>(2).unwrap(), - row.get::(3).unwrap(), - row.get::(4).unwrap(), - )); - } - drop(rows); - drop(conn); - drop(raw_db); - assert_eq!( - recovery, - [ - (1, "pending".to_string(), None, 0, 0), - (2, "pending".to_string(), None, 0, 0), - ], - "retained receipts must take the contract's recovery defaults" - ); - - let migrated_catalog = temporal_schema_object_catalog(&db_path).await; - let restart_path = tmp.path().join(".tracedecay").join("restart.db"); - copy_database_for_temporal_restart(&db_path, &restart_path).await; - let reopened = open_global_db(&restart_path) - .await - .expect("a migrated store must reopen as exactly current"); - drop(reopened); - assert_eq!(temporal_schema_version(&restart_path).await, 4); - assert_eq!( - temporal_schema_object_catalog(&restart_path).await, - migrated_catalog - ); - assert_eq!( - retained_relation_receipts(&restart_path).await, - expected_retained_relation_receipts() + .await, + "refusal must not add the recovery index" ); } @@ -1126,7 +1059,10 @@ async fn valid_watermarks_unbound_released_v3_receipts_refuse_duplicate_batch_se }; let (authority, reason) = error.reset_required_context().unwrap(); assert_eq!(authority, "session temporal"); - assert!(reason.contains("unbound or ambiguous")); + assert!( + reason.contains("no sanctioned conversion"), + "unexpected reason: {reason}" + ); assert_eq!(temporal_schema_version(&db_path).await, 3); assert!( !persisted_column_names(&db_path, "session_temporal_projection_receipts") @@ -1167,7 +1103,10 @@ async fn ambiguous_released_v3_refresh_progress_rolls_back_without_batch_counts( }; let (authority, reason) = error.reset_required_context().unwrap(); assert_eq!(authority, "session temporal"); - assert!(reason.contains("unbound or ambiguous")); + assert!( + reason.contains("no sanctioned conversion"), + "unexpected reason: {reason}" + ); assert_eq!(temporal_schema_version(&db_path).await, 3); assert!( !persisted_column_names(&db_path, "session_temporal_projection_receipts") @@ -1197,7 +1136,7 @@ async fn non_monotonic_released_v3_receipts_roll_back_the_v4_migration() { .expect("invalid released-v3 progress must return typed reset-required"); assert_eq!(authority, "session temporal"); assert!( - reason.contains("non-monotonic"), + reason.contains("no sanctioned conversion"), "unexpected reason: {reason}" ); assert_eq!(temporal_schema_version(&db_path).await, 3); From 71bfb5a694c12bfb2d224bf4853b882199bda003 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 02:04:26 +0000 Subject: [PATCH 3/3] fix(code-index): compare pointer digest before memo reuse Equal length and mtime are not the incumbent pointer. A same-size restore inside one timestamp quantum was refused as CompareAndSwap. Co-authored-by: Zack Jackson --- .../code_index_scheduler/publication_store.rs | 19 +++++------------ .../tests/publication_store.rs | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index f983b22e3e..8dce2094e0 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -418,7 +418,11 @@ impl HeldActiveDecodeV1 { } } -/// Last validated publication pointer, reused when the on-disk file is unchanged. +/// Last validated publication pointer, reused when the on-disk bytes are unchanged. +/// +/// Equal length and mtime are not identity. A same-size rewrite inside one +/// timestamp quantum must still be compared by digest before a compare-and-swap +/// treats the memo as the incumbent pointer. struct PublicationPointerMemoV1 { mtime: Option, size: u64, @@ -1086,19 +1090,6 @@ impl DaemonCodeIndexPublicationStoreV1 { } let mtime = metadata.modified().ok(); let size = metadata.len(); - { - let memo = self - .pointer_memo - .lock() - .unwrap_or_else(PoisonError::into_inner); - if let Some(memo) = memo.as_ref() - && memo.size == size - && memo.mtime.is_some() - && memo.mtime == mtime - { - return Ok(Some(memo.pointer.clone())); - } - } let bytes = std::fs::read(&self.active_path).map_err(Self::unavailable)?; let digest = Self::state_digest(&bytes); { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs index eb0fdb3ff4..0fa2f10897 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/publication_store.rs @@ -2510,7 +2510,28 @@ fn publication_over_an_undecodable_active_generation_refuses_a_moved_pointer() { } let mut restored = observed.clone(); + let moved_metadata = std::fs::metadata(&pointer_path).expect("moved pointer metadata"); + let preserved_mtime = moved_metadata.modified().expect("moved pointer mtime"); + let moved_len = moved_metadata.len(); write_repaired_pointer(&pointer_path, &mut restored); + filetime::set_file_mtime( + &pointer_path, + filetime::FileTime::from_system_time(preserved_mtime), + ) + .expect("preserve the moved pointer mtime on the restored identity"); + let restored_metadata = std::fs::metadata(&pointer_path).expect("restored pointer metadata"); + assert_eq!( + restored_metadata.len(), + moved_len, + "the restored identity must be invisible to a length check" + ); + assert_eq!( + restored_metadata + .modified() + .expect("restored pointer mtime"), + preserved_mtime, + "the restored identity must be invisible to an mtime check" + ); let mut admitting = publication.for_undecoded_active_rebuild(&observed); admitting .publish_atomically(&scope, None, seeded)