From dd6c1eacbfd522149a29b6f1cb672953180370ee Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 26 Sep 2026 19:17:44 +0000 Subject: [PATCH 1/3] fix(cli): type unredeemable cursors instead of unavailable --- .../tests/core_cli_suite/main.rs | 2 + .../tests/core_cli_suite/tool_cursor_test.rs | 282 ++++++++++++++++++ .../src/code_index_branch_diff.rs | 2 +- .../src/code_index_scheduler/queries.rs | 28 +- .../src/code_index_scheduler/tests/serving.rs | 4 +- .../src/result/evidence.rs | 6 + .../src/retrieval/prepared_query.rs | 33 +- .../tests/canonical_execution_equivalence.rs | 84 +++++- crates/tracedecay/src/daemon.rs | 3 + .../src/daemon/invocation_dispatch.rs | 13 + .../tracedecay/src/daemon/project_routing.rs | 9 +- 11 files changed, 443 insertions(+), 23 deletions(-) create mode 100644 crates/tracedecay-cli/tests/core_cli_suite/tool_cursor_test.rs diff --git a/crates/tracedecay-cli/tests/core_cli_suite/main.rs b/crates/tracedecay-cli/tests/core_cli_suite/main.rs index e453d472c0..77d45e54e2 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/main.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/main.rs @@ -28,6 +28,8 @@ mod source_provenance_test; mod sync_test; mod test_profile_isolation_test; #[cfg(unix)] +mod tool_cursor_test; +#[cfg(unix)] mod tool_daemon_test; mod tool_first_touch_test; #[cfg(unix)] diff --git a/crates/tracedecay-cli/tests/core_cli_suite/tool_cursor_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/tool_cursor_test.rs new file mode 100644 index 0000000000..091a193674 --- /dev/null +++ b/crates/tracedecay-cli/tests/core_cli_suite/tool_cursor_test.rs @@ -0,0 +1,282 @@ +//! Paging a relation with `tracedecay tool`: every page is a separate CLI +//! process, so a `next_cursor` must be redeemable by a later invocation, and +//! a cursor presented where it cannot be served must say why instead of +//! answering a retryable `unavailable`. + +use std::collections::BTreeSet; +use std::path::Path; +use std::time::{Duration, Instant}; + +use crate::common::{ + canonical_existing_path, git_program, initialize_tracedecay_cli_project, stop_managed_daemon, + tracedecay_command_with_home, +}; +use serde_json::{Value, json}; +use tempfile::TempDir; + +const LEAF_COUNT: usize = 25; +/// Indexing after `init` is asynchronous; this only bounds a hang. +const INDEX_READY_TIMEOUT: Duration = Duration::from_secs(90); + +fn git(project: &Path, args: &[&str]) { + let output = std::process::Command::new(git_program()) + .args(args) + .current_dir(project) + .output() + .unwrap_or_else(|error| panic!("git {args:?} should run: {error}")); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn committed_git_project(project: &Path, source: &str) { + std::fs::create_dir_all(project.join("src")).unwrap(); + std::fs::write(project.join("src/lib.rs"), source).unwrap(); + git(project, &["init", "--initial-branch=master"]); + git(project, &["config", "user.email", "cursor@example.com"]); + git(project, &["config", "user.name", "Cursor Test"]); + git(project, &["add", "."]); + git(project, &["commit", "-m", "initial"]); +} + +/// `hub` calls `leaf_01` .. `leaf_25`: three pages at the CLI page size. +fn hub_source() -> String { + let calls: String = (1..=LEAF_COUNT) + .map(|leaf| format!(" leaf_{leaf:02}();\n")) + .collect(); + let leaves: String = (1..=LEAF_COUNT) + .map(|leaf| format!("pub fn leaf_{leaf:02}() {{}}\n")) + .collect(); + format!("pub fn hub() {{\n{calls}}}\n{leaves}") +} + +struct ToolRun { + success: bool, + stdout: String, + stderr: String, +} + +fn run_tool(home: &Path, cwd: &Path, name: &str, args: &Value) -> ToolRun { + let output = tracedecay_command_with_home(home) + .current_dir(cwd) + .args(["tool", name, "--json", "--args", &args.to_string()]) + .output() + .expect("tracedecay tool should run"); + ToolRun { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + } +} + +fn body_of(name: &str, run: &ToolRun) -> Value { + let printed: Value = serde_json::from_str(&run.stdout).unwrap_or_else(|error| { + panic!( + "{name} printed non-JSON ({error}):\n{}\nstderr:\n{}", + run.stdout, run.stderr + ) + }); + match printed["content"][0]["text"].as_str() { + Some(text) => serde_json::from_str(text).expect("tool text JSON"), + None => printed, + } +} + +/// One `tracedecay tool --json` process from `cwd`; returns whether +/// the process succeeded and the tool's JSON body. +fn tool(home: &Path, cwd: &Path, name: &str, args: &Value) -> (bool, Value) { + let run = run_tool(home, cwd, name, args); + (run.success, body_of(name, &run)) +} + +fn hub_node_id(home: &Path, project: &Path) -> String { + let started = Instant::now(); + loop { + let run = run_tool( + home, + project, + "find_exact_symbol", + &json!({"name": "hub", "format": "json"}), + ); + // Until the first graph publishes, the lookup refuses with an empty stdout. + if run.success + && let Some(id) = body_of("find_exact_symbol", &run)["matches"][0]["id"].as_str() + { + return id.to_owned(); + } + assert!( + started.elapsed() < INDEX_READY_TIMEOUT, + "hub never indexed:\n{}\n{}", + run.stdout, + run.stderr + ); + std::thread::sleep(Duration::from_millis(250)); + } +} + +fn callees_args(node_id: &str, cursor: Option<&str>) -> Value { + let mut meta = json!({"projection": "evidence", "order": "source_position"}); + if let Some(cursor) = cursor { + meta["cursor"] = json!(cursor); + } + json!({"node_id": node_id, "maximum_depth": 1, "meta": meta}) +} + +fn page_names(body: &Value) -> Vec { + body["outcome"]["value"]["payload"]["items"] + .as_array() + .unwrap_or_else(|| panic!("callees page has no items: {body}")) + .iter() + .map(|item| item["symbol"]["name"].as_str().unwrap().to_owned()) + .collect() +} + +fn next_cursor(body: &Value) -> Option { + body["outcome"]["value"]["payload"]["next_cursor"] + .as_str() + .map(str::to_owned) +} + +#[test] +fn callees_cursors_page_to_the_end_across_tool_processes() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let home = canonical_existing_path(home.path()); + let project = canonical_existing_path(project.path()); + committed_git_project(&project, &hub_source()); + initialize_tracedecay_cli_project(&home, &project); + let hub = hub_node_id(&home, &project); + + let mut page_sizes = Vec::new(); + let mut names = BTreeSet::new(); + let mut cursor = None; + loop { + let (ok, body) = tool( + &home, + &project, + "tracedecay_callees", + &callees_args(&hub, cursor.as_deref()), + ); + assert!(ok, "page {} failed: {body}", page_sizes.len() + 1); + assert_eq!(body["outcome"]["value"]["payload"]["total"], 25, "{body}"); + let page = page_names(&body); + page_sizes.push(page.len()); + names.extend(page); + cursor = next_cursor(&body); + if cursor.is_none() { + break; + } + } + + assert_eq!(page_sizes, [10, 10, 5]); + let expected: BTreeSet = (1..=LEAF_COUNT) + .map(|leaf| format!("leaf_{leaf:02}")) + .collect(); + assert_eq!(names, expected); +} + +#[test] +fn a_cursor_presented_where_it_cannot_be_served_is_typed() { + let home = TempDir::new().unwrap(); + let other_home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let other_project = TempDir::new().unwrap(); + let unenrolled = TempDir::new().unwrap(); + let no_repository = TempDir::new().unwrap(); + let home = canonical_existing_path(home.path()); + let other_home = canonical_existing_path(other_home.path()); + let project = canonical_existing_path(project.path()); + let other_project = canonical_existing_path(other_project.path()); + let unenrolled = canonical_existing_path(unenrolled.path()); + let no_repository = canonical_existing_path(no_repository.path()); + committed_git_project(&project, &hub_source()); + committed_git_project( + &other_project, + "pub fn hub() { leaf(); }\npub fn leaf() {}\n", + ); + committed_git_project(&unenrolled, "pub fn unenrolled() {}\n"); + // Enrolled by another profile: the checkout carries a TraceDecay identity + // marker, so this profile's CLI routes to it, but this profile's daemon + // never enrolled it. + initialize_tracedecay_cli_project(&other_home, &unenrolled); + stop_managed_daemon(&other_home); + initialize_tracedecay_cli_project(&home, &project); + initialize_tracedecay_cli_project(&home, &other_project); + let hub = hub_node_id(&home, &project); + hub_node_id(&home, &other_project); + let (ok, first) = tool( + &home, + &project, + "tracedecay_callees", + &callees_args(&hub, None), + ); + assert!(ok, "{first}"); + let cursor = next_cursor(&first).expect("first page continues"); + + // Another enrolled project cannot serve this project's cursor. + let (_, foreign) = tool( + &home, + &other_project, + "tracedecay_callees", + &callees_args(&hub, Some(&cursor)), + ); + let value = &foreign["outcome"]["value"]; + assert_eq!( + value["omissions"], + json!([{"domain": "symbol", "count": 0, "reason": "cursor_foreign"}]), + "{foreign}" + ); + assert_eq!(value["execution"]["termination"], "failed", "{foreign}"); + + // A checkout this profile never enrolled has no project to redeem in. + let (ok, outside) = tool( + &home, + &unenrolled, + "tracedecay_callees", + &callees_args(&hub, Some(&cursor)), + ); + assert!(!ok, "{outside}"); + assert_eq!(outside["problem"]["kind"], "invalid_request", "{outside}"); + assert_eq!( + outside["problem"]["code"], "project_not_enrolled", + "{outside}" + ); + assert_eq!(outside["problem"]["retryable"], false, "{outside}"); + assert_eq!( + outside["problem"]["legal_actions"], + json!(["correct_request"]), + "{outside}" + ); + + // A directory outside any repository names no project at all. + let (ok, projectless) = tool( + &home, + &no_repository, + "tracedecay_callees", + &callees_args(&hub, Some(&cursor)), + ); + assert!(!ok, "{projectless}"); + assert_eq!( + projectless["problem"]["code"], "project_required", + "{projectless}" + ); + assert_eq!(projectless["problem"]["retryable"], false, "{projectless}"); + + // The same cursor still pages where it was issued. + let (ok, second) = tool( + &home, + &project, + "tracedecay_callees", + &callees_args(&hub, Some(&cursor)), + ); + assert!(ok, "{second}"); + assert_eq!(page_names(&second).len(), 10, "{second}"); + assert!( + page_names(&first) + .iter() + .all(|name| !page_names(&second).contains(name)), + "page two repeated page one: {first} / {second}" + ); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_branch_diff.rs b/crates/tracedecay-code-index-runtime/src/code_index_branch_diff.rs index 6880bdee1b..e90da011fd 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_branch_diff.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_branch_diff.rs @@ -364,7 +364,7 @@ fn prepared_error_reason( error: PreparedQueryErrorV1, ) -> code_search::CodeIndexSearchUnavailableReasonV1 { match error { - PreparedQueryErrorV1::Invalid => { + PreparedQueryErrorV1::Invalid | PreparedQueryErrorV1::Foreign => { code_search::CodeIndexSearchUnavailableReasonV1::InvalidRequest } PreparedQueryErrorV1::Stale => { diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs index 199fa59780..9f79ba4690 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/queries.rs @@ -348,7 +348,9 @@ impl CodeIndexSchedulerRegistryV1 { )? .await .map_err(|_| CallableCodeCursorError::Unavailable)? - .ok_or(CallableCodeCursorError::Unavailable) + // The generation this authenticated cursor pages is no + // longer held, so no later retry can serve it either. + .ok_or(CallableCodeCursorError::Stale) } else if is_unpinned_latest(requested) { self.latest_complete_fresh_for_scope(request.scope()) .await @@ -411,7 +413,7 @@ impl CodeIndexSchedulerRegistryV1 { )? .await .map_err(|_| CallableCodeCursorError::Unavailable)? - .ok_or(CallableCodeCursorError::Unavailable) + .ok_or(CallableCodeCursorError::Stale) } else if is_unpinned_latest(requested) { self.latest_text_fresh_for_scope(request.scope()) .await @@ -484,7 +486,7 @@ impl CodeIndexSchedulerRegistryV1 { )? .await .map_err(|_| CallableCodeCursorError::Unavailable)? - .ok_or(CallableCodeCursorError::Unavailable) + .ok_or(CallableCodeCursorError::Stale) } else if is_unpinned_latest(requested) { self.retained_text_owner_freshness_for_scope(request.scope()) .await @@ -719,16 +721,24 @@ fn rejected_cursor( if !is_unpinned_latest(&generation) { evidence.temporal.source_generation = Some(generation); } + let reason = match error { + CallableCodeCursorError::Stale => OmissionReason::CursorExpired, + CallableCodeCursorError::Foreign => OmissionReason::CursorForeign, + CallableCodeCursorError::Invalid => OmissionReason::Failed, + CallableCodeCursorError::Unavailable => OmissionReason::Unavailable, + }; evidence.omissions.push(Omission { domain: EvidenceDomain::Symbol, count: 0, - reason: match error { - CallableCodeCursorError::Stale => OmissionReason::Stale, - CallableCodeCursorError::Invalid => OmissionReason::Failed, - CallableCodeCursorError::Unavailable => OmissionReason::Unavailable, - }, + reason, }); - RetrievalPortOutcome::Unavailable(evidence) + // Only an unavailable authority is worth retrying; a rejected cursor + // fails the same way every time it is presented. + if reason == OmissionReason::Unavailable { + RetrievalPortOutcome::Unavailable(evidence) + } else { + RetrievalPortOutcome::Failed(evidence) + } } fn bounded_result( diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/serving.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/serving.rs index 9107639b23..4a3f100fa6 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/serving.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/serving.rs @@ -5476,8 +5476,8 @@ async fn unpinned_cursor_continues_on_its_immutable_generation() { &tampered_request, ) .await; - let RetrievalPortOutcome::Unavailable(tampered_evidence) = tampered_outcome else { - panic!("tampered cursor must be rejected"); + let RetrievalPortOutcome::Failed(tampered_evidence) = tampered_outcome else { + panic!("tampered cursor must be rejected as a failed request"); }; assert_eq!( tampered_evidence.omissions[0].reason, diff --git a/crates/tracedecay-contracts/src/result/evidence.rs b/crates/tracedecay-contracts/src/result/evidence.rs index 1ccd09c0f9..ae7955827c 100644 --- a/crates/tracedecay-contracts/src/result/evidence.rs +++ b/crates/tracedecay-contracts/src/result/evidence.rs @@ -348,6 +348,12 @@ pub enum OmissionReason { /// Evidence came from a macro body that was not expanded, so what the /// expansion defines or calls is not covered. MacroBodyUnparsed, + /// The continuation cursor can no longer be redeemed (its lifetime ended + /// or the snapshot it pages is gone); restart the request without it. + CursorExpired, + /// The continuation cursor was issued for another project, worktree, or + /// ref; redeem it where it was issued. + CursorForeign, } #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] diff --git a/crates/tracedecay-query/src/retrieval/prepared_query.rs b/crates/tracedecay-query/src/retrieval/prepared_query.rs index 6007a7d2d3..b1b589fb19 100644 --- a/crates/tracedecay-query/src/retrieval/prepared_query.rs +++ b/crates/tracedecay-query/src/retrieval/prepared_query.rs @@ -27,8 +27,12 @@ const PREPARED_QUERY_CURSOR_TTL_MICROS_V1: i64 = 15 * 60 * 1_000_000; pub enum PreparedQueryErrorV1 { #[error("prepared query cursor is invalid")] Invalid, + /// Expired, or its key or generation is no longer held: restart without it. #[error("prepared query cursor is stale")] Stale, + /// Issued for another scope (project, repository, worktree, or ref). + #[error("prepared query cursor was issued for another scope")] + Foreign, #[error("prepared query authority is unavailable")] Unavailable, } @@ -224,6 +228,9 @@ impl PreparedQueryV1 { canonical_sha256(&candidates).map_err(|_| PreparedQueryErrorV1::Unavailable)?; let start = match &self.cursor { Some(cursor) => { + if cursor.payload.scope_digest != bindings.scope_digest { + return Err(PreparedQueryErrorV1::Foreign); + } require_unexpired(cursor, now)?; if cursor.payload.generation != bindings.generation || cursor.payload.candidate_set_digest != candidate_set_digest @@ -231,7 +238,6 @@ impl PreparedQueryV1 { return Err(PreparedQueryErrorV1::Stale); } if cursor.payload.operation != bindings.operation - || cursor.payload.scope_digest != bindings.scope_digest || cursor.payload.query_binding_digest != bindings.query_binding_digest || cursor.payload.page_size != page_size { @@ -302,6 +308,11 @@ pub fn authenticate_prepared_query_cursor_for_routing( now: UtcMicros, ) -> Result { let cursor = decode_cursor(encoded)?; + // Unauthenticated at this point, but it only selects which rejection a + // cursor from another scope receives; nothing is served from it. + if cursor.payload.scope_digest != bindings.scope_digest { + return Err(PreparedQueryErrorV1::Foreign); + } let request = routing_request( &cursor.payload.request_binding, &cursor.authentication, @@ -316,10 +327,9 @@ pub fn authenticate_prepared_query_cursor_for_routing( &cursor_authentication_payload_bytes(&cursor.payload)?, &cursor.authentication, ) - .map_err(map_authority_error)?; + .map_err(map_verification_error)?; require_unexpired(&cursor, now)?; if cursor.payload.operation != bindings.operation - || cursor.payload.scope_digest != bindings.scope_digest || cursor.payload.query_binding_digest != bindings.query_binding_digest || cursor.payload.page_size != bindings.page_size { @@ -363,7 +373,7 @@ fn authenticate_cursor( &cursor_authentication_payload_bytes(&cursor.payload)?, &cursor.authentication, ) - .map_err(map_authority_error)?; + .map_err(map_verification_error)?; if cursor.payload.request_binding != PreparedQueryRequestBindingV1::from_request(request) { return Err(PreparedQueryErrorV1::Invalid); } @@ -408,6 +418,21 @@ fn map_authority_error(error: QueryAuthorityErrorV1) -> PreparedQueryErrorV1 { } } +/// Redeeming a cursor never waits on key availability: a key this authority +/// does not hold is one it will not hold later, and another privacy domain's +/// cursor belongs to another scope. +fn map_verification_error(error: QueryAuthorityErrorV1) -> PreparedQueryErrorV1 { + match error { + QueryAuthorityErrorV1::QueryAuthentication( + QueryDigestAuthenticationError::KeyUnavailable, + ) => PreparedQueryErrorV1::Stale, + QueryAuthorityErrorV1::QueryAuthentication( + QueryDigestAuthenticationError::PrivacyDomainMismatch, + ) => PreparedQueryErrorV1::Foreign, + error => map_authority_error(error), + } +} + fn cursor_authentication_payload_bytes( payload: &PreparedQueryCursorPayloadV1, ) -> Result, PreparedQueryErrorV1> { diff --git a/crates/tracedecay-query/tests/canonical_execution_equivalence.rs b/crates/tracedecay-query/tests/canonical_execution_equivalence.rs index e25f0d7f32..6cbf120e5e 100644 --- a/crates/tracedecay-query/tests/canonical_execution_equivalence.rs +++ b/crates/tracedecay-query/tests/canonical_execution_equivalence.rs @@ -21,9 +21,9 @@ use tracedecay_query::retrieval::ports::{CodeCandidateBindingV1, CodeOccurrenceR use tracedecay_query::retrieval::{ AdmittedGenerationContextV1, NativeCodeOccurrenceV1, NativeExactRecordV1, NativeGraphRecordV1, NativeLaneOutcomeV1, NativeLanePageV1, NativeLexicalRecordV1, NativeRecordReadPortV1, - NativeSymbolRecordV1, PreparedQueryBindingsV1, PreparedQueryRoutingBindingsV1, PreparedQueryV1, - QUERY_RANKING_REVISION_V1, QueryAuthorityV1, authenticate_prepared_query_cursor_for_routing, - route_authenticated_prepared_query_cursor, + NativeSymbolRecordV1, PreparedQueryBindingsV1, PreparedQueryErrorV1, + PreparedQueryRoutingBindingsV1, PreparedQueryV1, QUERY_RANKING_REVISION_V1, QueryAuthorityV1, + authenticate_prepared_query_cursor_for_routing, route_authenticated_prepared_query_cursor, }; use tracedecay_domain::test_fixtures::id; @@ -425,6 +425,10 @@ fn query_authority() -> Arc { } fn query_authority_with_secret(secret: u8) -> Arc { + query_authority_with_key("cursor-key.canonical-equivalence.v1", secret) +} + +fn query_authority_with_key(key_id: &str, secret: u8) -> Arc { let evaluation = RetrievalAnchorId::new("evaluation.canonical-equivalence") .expect("valid evaluation anchor"); let calibrations = RetrieverKind::QUERY_FALLBACK_LANES @@ -487,7 +491,7 @@ fn query_authority_with_secret(secret: u8) -> Arc { }; let keyring = RetrievalCursorKeyringV1::new( id("privacy.canonical-equivalence"), - id::("cursor-key.canonical-equivalence.v1"), + id::(key_id), 7, vec![secret; 32], 15 * 60 * 1_000_000, @@ -674,3 +678,75 @@ fn equivalent_prepared_queries_emit_identical_stable_cursor_bytes() { UtcMicros(900_000_010) ); } + +#[test] +fn unredeemable_prepared_cursors_reject_with_their_typed_state() { + let authority = query_authority(); + let request = retrieval_request(); + let bindings = PreparedQueryBindingsV1::new( + "code_canonical_query", + digest::('8'), + generation(), + digest::('9'), + ) + .expect("valid prepared-query bindings"); + let cursor = PreparedQueryV1::prepare(authority.clone(), request.clone(), None) + .expect("prepared query") + .paginate(&bindings, vec!["first", "second"], 1, UtcMicros(10)) + .expect("first page") + .next_cursor + .expect("continuation cursor"); + let routing = PreparedQueryRoutingBindingsV1 { + operation: "code_canonical_query".to_owned(), + scope_digest: digest::('8'), + principal: request.principal.clone(), + root: request.scope.root.clone(), + temporal_mode: request.temporal_mode, + query_binding_digest: digest::('9'), + page_size: 1, + authorization_revision: request.snapshot.authorization_revision.clone(), + }; + let route = |authority: &QueryAuthorityV1, routing: &PreparedQueryRoutingBindingsV1, now| { + authenticate_prepared_query_cursor_for_routing(authority, routing, &cursor, now) + .map(|routed| routed.generation) + }; + + assert_eq!( + route(&authority, &routing, UtcMicros(100)), + Ok(generation()) + ); + let mut other_scope = routing.clone(); + other_scope.scope_digest = digest::('7'); + assert_eq!( + route(&authority, &other_scope, UtcMicros(100)), + Err(PreparedQueryErrorV1::Foreign) + ); + assert_eq!( + route(&authority, &routing, UtcMicros(900_000_010)), + Err(PreparedQueryErrorV1::Stale) + ); + let rekeyed = query_authority_with_key("cursor-key.canonical-equivalence.v2", 0x5a); + assert_eq!( + route(&rekeyed, &routing, UtcMicros(100)), + Err(PreparedQueryErrorV1::Stale) + ); + + let other_scope_bindings = PreparedQueryBindingsV1::new( + "code_canonical_query", + digest::('7'), + generation(), + digest::('9'), + ) + .expect("valid prepared-query bindings"); + assert_eq!( + PreparedQueryV1::prepare(authority, request, Some(&cursor)) + .expect("authenticated continuation") + .paginate( + &other_scope_bindings, + vec!["first", "second"], + 1, + UtcMicros(100) + ), + Err(PreparedQueryErrorV1::Foreign) + ); +} diff --git a/crates/tracedecay/src/daemon.rs b/crates/tracedecay/src/daemon.rs index 595473de04..2c2fb710ac 100644 --- a/crates/tracedecay/src/daemon.rs +++ b/crates/tracedecay/src/daemon.rs @@ -75,6 +75,9 @@ pub const PROJECT_SERVER_CAPACITY_REASON_CODE: &str = "project_server_capacity_r /// still answer, and every `tools/call` re-derives this refusal until /// enrollment succeeds. pub const PROJECT_NOT_ENROLLED_REASON_CODE: &str = "project_not_enrolled"; +/// Typed reason a project-scoped request arrived on a handshake that names no +/// project at all (no `--project`, and no enrolled project above the cwd). +pub const PROJECT_REQUIRED_REASON_CODE: &str = "project_required"; #[cfg(unix)] const TOOL_LIST_CHANGED_METHOD: &str = "notifications/tools/list_changed"; #[cfg(unix)] diff --git a/crates/tracedecay/src/daemon/invocation_dispatch.rs b/crates/tracedecay/src/daemon/invocation_dispatch.rs index 2a7a85b306..9acc4bcab5 100644 --- a/crates/tracedecay/src/daemon/invocation_dispatch.rs +++ b/crates/tracedecay/src/daemon/invocation_dispatch.rs @@ -888,6 +888,19 @@ fn project_open_refusal_response( tracedecay_contracts::ApplicationProblem::runtime_mounting(), ); } + // No route names an enrolled project: the caller must pick one, so a + // retryable `unavailable` would only be retried until its deadline. + if let Some(( + reason_code @ (PROJECT_NOT_ENROLLED_REASON_CODE | PROJECT_REQUIRED_REASON_CODE), + false, + detail, + )) = error.project_route_context() + { + return DaemonInvocationResponse::application_problem( + request_id, + tracedecay_contracts::ApplicationProblem::invalid_request(reason_code, detail), + ); + } DaemonInvocationResponse::problem( request_id, project_open_problem(error, workflow_application, git_operation), diff --git a/crates/tracedecay/src/daemon/project_routing.rs b/crates/tracedecay/src/daemon/project_routing.rs index 8bbd06178e..68ace04f11 100644 --- a/crates/tracedecay/src/daemon/project_routing.rs +++ b/crates/tracedecay/src/daemon/project_routing.rs @@ -74,9 +74,12 @@ pub(super) fn project_route_for_handshake( handshake: &DaemonHandshake, ) -> Result<(PathBuf, ProjectRouteKey)> { let Some(project_path) = handshake.project_path.as_ref() else { - return Err(TraceDecayError::Config { - message: "project server requested without project_path".to_string(), - }); + return Err(TraceDecayError::project_route( + PROJECT_REQUIRED_REASON_CODE, + false, + "this operation needs a TraceDecay project, and the request named none; \ + run it inside an initialized project or pass --project ", + )); }; let canonical_project_path = tracedecay_runtime_core::path_safety::canonical_root_identity(project_path); From a3f4246169171a27469f640b633462bfecdaffaf Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 26 Sep 2026 20:06:15 +0000 Subject: [PATCH 2/3] test(daemon): expect the typed projectless refusal --- crates/tracedecay/src/daemon/tests/socket.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay/src/daemon/tests/socket.rs b/crates/tracedecay/src/daemon/tests/socket.rs index adb042b6de..1707521372 100644 --- a/crates/tracedecay/src/daemon/tests/socket.rs +++ b/crates/tracedecay/src/daemon/tests/socket.rs @@ -1044,8 +1044,14 @@ async fn socket_client_routes_multiple_closed_invocations_without_falling_back_t let response: serde_json::Value = serde_json::from_str(&line).expect("response json"); assert_eq!(response["protocol"], "tracedecay.daemon.invocation"); assert_eq!(response["request_id"], request_id); - assert_eq!(response["status"], "problem"); - assert_eq!(response["problem"], "unavailable"); + // The handshake names no project, so the project-scoped read is a + // terminal refusal the client must correct, not a retryable outage. + assert_eq!(response["status"], "application_problem", "{response:#}"); + assert_eq!(response["problem"]["kind"], "invalid_request"); + assert_eq!( + response["problem"]["diagnostic"]["code"], + "project_required" + ); assert!(response.get("jsonrpc").is_none()); } @@ -1468,8 +1474,14 @@ async fn portable_broker_routes_multiple_closed_invocations_without_falling_back let response: serde_json::Value = serde_json::from_str(&line).expect("response json"); assert_eq!(response["protocol"], "tracedecay.daemon.invocation"); assert_eq!(response["request_id"], request_id); - assert_eq!(response["status"], "problem"); - assert_eq!(response["problem"], "unavailable"); + // The handshake names no project, so the project-scoped read is a + // terminal refusal the client must correct, not a retryable outage. + assert_eq!(response["status"], "application_problem", "{response:#}"); + assert_eq!(response["problem"]["kind"], "invalid_request"); + assert_eq!( + response["problem"]["diagnostic"]["code"], + "project_required" + ); assert!(response.get("jsonrpc").is_none()); } writer.shutdown().await.expect("shutdown writer"); From 561ac2736b3817904be4a41ff4ca7212fab80864 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 26 Sep 2026 20:09:17 +0000 Subject: [PATCH 3/3] chore(sdk): regenerate operations for cursor omission reasons --- sdks/typescript/src/operations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdks/typescript/src/operations.ts b/sdks/typescript/src/operations.ts index 1fccdcf39b..c9d1921752 100644 --- a/sdks/typescript/src/operations.ts +++ b/sdks/typescript/src/operations.ts @@ -592,7 +592,7 @@ export type ObservationSourceIdentityV1 = { readonly provider?: ProviderId; read export type ObservatoryReadModelV1 = { readonly analytics_mode: AnalyticsModeReadModelV1; readonly authorized_scope_ref: string; readonly comparison: PerformanceComparisonReadModelV1; readonly current: boolean; readonly horizon: ObservabilityHorizonV1; readonly metrics: readonly MetricValueV1[]; readonly observed_at_micros: number; readonly rejected_arguments: RejectedArgumentAnalyticsV1; readonly watermark: string; readonly [key: string]: unknown }; export type OccurrenceProvenance = { readonly evidence_role: EvidenceRole; readonly file_occurrence_id?: FileOccurrenceId | null; readonly freshness: SourceFreshness; readonly logical_copy_cluster_id?: LogicalCopyClusterId | null; readonly logical_copy_evidence_anchor?: RetrievalAnchorId | null; readonly repository_id?: RepositoryId | null; readonly retriever_evidence_anchor: RetrievalAnchorId; readonly session_or_thread_id?: SessionOrThreadId | null; readonly source_namespace: SourceNamespace; readonly source_occurrence_id: SourceOccurrenceId }; export type Omission = { readonly count: number; readonly domain: EvidenceDomain; readonly reason: OmissionReason }; -export type OmissionReason = "budget" | "redacted" | "unavailable" | "unsupported" | "stale" | "failed" | "cancelled" | "timed_out" | "conflict" | "macro_body_unparsed"; +export type OmissionReason = "budget" | "redacted" | "unavailable" | "unsupported" | "stale" | "failed" | "cancelled" | "timed_out" | "conflict" | "macro_body_unparsed" | "cursor_expired" | "cursor_foreign"; export type OperationBudgetUsage = { readonly bytes_consumed: number; readonly elapsed_micros: number; readonly units_consumed: number }; export type OperationReceipt_2 = { readonly budget: OperationBudgetUsage; readonly cancellation?: CancellationObservation | null; readonly effective_deadline: Deadline; readonly ended_at: UtcMicros; readonly started_at: UtcMicros; readonly termination: OperationTermination_2 }; export type OperationTermination_2 = "completed" | "cancelled" | "timed_out" | "failed" | "unavailable" | "partial" | "effect_unknown"; @@ -2092,7 +2092,7 @@ const DEFINITIONS = { ObservatoryReadModelV1: {"properties":{"analytics_mode":{"$ref":"#/$defs/AnalyticsModeReadModelV1"},"authorized_scope_ref":{"type":"string"},"comparison":{"$ref":"#/$defs/PerformanceComparisonReadModelV1"},"current":{"type":"boolean"},"horizon":{"$ref":"#/$defs/ObservabilityHorizonV1"},"metrics":{"items":{"$ref":"#/$defs/MetricValueV1"},"type":"array"},"observed_at_micros":{"format":"int64","type":"integer"},"rejected_arguments":{"$ref":"#/$defs/RejectedArgumentAnalyticsV1"},"watermark":{"type":"string"}},"required":["authorized_scope_ref","horizon","watermark","observed_at_micros","current","metrics","analytics_mode","comparison","rejected_arguments"],"type":"object"}, OccurrenceProvenance: {"additionalProperties":false,"description":"Structured occurrence provenance retained through fusion. Fusion\npreserves each exact `(source_occurrence_id, retriever_evidence_anchor)`\npair; parallel unassociated provenance vectors are forbidden.","properties":{"evidence_role":{"$ref":"#/$defs/EvidenceRole"},"file_occurrence_id":{"anyOf":[{"$ref":"#/$defs/FileOccurrenceId"},{"type":"null"}]},"freshness":{"$ref":"#/$defs/SourceFreshness"},"logical_copy_cluster_id":{"anyOf":[{"$ref":"#/$defs/LogicalCopyClusterId"},{"type":"null"}]},"logical_copy_evidence_anchor":{"anyOf":[{"$ref":"#/$defs/RetrievalAnchorId"},{"type":"null"}]},"repository_id":{"anyOf":[{"$ref":"#/$defs/RepositoryId"},{"type":"null"}]},"retriever_evidence_anchor":{"$ref":"#/$defs/RetrievalAnchorId"},"session_or_thread_id":{"anyOf":[{"$ref":"#/$defs/SessionOrThreadId"},{"type":"null"}]},"source_namespace":{"$ref":"#/$defs/SourceNamespace"},"source_occurrence_id":{"$ref":"#/$defs/SourceOccurrenceId"}},"required":["source_occurrence_id","retriever_evidence_anchor","source_namespace","evidence_role","freshness"],"type":"object"}, Omission: {"additionalProperties":false,"properties":{"count":{"format":"uint64","minimum":0,"type":"integer"},"domain":{"$ref":"#/$defs/EvidenceDomain"},"reason":{"$ref":"#/$defs/OmissionReason"}},"required":["domain","count","reason"],"type":"object"}, - OmissionReason: {"description":"Safe reason why authorized requested evidence was omitted.","oneOf":[{"enum":["budget","redacted","unavailable","unsupported","stale","failed","cancelled","timed_out","conflict"],"type":"string"},{"const":"macro_body_unparsed","description":"Evidence came from a macro body that was not expanded, so what the\nexpansion defines or calls is not covered.","type":"string"}]}, + OmissionReason: {"description":"Safe reason why authorized requested evidence was omitted.","oneOf":[{"enum":["budget","redacted","unavailable","unsupported","stale","failed","cancelled","timed_out","conflict"],"type":"string"},{"const":"macro_body_unparsed","description":"Evidence came from a macro body that was not expanded, so what the\nexpansion defines or calls is not covered.","type":"string"},{"const":"cursor_expired","description":"The continuation cursor can no longer be redeemed (its lifetime ended\nor the snapshot it pages is gone); restart the request without it.","type":"string"},{"const":"cursor_foreign","description":"The continuation cursor was issued for another project, worktree, or\nref; redeem it where it was issued.","type":"string"}]}, OperationBudgetUsage: {"additionalProperties":false,"description":"Bounded work accounting supplied by an owning port or transaction.","properties":{"bytes_consumed":{"format":"uint64","minimum":0,"type":"integer"},"elapsed_micros":{"format":"uint64","minimum":0,"type":"integer"},"units_consumed":{"format":"uint64","minimum":0,"type":"integer"}},"required":["units_consumed","bytes_consumed","elapsed_micros"],"type":"object"}, OperationReceipt_2: {"additionalProperties":false,"description":"Canonical operation evidence. An admitted failure remains represented here\nrather than being replaced by a transport exception.","properties":{"budget":{"$ref":"#/$defs/OperationBudgetUsage"},"cancellation":{"anyOf":[{"$ref":"#/$defs/CancellationObservation"},{"type":"null"}]},"effective_deadline":{"$ref":"#/$defs/Deadline"},"ended_at":{"$ref":"#/$defs/UtcMicros"},"started_at":{"$ref":"#/$defs/UtcMicros"},"termination":{"$ref":"#/$defs/OperationTermination"}},"required":["started_at","ended_at","effective_deadline","budget","termination"],"type":"object"}, OperationTermination_2: {"description":"Terminal state after an operation has been admitted.","enum":["completed","cancelled","timed_out","failed","unavailable","partial","effect_unknown"],"type":"string"},