diff --git a/docs/en/docs/operate/context-protection.md b/docs/en/docs/operate/context-protection.md index 29b66ff15c..67923b8770 100644 --- a/docs/en/docs/operate/context-protection.md +++ b/docs/en/docs/operate/context-protection.md @@ -44,12 +44,18 @@ client returns either `PreparedContext` or `PreparedContextWithReuse`; narrow th In process, use `runtime.context.for_scope(scope_id).prepare(request)`. Its `validate_reuse` operation checks the retained proof without retrieval or embedding; embedded applications supply their own current authorization context. -The first reuse profile supports a single Scope and selected Atomic Memory only, without context references or code. -It records selected current revisions, lifecycle versions, and complete bounded historical dependencies. Exact +The reuse profile supports a single Scope, selected Atomic Memory, and code evidence (`include_code: true`), without +context references. For Atomic Memory it records selected current revisions, lifecycle versions, and complete bounded historical dependencies. Exact historical ancestors of a merge remain valid evidence when their owning domain permits them. Revised or forgotten roots, revoked access, changed reference policy, expired bindings, and ineligible Sources stop reuse. Other selected families are explicitly rejected for this option; ordinary preparation remains supported. +For code evidence it records each cited file's repository path and SHA-256 digest, with the index fingerprint for the +record. Validation reads only the cited files from the Scope's current worktree, with the same path and exclusion rules +as indexing. A changed, removed, or no-longer-indexable cited file, or an unavailable repository, stops reuse. An edit to +a file that was not cited, or a rebuilt index, does not. Code files and Atomic Memory dependencies share the dependency +limit. + Bindings are authenticated and encrypted, expire after five minutes, and are limited to 64 dependencies and eight ancestry levels. They do not grant access. Server bindings use the existing signing-secret material with a separate cryptographic purpose and persistent Server identity; processes sharing that deployment must share the secret. diff --git a/docs/en/docs/operate/deploy-server.md b/docs/en/docs/operate/deploy-server.md index c4a64f0fce..cc68afafff 100644 --- a/docs/en/docs/operate/deploy-server.md +++ b/docs/en/docs/operate/deploy-server.md @@ -233,6 +233,11 @@ translates its requests to the Chat or Responses route and forwards them. Set `P (`PC_GATEWAY_CORE_BASE`) injects only with `PC_GATEWAY_INJECTION=on`. An empty or failed preparation sends the request without injection. +Set `PC_GATEWAY_INJECTION_CODE=on` to include code evidence from the Scope's code index in the prepared context; it is +off by default and requires a configured repository for the Scope. When a replayed value no longer passes the check +because a cited source changed (for example, the agent edited a cited file), the gateway discards that conversation's +replay and prepares once more for the request instead of returning the withhold. + ### Gateway instruction `PC_GATEWAY_STEERING_FILE` names a file with a short, fixed instruction that the gateway adds to every request on the diff --git a/docs/zh/docs/operate/context-protection.md b/docs/zh/docs/operate/context-protection.md index e2b7e90892..c289f0cef5 100644 --- a/docs/zh/docs/operate/context-protection.md +++ b/docs/zh/docs/operate/context-protection.md @@ -43,11 +43,15 @@ Python Client 返回 `PreparedContext` 或 `PreparedContextWithReuse`,读取 进程内沿用 `runtime.context.for_scope(scope_id).prepare(request)`; `validate_reuse` 检查保留的证明,不做检索或 Embedding。嵌入式应用自行提供当前可信授权上下文。 -首个复用 profile 支持单 Scope、实际选中的 Atomic Memory,不支持 context references 或代码。 -它记录最终选中的当前 revision、生命周期版本和完整有界历史依赖。合并结果的旧祖先在领域规则允许时仍可作为证据。 +复用 profile 支持单 Scope、实际选中的 Atomic Memory 和代码证据(`include_code: true`),不支持 context references。 +对 Atomic Memory,它记录最终选中的当前 revision、生命周期版本和完整有界历史依赖。合并结果的旧祖先在领域规则允许时仍可作为证据。 根记忆被修订或遗忘、访问撤销、引用范围变化、绑定过期、Source 不再符合资格,都会停止旧值复用。 请求复用时实际选中了其他 family,会明确拒绝;普通 prepare 保持原支持范围。 +对代码证据,它记录每个被引用文件的仓库路径和 SHA-256 摘要,并附带索引指纹以备查。校验时只按与建索引相同的路径和排除 +规则,从 Scope 当前工作区读取被引用的文件。被引用文件被修改、删除或不再可索引,或仓库不可用,都会停止复用;修改未被 +引用的文件或重建索引不会。代码文件与 Atomic Memory 依赖共用依赖上限。 + 绑定经过认证加密,期限五分钟,最多 64 个依赖、八层祖先。绑定不是访问权限。 Server 用既有签名秘密派生独立用途的密钥,并绑定持久 Server 身份;同部署的多个进程需要共享该秘密。 进程内 Runtime 默认使用临时密钥。没有新增 prompt 或响应缓存表。 diff --git a/docs/zh/docs/operate/deploy-server.md b/docs/zh/docs/operate/deploy-server.md index c84ca2fa29..83f8a48029 100644 --- a/docs/zh/docs/operate/deploy-server.md +++ b/docs/zh/docs/operate/deploy-server.md @@ -213,6 +213,10 @@ Rust 可执行程序、联合 launcher、网关配置、私有通道认证、私 `PC_GATEWAY_INJECTION_RESPONSES=off` 只关闭 Responses 入口的注入。独立运行的网关(`PC_GATEWAY_CORE_BASE`)只有 设置 `PC_GATEWAY_INJECTION=on` 时才注入。准备结果为空或准备失败时,请求照常发送,不注入内容。 +设置 `PC_GATEWAY_INJECTION_CODE=on` 后,准备的上下文同时包含 Scope 代码索引中的代码证据;该选项默认关闭,并要求 Scope +已配置代码仓库。重放的内容因被引用的来源变化(例如 Agent 修改了被引用的文件)而不再通过检查时,网关丢弃该会话的重放, +为该请求重新准备一次,而不是把 withhold 返回给客户端。 + ### 网关固定指令 `PC_GATEWAY_STEERING_FILE` 指定一个文件,其中是一段简短的固定指令,网关把它加入 Responses 入口 diff --git a/gateway/README.md b/gateway/README.md index b58431d8f7..7b1f9799b6 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -92,7 +92,8 @@ PC_GATEWAY_UPSTREAM_API_KEY_CHAT=... \ # requests that belong to a conversation (X-PowerContext-Conversation-ID or a recognized client session header) # are injected by default; standalone is off by default. # PC_GATEWAY_INJECTION=on injects every request, PC_GATEWAY_INJECTION=off disables injection, and -# PC_GATEWAY_INJECTION_RESPONSES=off keeps the Responses route uninjected. +# PC_GATEWAY_INJECTION_RESPONSES=off keeps the Responses route uninjected, and PC_GATEWAY_INJECTION_CODE=on adds code +# evidence from the Scope's code index (off by default). # Optional: PC_GATEWAY_STEERING_FILE (a fixed operator instruction added to Responses requests after the client's # base prompt; read and validated at startup), PC_GATEWAY_SEDIMENT=on (capture after release), # PC_GATEWAY_DEGRADE=passthrough (no-memory passthrough when Core is unreachable; never applies diff --git a/gateway/crates/pc-gateway-app/src/ingress.rs b/gateway/crates/pc-gateway-app/src/ingress.rs index d388cc8c85..bb7f412f36 100644 --- a/gateway/crates/pc-gateway-app/src/ingress.rs +++ b/gateway/crates/pc-gateway-app/src/ingress.rs @@ -273,6 +273,7 @@ async fn ingress_model( state.inject_responses, conversation.is_some(), ), + include_code: state.inject_code, conversation_id: conversation, sediment: state.sediment, prepared_contexts: Vec::new(), @@ -758,6 +759,7 @@ mod tests { degrade: pc_gateway_core::DegradePolicy::Pause, injection: Injection::Off, inject_responses: false, + inject_code: false, sediment: false, steering: None, default_scope: "test".into(), diff --git a/gateway/crates/pc-gateway-app/src/main.rs b/gateway/crates/pc-gateway-app/src/main.rs index f91f88b79c..1f5d7eccf5 100644 --- a/gateway/crates/pc-gateway-app/src/main.rs +++ b/gateway/crates/pc-gateway-app/src/main.rs @@ -59,6 +59,8 @@ pub struct AppState { /// Responses 入口是否注入:默认跟随 injection, /// `PC_GATEWAY_INJECTION_RESPONSES=off` 可单独关闭。 pub inject_responses: bool, + /// 注入时同时请求代码证据:`PC_GATEWAY_INJECTION_CODE=on`(默认关)。 + pub inject_code: bool, /// 过检后同步沉淀对话(默认关)。 pub sediment: bool, /// 运维配置的固定指令(`PC_GATEWAY_STEERING_FILE`),只插入 Responses 请求;未配置时为空。 @@ -156,6 +158,10 @@ async fn main() { std::env::var("PC_GATEWAY_INJECTION").ok().as_deref(), std::env::var("PC_GATEWAY_CORE_UDS").is_ok(), ), + inject_code: matches!( + std::env::var("PC_GATEWAY_INJECTION_CODE").as_deref(), + Ok("on") + ), inject_responses: !matches!( std::env::var("PC_GATEWAY_INJECTION_RESPONSES").as_deref(), Ok("off") diff --git a/gateway/crates/pc-gateway-core/src/executor.rs b/gateway/crates/pc-gateway-core/src/executor.rs index 34ec60f2ff..b54a15ede5 100644 --- a/gateway/crates/pc-gateway-core/src/executor.rs +++ b/gateway/crates/pc-gateway-core/src/executor.rs @@ -31,6 +31,9 @@ use crate::lifecycle::Execution; use crate::replay_cache::{message_anchor, PreparedBlock, ReplayCache, MAX_REPLAY_MS}; use crate::transcript::turn_transcript; + +/// Core 的 before_send 在复用证明的依赖已变化或证明已失效时给出的 withhold 原因。 +const CONTEXT_REFRESH_REQUIRED: &str = "context-refresh-required"; use crate::{GateClient, GateError}; use crate::{ModelSendState, RequestControl}; use pc_gateway_evidence::EvidenceSink; @@ -62,6 +65,8 @@ pub struct ManagedCall { pub headers: Vec<(String, String)>, pub degrade_policy: DegradePolicy, pub inject_context: bool, + /// 注入时同时请求代码证据(默认关)。 + pub include_code: bool, /// 宿主提供的会话 ID(`X-PowerContext-Conversation-ID`)。只有带它的请求才在会话内重放准备结果。 pub conversation_id: Option, pub sediment: bool, @@ -161,89 +166,120 @@ where // 重新渲染,否则 prepare 召回的私密记忆会在未经任何检查的情况下外发。 let pristine_request = call.request.clone(); - if call.inject_context { - let anchor = message_anchor(&pristine_request); - self.enrich(&mut call, anchor, renderer.message_list_key(), evidence) - .await; - } + let anchor = call + .inject_context + .then(|| message_anchor(&pristine_request)) + .flatten(); + let mut refreshed = false; + let (payload, admission) = loop { + if call.inject_context { + self.enrich(&mut call, anchor, renderer.message_list_key(), evidence) + .await; + } - let payload = match renderer.render(&call.request, None) { - Ok(p) => p, - Err(e) => { - return ManagedOutcome::Blocked { - reason_codes: vec![format!("render_failed:{e}")], + let payload = match renderer.render(&call.request, None) { + Ok(p) => p, + Err(e) => { + return ManagedOutcome::Blocked { + reason_codes: vec![format!("render_failed:{e}")], + } } - } - }; - // Measure the actual wire body after prepare/replay, steering and JSON escaping. - if payload.bytes.len() > self.max_input_bytes { - return ManagedOutcome::Blocked { - reason_codes: vec!["request_payload_too_large".into()], }; - } - let payload_body = match String::from_utf8(payload.bytes.clone().to_vec()) { - Ok(text) => text, - Err(_) => { + // Measure every rendered body, including a refreshed prepare result. + if payload.bytes.len() > self.max_input_bytes { return ManagedOutcome::Blocked { - reason_codes: vec!["invalid_payload_encoding".into()], - } - } - }; - let model_payload = ModelJsonPayload::new( - content_headers(&call.headers, payload.content_type), - payload_body, - ); - let check = ContextCheckRequest::BeforeSend { - scope_id: call.scope_id.clone(), - target_profile: call.target_profile.clone(), - payload: model_payload, - prepared_contexts: call.prepared_contexts.clone(), - }; - - let admission = match self.gate.check(check).await { - Err(e) => { - // 降级仅适用于 Core「不可用」;Malformed(可达但响应异常)一律暂停。 - return match (e, call.degrade_policy) { - (GateError::GateUnavailable(_), DegradePolicy::PassthroughWithoutMemory) => { - // P1-3:降级时丢弃已注入的载荷,用 pristine 请求重新渲染。 - let mut degraded_call = ManagedCall { - scope_id: call.scope_id.clone(), - target_profile: call.target_profile.clone(), - request: pristine_request, - target_url: call.target_url.clone(), - headers: call.headers.clone(), - degrade_policy: DegradePolicy::Pause, - inject_context: false, - conversation_id: None, - sediment: false, - prepared_contexts: Vec::new(), - }; - degraded_call.request.stream = call.request.stream; - self.degraded_send(°raded_call, renderer, evidence, execution) - .await - } - (e, _) => ManagedOutcome::GatePaused(classify_gate_error(e)), + reason_codes: vec!["request_payload_too_large".into()], }; } - Ok(result) => match result.decision { - CheckDecision::Allow => match validate_allow(&result, CheckPhase::BeforeSend) { - Ok((binding, deadline)) => Admission { - binding, - valid_until_ms: deadline, - }, - Err(outcome) => return outcome, - }, - CheckDecision::Block => { + let payload_body = match String::from_utf8(payload.bytes.clone().to_vec()) { + Ok(text) => text, + Err(_) => { return ManagedOutcome::Blocked { - reason_codes: result.reason_codes, + reason_codes: vec!["invalid_payload_encoding".into()], } } - CheckDecision::Withhold => { - return ManagedOutcome::Withheld { - reason_codes: result.reason_codes, - } + }; + let model_payload = ModelJsonPayload::new( + content_headers(&call.headers, payload.content_type), + payload_body, + ); + let check = ContextCheckRequest::BeforeSend { + scope_id: call.scope_id.clone(), + target_profile: call.target_profile.clone(), + payload: model_payload, + prepared_contexts: call.prepared_contexts.clone(), + }; + + let admission = match self.gate.check(check).await { + Err(e) => { + // 降级仅适用于 Core「不可用」;Malformed(可达但响应异常)一律暂停。 + return match (e, call.degrade_policy) { + ( + GateError::GateUnavailable(_), + DegradePolicy::PassthroughWithoutMemory, + ) => { + // P1-3:降级时丢弃已注入的载荷,用 pristine 请求重新渲染。 + let mut degraded_call = ManagedCall { + scope_id: call.scope_id.clone(), + target_profile: call.target_profile.clone(), + request: pristine_request.clone(), + target_url: call.target_url.clone(), + headers: call.headers.clone(), + degrade_policy: DegradePolicy::Pause, + inject_context: false, + include_code: false, + conversation_id: None, + sediment: false, + prepared_contexts: Vec::new(), + }; + degraded_call.request.stream = call.request.stream; + self.degraded_send(°raded_call, renderer, evidence, execution) + .await + } + (e, _) => ManagedOutcome::GatePaused(classify_gate_error(e)), + }; } - }, + Ok(result) => match result.decision { + CheckDecision::Allow => match validate_allow(&result, CheckPhase::BeforeSend) { + Ok((binding, deadline)) => Admission { + binding, + valid_until_ms: deadline, + }, + Err(outcome) => return outcome, + }, + CheckDecision::Block => { + return ManagedOutcome::Blocked { + reason_codes: result.reason_codes, + } + } + // 复用证明的依赖已变化(例如 Agent 改了被引用的代码文件):丢弃该会话的重放,从注入前的 + // 请求重新准备一次再送检,不把可恢复的过期交给客户端。每个请求最多刷新一次。 + CheckDecision::Withhold + if !refreshed + && !call.prepared_contexts.is_empty() + && result + .reason_codes + .iter() + .any(|reason| reason == CONTEXT_REFRESH_REQUIRED) => + { + refreshed = true; + if let Some((conversation, anchor)) = + call.conversation_id.as_deref().zip(anchor) + { + self.replays.forget(&call.scope_id, conversation, anchor); + } + call.request = pristine_request.clone(); + call.prepared_contexts.clear(); + continue; + } + CheckDecision::Withhold => { + return ManagedOutcome::Withheld { + reason_codes: result.reason_codes, + } + } + }, + }; + break (payload, admission); }; // P1-4:发送阶段。每次尝试前验有效期;确定性失败后重新走 check 拿新准入。 @@ -494,6 +530,7 @@ where query, max_bytes: 8192, include_reuse: true, + include_code: call.include_code, // P2 review:限定 memory-only(#2313 reuse 只支持 atomic-memory), // 防止 Experience/Topic 加入后 check 拒绝导致注入静默失效。 assembly: Some(pc_gateway_types::ContextAssembly::memory_only()), diff --git a/gateway/crates/pc-gateway-core/src/replay_cache.rs b/gateway/crates/pc-gateway-core/src/replay_cache.rs index f3ad6d8003..922279997b 100644 --- a/gateway/crates/pc-gateway-core/src/replay_cache.rs +++ b/gateway/crates/pc-gateway-core/src/replay_cache.rs @@ -83,6 +83,13 @@ impl ReplayCache { .map(|e| e.prepared.clone()) } + /// 丢弃一个锚点的结果,例如它的复用证明已不能通过 check;下一次请求重新准备。 + pub(crate) fn forget(&self, scope_id: &str, conversation_id: &str, anchor: u64) { + lock(&self.entries).retain(|e| { + !(e.scope_id == scope_id && e.conversation_id == conversation_id && e.anchor == anchor) + }); + } + /// 写入一个锚点的结果(put-if-absent):已有同一锚点时保留先写入的值并返回它,保证同一锚点只有 /// 一种注入内容。同一会话的不同锚点并存、不互相替换,上一回合迟到完成的准备不会挤掉当前回合。 pub(crate) fn put_if_absent( diff --git a/gateway/crates/pc-gateway-core/tests/executor_semantics.rs b/gateway/crates/pc-gateway-core/tests/executor_semantics.rs index e29858ce65..9369324e4d 100644 --- a/gateway/crates/pc-gateway-core/tests/executor_semantics.rs +++ b/gateway/crates/pc-gateway-core/tests/executor_semantics.rs @@ -81,6 +81,8 @@ struct FakeGate { prepared_seen: Mutex>>, prepare_result: Mutex>>, prepare_queries: Mutex>, + /// 每次 prepare 是否请求了代码证据。 + prepare_include_code: Mutex>, captured: Mutex>, /// 设置后,capture 记录请求后返回该错误。 capture_error: Mutex>, @@ -132,6 +134,10 @@ impl GateClient for FakeGate { request: PrepareContextRequest, ) -> impl Future> + Send { self.prepare_queries.lock().unwrap().push(request.query); + self.prepare_include_code + .lock() + .unwrap() + .push(request.include_code); let queued = self.prepare_queue.lock().unwrap().pop_front(); let hold = self.prepare_holds.lock().unwrap().pop_front().flatten(); let step = @@ -260,6 +266,7 @@ fn call(degrade: DegradePolicy) -> ManagedCall { headers: vec![("authorization".into(), "Bearer secret".into())], degrade_policy: degrade, inject_context: false, + include_code: false, conversation_id: None, sediment: false, prepared_contexts: Vec::new(), @@ -357,6 +364,7 @@ fn harness( prepare_queue: Mutex::new(VecDeque::new()), prepare_holds: Mutex::new(VecDeque::new()), prepare_queries: Mutex::new(Vec::new()), + prepare_include_code: Mutex::new(Vec::new()), captured: Mutex::new(Vec::new()), capture_error: Mutex::new(None), }); @@ -2867,6 +2875,180 @@ async fn a_hosted_tool_response_is_terminal_but_a_client_tool_call_is_not() { assert_eq!(transcript["assistant"], "found it"); } +// ---------- #2405:复用证明的依赖变化后重新准备一次 ---------- + +fn refresh_required() -> Result { + Ok(result( + CheckPhase::BeforeSend, + CheckDecision::Withhold, + &["context-refresh-required"], + )) +} + +#[tokio::test] +async fn a_stale_reuse_proof_drops_the_replay_and_prepares_once_more() { + let allow_send = || Ok(result(CheckPhase::BeforeSend, CheckDecision::Allow, &[])); + let allow_release = || Ok(result(CheckPhase::BeforeRelease, CheckDecision::Allow, &[])); + let (executor, renderer, transport, gate) = harness( + vec![ + // 第一步:准备 snapshot-1 并发送。 + allow_send(), + allow_release(), + // 第二步:重放的 snapshot-1 已失效 → 重新准备 snapshot-2 → 放行。 + refresh_required(), + allow_send(), + allow_release(), + // 第三步:重放 snapshot-2。 + allow_send(), + allow_release(), + ], + (0..3).map(|_| raw_response()).collect(), + 2, + ); + for snapshot in ["memory-snapshot-1", "memory-snapshot-2"] { + gate.prepare_queue + .lock() + .unwrap() + .push_back(Ok(reuse_result(snapshot))); + } + let r = RendererRef(renderer.clone()); + for step in 0..3 { + let control = request_control(); + let observation = control.observation(); + let outcome = executor + .run( + control, + tool_loop_call("fix the bug", step, false), + &r, + &NoopEvidenceSink, + ) + .await; + assert!( + matches!(outcome, ManagedOutcome::Released { .. }), + "{outcome:?}" + ); + assert_eq!( + observation.snapshot().model_send, + ModelSendState::ResponseReceived + ); + assert_eq!( + observation.snapshot().execution, + ExecutionState::Finished(ExecutionTerminal::ReleaseAuthorized) + ); + } + assert_eq!(gate.prepare_queries.lock().unwrap().len(), 2); + assert_eq!( + transport.sends.lock().unwrap().len(), + 3, + "the stale value is never sent" + ); + let checked: Vec<_> = sends(&gate).into_iter().map(|(body, _)| body).collect(); + assert_eq!(checked.len(), 4); + assert!(checked[0].contains("memory-snapshot-1")); + assert!(checked[1].contains("memory-snapshot-1")); + assert!(checked[2].contains("memory-snapshot-2") && !checked[2].contains("memory-snapshot-1")); + assert!(checked[3].contains("memory-snapshot-2")); +} + +#[tokio::test] +async fn a_request_refreshes_at_most_once() { + let (executor, renderer, transport, gate) = + harness(vec![refresh_required(), refresh_required()], vec![], 2); + *gate.prepare_result.lock().unwrap() = Some(Ok(reuse_result("memory-snapshot-1"))); + let outcome = executor + .run( + request_control(), + tool_loop_call("fix the bug", 0, false), + &RendererRef(renderer.clone()), + &NoopEvidenceSink, + ) + .await; + assert!( + matches!(outcome, ManagedOutcome::Withheld { ref reason_codes } if reason_codes == &["context-refresh-required"]), + "{outcome:?}" + ); + assert_eq!(gate.prepare_queries.lock().unwrap().len(), 2); + assert_eq!(sends(&gate).len(), 2); + assert!(transport.sends.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn refresh_required_without_an_injection_is_withheld_directly() { + let (executor, renderer, _transport, gate) = harness(vec![refresh_required()], vec![], 2); + let mut c = tool_loop_call("fix the bug", 0, false); + c.inject_context = false; + let outcome = executor + .run( + request_control(), + c, + &RendererRef(renderer.clone()), + &NoopEvidenceSink, + ) + .await; + assert!(matches!(outcome, ManagedOutcome::Withheld { .. })); + assert!(gate.prepare_queries.lock().unwrap().is_empty()); + assert_eq!(sends(&gate).len(), 1); +} + +#[tokio::test] +async fn code_evidence_is_requested_only_when_enabled() { + let (executor, renderer, _transport, gate) = + harness(allow_runs(2), (0..2).map(|_| raw_response()).collect(), 2); + let r = RendererRef(renderer.clone()); + for include_code in [false, true] { + let mut c = call(DegradePolicy::Pause); + c.inject_context = true; + c.include_code = include_code; + executor + .run(request_control(), c, &r, &NoopEvidenceSink) + .await; + } + assert_eq!( + *gate.prepare_include_code.lock().unwrap(), + vec![false, true] + ); +} + +#[tokio::test] +async fn refreshed_context_cannot_exceed_the_rendered_input_limit() { + let (executor, renderer, transport, gate) = harness( + vec![ + refresh_required(), + Ok(result(CheckPhase::BeforeSend, CheckDecision::Allow, &[])), + Ok(result(CheckPhase::BeforeRelease, CheckDecision::Allow, &[])), + ], + vec![raw_response()], + 2, + ); + let executor = executor.with_input_limit(1024); + for snapshot in ["small snapshot".to_string(), "私密\n\"".repeat(1000)] { + gate.prepare_queue + .lock() + .unwrap() + .push_back(Ok(reuse_result(&snapshot))); + } + let control = request_control(); + let observation = control.observation(); + let outcome = executor + .run( + control, + tool_loop_call("fix the bug", 0, false), + &RendererRef(renderer), + &NoopEvidenceSink, + ) + .await; + assert!(matches!(outcome, ManagedOutcome::Blocked { reason_codes } + if reason_codes == ["request_payload_too_large"])); + assert_eq!(gate.prepare_queries.lock().unwrap().len(), 2); + assert_eq!(gate.seen.lock().unwrap().len(), 1); + assert!(transport.sends.lock().unwrap().is_empty()); + assert_eq!(observation.snapshot().model_send, ModelSendState::NoSend); + assert_eq!( + observation.snapshot().execution, + ExecutionState::Finished(ExecutionTerminal::Blocked) + ); +} + #[tokio::test] async fn rendered_capacity_includes_prepared_context_and_replay() { let (executor, renderer, transport, gate) = harness(vec![], vec![], 2); diff --git a/gateway/crates/pc-gateway-types/src/prepare.rs b/gateway/crates/pc-gateway-types/src/prepare.rs index 5381751eca..199d6772c9 100644 --- a/gateway/crates/pc-gateway-types/src/prepare.rs +++ b/gateway/crates/pc-gateway-types/src/prepare.rs @@ -32,6 +32,9 @@ pub struct PrepareContextRequest { pub max_bytes: u32, #[serde(default)] pub include_reuse: bool, + /// 同时召回代码证据(Core `include_code`)。复用证明记录被引用文件的摘要,check 时逐个复核。 + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub include_code: bool, /// #2313 契约:限定召回家族。reuse 只支持 atomic-memory; /// 不指定 assembly 时 Core 默认召回 Experience/Topic → check 阶段拒绝。 #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/release/upgrade-support.json b/release/upgrade-support.json index f6ab631e6e..bbf19063bd 100644 --- a/release/upgrade-support.json +++ b/release/upgrade-support.json @@ -3,8 +3,8 @@ "target_version": "1.5.0", "mode": "upgrade-required", "schema_review": { - "persistence_sha256": "ebdc1e24325fb1f409f5b0126c2a108b70881ed373c9ec867cd4d40ae9d43391", - "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy." + "persistence_sha256": "53f1fd23a2a23cf77c2b70acd6e29e11c5d3e0d6eb3ee56a8b1cf8e9e0334a33", + "rationale": "The reusable Server migration framework registers actual PowerContext 1.3.0 through s1300 -> s1400 -> s1401 -> s1402 and actual 1.4.0 through the separate immutable r1400 -> s1400 -> s1401 -> s1402 source bridge. Original 1.3 resources and checksums remain byte-identical. Real 1.4.0 SQLite, embedded seekDB and OceanBase structures were captured from the official wheel in all twelve vector/Code configurations; provenance and exact source layouts are packaged with the bridge. The 1.4 bridge preserves current Candidate, Artifact and task data, adds the missing Topic budget/block structure, revokes old lease fences and joins the existing archive/portable-storage path. Empty retained tables align the final schema without inventing 1.3 Candidate history. Source histories retain their identity through subsequent upgrades and recovery; shared head names never authorize adopting unknown layouts. Vector profiles remain bound to historical model, profile ID, dimension, unit normalization and L2 distance, with no provider calls during maintenance. Complete schema, domain, projection, profile and retained-history checks precede startup and partial maintenance. Local SQLite and embedded seekDB startup reject an observable active maintenance lock through a read-only probe. SQLite runtime admission validates the default adjacent journal, rejecting unfinished full or partial maintenance, changed recorded revision checksums and missing required retained tables. Absent journals do not invent retention obligations; custom evidence directories require explicit operator verification and remote coordinator locks remain subject to deployment orchestration. Native read-only readiness uses a consistent snapshot and does not acquire Atomic Memory head locks; normal business transaction isolation is unchanged. Offline projection repairs retain the original locked state comparison and write-side CAS protection. Target locks, backup choices, original recovery evidence and explicit remote acknowledgments remain enforced. The installed-artifact catalog retains twelve 1.3.0 source/backend/vector/Code combinations. Embedded seekDB uses pylibseekdb 1.4.0.post2 or newer with the recovered-transaction lock fix. Main and publication require all twelve configurations, including embedded seekDB full-server-code and full-server-vector-code. No configuration is waived; incomplete, failed or skipped evidence rejects acceptance. The seekdb-code diagnostic selection cannot authorize publication. The registered 1.4.0 bridge remains implemented but is outside the CI upgrade acceptance matrix; passing this gate does not certify that path. The gate preserves source-specific Processing counters and isolates OceanBase source databases. Any failed, skipped or missing automatic case rejects publication. The policy remains upgrade-required until the exact target artifact passes the required automatic matrix and the reviewed support declaration is updated. Captured source structure, local tests and checkpoint replay are distinct from installed-release acceptance, native interruption/observer recovery and backup restoration. The Candidate generation prototype observes actual SQLite/seekDB/OceanBase migration checkpoints outside the production bundle; it does not replace frozen registrations or weaken recovery checks. Existing target schema and data formats, including batched persistence interfaces, remain governed by the shared registered path. The synchronized master adds blank-text validation at Source creation and managed worker logging. It changes neither registered table layout nor migration data encodings; existing stored Source values are not rewritten. The source bridges and frozen history remain unchanged. Compatibility commands identify Server-owned storage before opening a writable legacy profile and delegate source admission to the shared framework, including actual 1.4.0 and rejection of unknown layouts. Partial Processing and Atomic Memory projection maintenance verify the original recorded revision checksums; native admission also validates typed journal identity and its completed position against the database revision. Persisted vector profiles reject unsupported partial maintenance before plans, backups or operation evidence, including when the supplied configuration omits embeddings. These are admission-only changes; frozen migration resources, registered layouts, historical checksums and data transformations remain unchanged. PowerContext 1.4.1 assigns release-based presentation names v1_4_1_001 through v1_4_1_003 to the existing shared upgrade steps; the 1.3.0 and 1.4.0 source baselines remain independently registered. Existing revision IDs, bundle and revision checksums, accepted plan identities and journal schemas are unchanged. New Server revisions use software-version-plus-sequence names directly; software releases without storage changes may reuse the same database revision. Naming does not grant acceptance or replace source registration. Full Server status, plan, apply and verify reject unfinished Processing configuration and Atomic Memory projection journals through the same typed admission check used by partial maintenance and SQLite startup. The owning domain runner can still resume its own journal. This only changes admission; frozen revisions, journal formats, database layouts and data transformations remain unchanged. Revision v1_4_1_004 registers the pc_access_owner_recovery outbox on the shared Server chain for both installed-release sources and all registered vector/Code profiles. It creates an empty table through explicit maintenance for existing managed databases and through the registered bootstrap for fresh databases. Earlier revision resources, fingerprints, checksums and existing domain row encodings remain unchanged. External relationship writers use version-1 Artifact and Candidate intents committed with domain data; replay retains the original deployment, principal, resource or target, and idempotency identity. Stable operation and audit event identities prevent lost acknowledgements from duplicating ownership or audit. Conflicting intents are rejected and unknown historical ownership is not inferred. Unregistered outbox layouts are not adopted merely from a table name. This registration does not establish installed-release or external-Provider upgrade acceptance. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Database selection now defaults to powercontext for seekDB and for OceanBase URLs without a database, while explicit database names remain authoritative. An implicit seekDB selection is rejected before initialization when the legacy test database contains pc_ tables; operators can explicitly select database=test to retain those records, and no database is moved or deleted automatically. Bootstrap reuses an accessible selected database without CREATE privilege and creates only a confirmed missing database, with idempotent creation and explicit permission errors. Read-only maintenance on an initialized MySQL-compatible engine requires the selected database and never provisions it; an absent or empty seekDB instance remains a no-engine initial plan. MySQL initialization creates only missing requested tables and indexes, and sorts only those requested tables; existing registered tables are preserved. Code graph connections use the selected database, retain historical graph identities for test, and distinguish other database identities. Server identity initialization avoids redundant MySQL DDL on an existing table. Frozen migration resources, revision identifiers and checksums, registered table definitions, journal formats, and existing row encodings are unchanged. These runtime selection and admission changes do not establish an accepted upgrade path; the policy remains upgrade-required and the installed-release acceptance gate still applies. MySQL recovery rejects different registered table sets before normalization. Backend-default normalization copies only the column and index maps it changes and compares each table without cloning whole inventories. Admitted layouts, registered transitions, recovery checks, schema equality and caller-owned inventories remain preserved. SeekDB status and verify distinguish a missing configured database from an uninitialized engine and denied access. Read-only plan can represent a missing selected database as an uninitialized target without creating it; apply provisions that selected database only after the operator accepts its bound plan, then rechecks the target under the maintenance lock. Installed-release seekDB acceptance explicitly retains the historical test database and separately verifies that omitted selection is refused. Registered schema resources, revision checksums, row encodings and write-side provisioning remain unchanged. Server startup can apply an uninitialized plan to provision a missing selected seekDB database; plan, status and verify still never provision it. OceanBase 4.6 SHOW CREATE TABLE can reflect FTS_INDEX_TYPE=MATCH or PHRASE_MATCH on FULLTEXT indexes. Reflection accepts these known types without changing the logical inventory; unknown values remain rejected and indexed columns, parser identity and token-size properties remain significant. PHRASE_MATCH additionally supports phrase matching; the registered layouts require term matching and do not declare a phrase-only contract. Frozen migration resources, checksums, recovery evidence and persisted layouts remain unchanged. Native 4.6.3.1 DDL was captured from a real installed 1.3.0 database; replay of this fixture does not establish complete native upgrade acceptance. The CI database image and master full automatic upgrade matrix remain unchanged. Revision v1_4_1_005 adds nullable Atomic Memory description projection metadata after the registered owner-recovery revision, through explicit SQLite and MySQL schema transitions for both source chains and all vector/Code profiles. Earlier revision resources, fingerprints and checksums remain unchanged. The additive change neither rewrites authoritative Artifact history nor generates summaries or scans stored bodies. Existing managed databases require explicit migration; existing descriptions remain null until a new write or explicit projection repair copies authoritative metadata. Intermediate projection validation uses only columns available at that revision and still rejects inconsistent existing fields. Unmanaged SDK initialization retains its nullable-column compatibility path. The installed-release acceptance matrix and publication restrictions remain required; local regression checks do not establish native upgrade acceptance. Artifact plugin storage extends the shared Server chain from v1_4_1_006 to v1_5_0_001 with registration columns, family declarations, search state and projections, index coverage and changes, and vector-space/index registrations. SQLite also registers portable vectors and FTS auxiliary structures. Native registration columns are added in one ALTER with a matching recovery checkpoint; final column definitions and constraints are unchanged. Native vector tables are created online from registered spaces and are admitted to later maintenance inventories only after exact layout validation. Historical migration resources, retained objects, and existing data are preserved. This review does not expand the supported upgrade matrix or certify installed-release acceptance. Source write plugin registrations use the additive v1_4_1_006 revision after v1_4_1_005 on both source chains. It creates only pc_source_write_plugins; all earlier master revision scripts, schema resources and checksums remain unchanged. Revision v1_5_0_002 follows v1_5_0_001 and adds retained Source evidence bindings, exact Atomic Memory evidence snapshots and their schema marker on all registered backends. A new versioned domain step verifies retained evidence for lexical and vector profiles; earlier released domain resources and recorded revision checksums remain byte-identical. Existing Sources and Memory revisions without accepted declarations stay neutral, with no trust backfill. Current projection maintenance and rebuild explicitly require evidence validation. This additive registration does not establish installed-release acceptance; the upgrade-required gate and publication restrictions are unchanged. Revision v1_5_0_003 adds the disposable exact-revision pc_atomic_memory_evidence projection through the existing index adapters. Transactional body writes publish it from accepted evidence; scoped portable restore and maintenance rebuild derive it from retained history without current-registry trust backfill or model calls. Domain conformance compares historical and derived values, including neutral legacy declarations, on lexical and vector layouts. Earlier revision resources and fingerprints remain unchanged. Local SQLite recovery and real OceanBase 4.4.2.1 evidence conformance were exercised; these do not certify installed-release upgrades or change publication restrictions. Source evidence maintenance now accepts the deployment's own Source definition registry, reports one bounded neutralization reason through operational logs, batches exact-revision reads and caches retained-schema validation per manifest fingerprint. Indexed repair accepts that registry explicitly; the migration bundle still admits evidence against the builtin registry and says so. These changes alter neither registered table layouts nor retained encodings, and the runtime and Server evidence DDL remain byte-identical. The policy remains upgrade-required with the full automatic matrix and publication restrictions described above. Source definition manifests derive every field whose default is null from the validation schema, so an already accepted Definition keeps its fingerprint and re-registers after upgrade; populating such a field requires the nested model to serialize by alias. Registered layouts and retained encodings are unchanged. The runtime MySQL-family table fast path now runs the registered additive evidence migration before creating other tables, so an unmanaged OceanBase or seekDB database cannot retain evidence tables without their p0005 marker.. Code publication recovery adds an optional per-binding publication.json journal that records a generation identifier before its database commit and final directory rename. Caches created before this journal existed remain readable without synthesizing historical publication records; identifiers for already orphaned rows are not inferred. New seekDB generations retain the existing identifier, graph descriptor, manifest and row encodings, while reclamation validates the recorded identifier and scopes deletion to its binding. Live or reader-retained generation directories and the in-flight publication are protected from journal-window reclamation. SQLite graph rows remain owned by their generation directory. This additive local cache journal changes recovery behavior but does not change registered SQL table layouts, migration resources, revision identifiers, historical checksums or the Server migration journal. The policy remains upgrade-required, and the complete installed-release acceptance matrix remains mandatory; this compatibility review is not evidence of completed native upgrade acceptance. Managed Ops admission validates a private main-file and WAL snapshot through the exact target Server bundle, retaining the original database identity and maintenance evidence. Snapshot admission is read-only and cannot initialize, migrate or recover storage. It adds no schema revision, changes no persisted row or journal format, and does not expand supported upgrade declarations or the installed-release acceptance matrix. PreparedContext Receipt records the actual FTS mode on Experience search outcomes. This adds only an in-memory observation field; it does not change stored Experience rows, SQL selection, schema registrations, migration resources, or data encodings. Unmanaged historical Server databases are admitted by required structure and domain state, then normalized before adopting an existing checkpoint. Historical Memory conversion, Scope preservation, SQLite rollback and native DDL recovery use the shared maintenance flow. Independent extension objects and harmless DDL differences do not require clean-install fingerprints. Existing immutable revision resources and the installed-release acceptance matrix remain unchanged. Local historical tests do not certify arbitrary tenant defaults, every vector/Code configuration or future releases. SQLite compatibility validation includes effective primary-key collations and rejects conflicting identity semantics. Historical normalization and the frozen s1400 rebuild preserve generated extension definitions while copying only writable columns. Both virtual and stored generated values remain computed by SQLite; immutable revision resources and the native backend paths are unchanged. SQLite table rebuilds preserve declared collations on ordinary and generated extension columns, including their inherited index comparison semantics. Expression-local COLLATE clauses remain part of their expressions and do not change the column collation. This changes only SQLite compatibility normalization; frozen revisions and native backend execution remain unchanged. Historical Memory admission recognizes lineage ownership columns and default non-merge references while rejecting unmapped payloads. Native compatibility journals record temporary Processing counter ownership before DDL and clear it after cleanup, including completed-phase recovery. SQLite extension preservation also covers the registered r1400 bridge. Frozen migration resources and the installed-release acceptance matrix remain unchanged. Native historical Atomic conversion creates nullable description metadata with the registered UTF-8 binary collation, allowing the later frozen ADD transition to recognize its completed postcondition without duplicate DDL. SQLite historical normalization and registered source bridges preserve dependent views and their triggers during same-name table replacement. Legacy rename behavior is limited to the exact staging-to-final rename statements and the original connection setting is restored; genuine table renames still update dependent references. Frozen migration resources and native backend behavior remain unchanged. SQLite admission validates required extension defaults using SQLite deterministic-expression restrictions and a non-NULL evaluation on an isolated connection. Connection-state, random and clock-dependent expressions are rejected; direct CURRENT_DATE, CURRENT_TIME and CURRENT_TIMESTAMP defaults remain supported alongside usable constant expressions. Regression coverage verifies consecutive Source writes in distinct scopes within the same transaction. Read-only verification and startup admission share this rule; supported defaults remain usable through Source write and readback. Independent extension tables remain preserved through managed upgrades and initialization recovery; missing recovery evidence and missing vector configuration still block maintenance. Registered native column additions and modifications materialize the charset and collation declared by their immutable postcondition when the SQL would otherwise inherit a historical table default. This preserves GBK-origin upgrade recovery without changing frozen resources, existing table defaults, independent extension encodings or readiness requirements. SQLite Candidate extension columns and their values survive both stages of the registered Dream rebuild, including generated columns and dependent views. Frozen source introspection projects the already-validated managed columns without altering stored extension data. Independent indexes use SQLite-rewritten definitions after genuine table renames. Native CHECK removal resolves the actual constraint name from the registered expression and enforcement semantics, including recovery after committed DDL. Frozen resources, registered revision checksums and the installed-release acceptance matrix remain unchanged. SQLite admission rejects additional NOT NULL generated columns because computed values can reject valid Source writes. Nullable virtual and stored extensions remain preserved across historical upgrades and support Source write/readback. Frozen migrations and native backend behavior are unchanged. Ownership recovery preflight derives candidate-family filters from the exact migration identities, including Prompt Candidates that do not have standalone Artifact owners. Static Scope permission previews now use the same complete internal receipt keys as bootstrap for long Scope IDs. These authorization-only changes add no stored fields, row encoding, schema, or migration registration. Static Scope preset keys retain existing short identities and use SHA-256 for oversized relationship keys. Bootstrap and read-only preview recognize both bounded keys and historical receipts, including revoked bindings. No schema or migration registration changes. Managed observability reads existing Context Bootstrap receipt metadata in authorized Scope batches and aggregates it by integration and receipt state. This adds no schema revision, stored row changes, migration resources, or data encoding changes; receipt identities, historical evidence and the declared upgrade acceptance matrix remain unchanged. SQLite connection failures drain the aiosqlite 0.22.1 worker before propagating the error or cancellation, including failures before pool admission. This connection-lifecycle fix changes no registered schema, historical revision checksum, stored value, or upgrade source policy. Prepared-context reuse proofs revalidate cited code files by reading their current worktree digest through the existing no-follow capture reader and exclusion rules. This adds a read-only lookup; it changes no code index cache layout, manifest, graph rows, publication journal, SQL layout, migration resource or checksum." }, "cases": [], "automatic_sources": { diff --git a/src/powercontext/builtin/code/application.py b/src/powercontext/builtin/code/application.py index 0c196fbee5..16911b756c 100644 --- a/src/powercontext/builtin/code/application.py +++ b/src/powercontext/builtin/code/application.py @@ -34,6 +34,11 @@ async def query(self, request: CodeQueryRequest, /) -> CodeQueryResult | CodeSta with tracing_context(self._runtime._tracing): return await asyncio.to_thread(self._service.query, self.scope_id, request) + async def file_digests(self, paths: tuple[str, ...], /) -> dict[str, str | None]: + """Current worktree digest of each repository path, read without re-capturing the repository.""" + async with self._runtime._scope_operation(self.scope_id): + return await asyncio.to_thread(self._service.file_digests, self.scope_id, paths) + class CodeApplication: def __init__(self, runtime: BuiltinRuntime, service: CodeService) -> None: diff --git a/src/powercontext/builtin/code/capture.py b/src/powercontext/builtin/code/capture.py index 7f2b015cfa..4ef5398274 100644 --- a/src/powercontext/builtin/code/capture.py +++ b/src/powercontext/builtin/code/capture.py @@ -337,6 +337,43 @@ def capture_repository( os.close(root_fd) +def worktree_digests( + config: CodeRepositoryConfig, limits: CodeLimits, paths: tuple[str, ...], deadline: float +) -> dict[str, str | None]: + """Return each path's current SHA-256 as the index would capture it, or `None` when it would not be indexed. + + Eligibility follows a capture: the path must be in the current Git inventory (tracked, or untracked and not + ignored when the repository includes untracked files), not a symlink or submodule, and not excluded. Only the + listed files are read, through the same no-follow path walk, so a caller can tell whether specific cited files + changed without re-capturing the whole repository. + """ + try: + root = config.root.resolve(strict=True) + root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + except OSError as error: + raise CodeError("repository_unavailable") from error + digests: dict[str, str | None] = {} + try: + inventory, _invalid = _inventory(root, config, deadline) + for path in paths: + check_deadline(deadline) + try: + relative_path(path) + except ValueError: + digests[path] = None + continue + mode = inventory.get(path, (None, ""))[0] + if mode is None or mode in {"120000", "160000"} or _excluded(path, config): + digests[path] = None + continue + content, reason, _size, _mode = _read_relative(root_fd, path, limits.max_file_bytes, deadline) + reason = reason or _content_reason(content) + digests[path] = digest_bytes(content) if content is not None and reason is None else None + return digests + finally: + os.close(root_fd) + + def _content_reason(content: bytes | None) -> str | None: if content is None: return None diff --git a/src/powercontext/builtin/code/service.py b/src/powercontext/builtin/code/service.py index afa9f8997e..a97a60dca3 100644 --- a/src/powercontext/builtin/code/service.py +++ b/src/powercontext/builtin/code/service.py @@ -28,6 +28,7 @@ digest_bytes, json_bytes, source_lines, + worktree_digests, write_private, ) from powercontext.builtin.code.errors import CodeError @@ -112,6 +113,13 @@ def index(self, scope_id: str, *, full: bool = False) -> dict[str, Any]: def sync(self, scope_id: str) -> dict[str, Any]: return self.index(scope_id) + def file_digests(self, scope_id: str, paths: tuple[str, ...]) -> dict[str, str | None]: + """Current worktree digest of each path in the Scope's repository; `None` when it would not be indexed.""" + repository, _, _ = self._binding(scope_id) + return worktree_digests( + repository, self.config.limits, paths, time.monotonic() + self.config.limits.query_seconds + ) + @observed("clear") def clear(self, scope_id: str) -> dict[str, Any]: """Unpublish this configured binding and collect generations without active readers.""" diff --git a/src/powercontext/builtin/runtime/application.py b/src/powercontext/builtin/runtime/application.py index d31b295cf3..c952627f3b 100644 --- a/src/powercontext/builtin/runtime/application.py +++ b/src/powercontext/builtin/runtime/application.py @@ -1121,8 +1121,6 @@ async def prepare( await authorize_scopes((self.scope_id, *scope.context_references)) if request.include_reuse: self._require_reuse_scope(scope) - if request.include_code: - raise ContextReuseError("context-reuse-code-unsupported") token = _PREPARE_ATOMIC_CONTEXT.set(atomic_context) try: return await self._prepare(request, scope, binding_identity=binding_identity) @@ -1156,6 +1154,7 @@ async def validate_reuse( binding_identity=binding_identity, now=self._runtime._clock(), context=atomic_context, + code=self._runtime.code.for_scope(self.scope_id), ) async def check( @@ -1264,6 +1263,7 @@ async def _prepared_result( binding_identity=binding_identity, now=self._runtime._clock(), context=_PREPARE_ATOMIC_CONTEXT.get(), + code=self._runtime.code.for_scope(self.scope_id), ) return context diff --git a/src/powercontext/builtin/runtime/context_reuse.py b/src/powercontext/builtin/runtime/context_reuse.py index 76f1e30227..b31f2f1c44 100644 --- a/src/powercontext/builtin/runtime/context_reuse.py +++ b/src/powercontext/builtin/runtime/context_reuse.py @@ -12,19 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Prepared-context proof issuance and comparison against current Atomic Memory evidence.""" +"""Prepared-context proof issuance and comparison against current Atomic Memory and code evidence.""" from __future__ import annotations from dataclasses import dataclass from datetime import UTC, datetime, timedelta from hashlib import sha256 -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from pydantic import ValidationError from powercontext.artifacts import ArtifactAddress, ArtifactRef from powercontext.artifacts.search import ArtifactSearchExecutionContext +from powercontext.builtin.code.errors import CodeError from powercontext.builtin.runtime.atomic_memory import ( MAX_ATOMIC_MEMORY_DEPENDENCIES, MAX_ATOMIC_MEMORY_DEPENDENCY_DEPTH, @@ -36,6 +37,7 @@ from powercontext.builtin.runtime.prepared_context import PreparedContextBuild if TYPE_CHECKING: + from powercontext.builtin.code.application import ScopedCodeApplication from powercontext.builtin.runtime.application import ScopedAtomicMemoryApplication MAX_REUSE_DEPENDENCIES = MAX_ATOMIC_MEMORY_DEPENDENCIES @@ -72,13 +74,65 @@ def _selected_roots(scope_id: str, build: PreparedContextBuild) -> tuple[Artifac if ref.family != "atomic-memory": raise ContextReuseError("context-reuse-family-unsupported") roots.append(ref) - if build.code_origins: - raise ContextReuseError("context-reuse-code-unsupported") - if len(roots) > MAX_REUSE_DEPENDENCIES: - raise ContextReuseError("context-reuse-dependency-limit") return tuple(roots) +def _code_dependencies(scope_id: str, build: PreparedContextBuild) -> list[dict[str, str]]: + """One dependency per cited file: its path and digest, with the index fingerprint kept for the record. + + Revalidation compares each cited file, not the whole index fingerprint, so an edit elsewhere in the + repository does not invalidate a proof whose cited files are unchanged. + """ + files: dict[str, dict[str, str]] = {} + for origin in build.code_origins: + if origin.scope_id != scope_id: + raise ContextReuseError("context-reuse-cross-scope-unsupported") + dependency = {"path": origin.path, "file_sha256": origin.file_sha256, "fingerprint": origin.fingerprint} + if files.setdefault(origin.path, dependency)["file_sha256"] != origin.file_sha256: + raise ContextReuseError("context-reuse-source-changed") + return [files[path] for path in sorted(files)] + + +def _valid_code_dependencies(value: object) -> list[dict[str, str]]: + if not isinstance(value, list): + raise TypeError + dependencies: list[dict[str, str]] = [] + for item in value: + if not isinstance(item, dict) or set(item) != {"path", "file_sha256", "fingerprint"}: + raise ValueError + fields = cast("dict[str, object]", item) + path, digest, fingerprint = fields["path"], fields["file_sha256"], fields["fingerprint"] + if not ( + isinstance(path, str) + and path + and isinstance(fingerprint, str) + and fingerprint + and isinstance(digest, str) + and _is_digest(digest) + ): + raise ValueError + dependencies.append({"path": path, "file_sha256": digest, "fingerprint": fingerprint}) + return dependencies + + +def _is_digest(value: str) -> bool: + return len(value) == 64 and all(character in "0123456789abcdef" for character in value) + + +async def _current_code(code: ScopedCodeApplication | None, dependencies: list[dict[str, str]]) -> None: + """Fail closed unless every cited file still has its recorded digest in the Scope's repository.""" + if not dependencies: + return + if code is None: + raise ContextReuseError("context-reuse-code-unavailable") + try: + current = await code.file_digests(tuple(item["path"] for item in dependencies)) + except CodeError as error: + raise ContextReuseError("context-reuse-code-unavailable") from error + if any(current.get(item["path"]) != item["file_sha256"] for item in dependencies): + raise ContextReuseError("context-reuse-source-changed") + + async def prepare_reuse( memory: ScopedAtomicMemoryApplication | None, scope_id: str, @@ -89,14 +143,22 @@ async def prepare_reuse( binding_identity: str, now: datetime, context: ArtifactSearchExecutionContext | None, + code: ScopedCodeApplication | None = None, ) -> PreparedContextWithReuse: if memory is None: raise ContextReuseError("context-reuse-unsupported") roots = _selected_roots(scope_id, build) + code_dependencies = _code_dependencies(scope_id, build) + if len(roots) + len(code_dependencies) > MAX_REUSE_DEPENDENCIES: + raise ContextReuseError("context-reuse-dependency-limit") try: dependencies = await memory.read_dependency_snapshot(roots, context=context, selected=build.reuse_roots) except AtomicMemoryDependencyError as error: raise ContextReuseError(f"context-reuse-{error.reason}") from error + # The bound covers every recorded dependency: the Memory snapshot includes ancestors and Sources, not only roots. + if len(dependencies) + len(code_dependencies) > MAX_REUSE_DEPENDENCIES: + raise ContextReuseError("context-reuse-dependency-limit") + await _current_code(code, code_dependencies) expires_at = now.astimezone(UTC) + REUSE_RETENTION payload = { "scope_id": scope_id, @@ -105,6 +167,7 @@ async def prepare_reuse( "content_bytes": prepared.content_bytes, "ordered_roots": [ref.model_dump(mode="json") for ref in roots], "dependencies": dependencies, + "code": code_dependencies, "expires_at": expires_at.isoformat(), "trust": "historical-data", } @@ -121,6 +184,7 @@ async def validate_reuse( binding_identity: str, now: datetime, context: ArtifactSearchExecutionContext | None, + code: ScopedCodeApplication | None = None, ) -> ValidatedContextReuse: if memory is None: raise ContextReuseError("context-reuse-unsupported") @@ -129,14 +193,15 @@ async def validate_reuse( raise ContextBindingError("context-binding-identity-mismatch") try: roots = tuple(ArtifactRef.model_validate(ref) for ref in payload["ordered_roots"]) + # Proofs issued before code dependencies existed carry no "code" field and cite no code. + code_dependencies = _valid_code_dependencies(payload.get("code", [])) expires_at = datetime.fromisoformat(payload["expires_at"]) content_digest, content_bytes = payload["content_digest"], payload["content_bytes"] if ( - len(roots) > MAX_REUSE_DEPENDENCIES + len(roots) + len(code_dependencies) > MAX_REUSE_DEPENDENCIES or expires_at.tzinfo is None or not isinstance(content_digest, str) - or len(content_digest) != 64 - or any(character not in "0123456789abcdef" for character in content_digest) + or not _is_digest(content_digest) or type(content_bytes) is not int or not 0 <= content_bytes <= 32768 or payload["trust"] != "historical-data" @@ -150,4 +215,7 @@ async def validate_reuse( raise ContextReuseError(f"context-reuse-{error.reason}") from error if dependencies != payload.get("dependencies"): raise ContextReuseError("context-reuse-source-changed") + if len(dependencies) + len(code_dependencies) > MAX_REUSE_DEPENDENCIES: + raise ContextReuseError("context-reuse-dependency-limit") + await _current_code(code, code_dependencies) return ValidatedContextReuse(content_digest, content_bytes, expires_at, roots) diff --git a/tests/builtin/runtime/test_code_context_reuse.py b/tests/builtin/runtime/test_code_context_reuse.py new file mode 100644 index 0000000000..8f8ad61d2e --- /dev/null +++ b/tests/builtin/runtime/test_code_context_reuse.py @@ -0,0 +1,265 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exact prepared reuse of code evidence against a real repository and code index.""" + +from __future__ import annotations + +import asyncio +import json +import shutil +import subprocess +from dataclasses import replace +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from powercontext.builtin.code import CodeConfig, CodeRepositoryConfig, CodeService +from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.runtime import ( + BuiltinConfig, + ContextAssembly, + PrepareContextRequest, + PreparedContext, + PreparedContextWithReuse, + open_builtin_runtime, +) +from powercontext.builtin.runtime.context_bindings import ContextBindingError +from powercontext.builtin.runtime.context_reuse import ( + MAX_REUSE_DEPENDENCIES, + ContextReuseError, + prepare_reuse, +) +from powercontext.builtin.runtime.prepared_code import CodeEvidenceRef +from powercontext.builtin.runtime.prepared_context import PreparedContextBuild +from powercontext.builtin.scope import ScopeDraft + +FILES = { + "src/sample/__init__.py": "from .budget import fit\n", + "src/sample/budget.py": 'def fit(value):\n """Budget bytes."""\n return value\n', + "src/sample/api.py": "from . import fit as trim\n\ndef prepare(value):\n return trim(value)\n", + "README.md": "Sample repository.\n", +} + + +def _git(root: Path, *arguments: str) -> None: + executable = shutil.which("git") + assert executable + subprocess.run([executable, "-C", str(root), *arguments], check=True, capture_output=True) + + +def _repository(root: Path) -> Path: + for path, content in FILES.items(): + destination = root / path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(content) + _git(root, "init") + _git(root, "add", ".") + _git(root, "-c", "user.name=Code Test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture") + return root + + +def _request() -> PrepareContextRequest: + return PrepareContextRequest( + query="fit prepare", + include_reuse=True, + include_code=True, + assembly=ContextAssembly(sections=()), + ) + + +def _cited_paths(prepared: PreparedContextWithReuse) -> set[str]: + paths = set() + for line in (prepared.context.content or "").splitlines(): + stripped = line.strip() + if stripped.startswith("{"): + citation = json.loads(stripped) + if isinstance(citation, dict) and "file_sha256" in citation: + paths.add(citation["path"]) + return paths + + +def _code_runtime(tmp_path: Path): + """Open a runtime whose Scope is bound to an indexed repository; yields (runtime, scoped context, root).""" + + class Fixture: + async def __aenter__(self): + root = _repository(tmp_path / "repository") + database = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'runtime.db'}") + async with open_builtin_runtime(BuiltinConfig(database=database)) as runtime: + assert runtime.scopes is not None + scope = await runtime.scopes.create( + ScopeDraft(title="Code reuse", summary="Exact code evidence", idempotency_key="code-reuse") + ) + self.scope_id = scope.scope_id + code = CodeConfig( + enabled=True, + repositories={self.scope_id: CodeRepositoryConfig(root=root)}, + cache_dir=tmp_path / "cache", + ) + self.service = CodeService(code) + await asyncio.to_thread(self.service.index, self.scope_id) + self._runtime = open_builtin_runtime(BuiltinConfig(database=database, code=code)) + runtime = await self._runtime.__aenter__() + return runtime.context.for_scope(self.scope_id), root, self + + async def __aexit__(self, *error): + await self._runtime.__aexit__(*error) + + return Fixture() + + +def test_code_reuse_revalidates_cited_files_only(tmp_path): + async def scenario(): + async with _code_runtime(tmp_path) as (scoped, root, fixture): + prepared = await scoped.prepare(_request()) + assert isinstance(prepared, PreparedContextWithReuse) + cited = _cited_paths(prepared) + assert "src/sample/budget.py" in cited + assert "README.md" not in cited + + checked = await scoped.validate_reuse(prepared.reuse.binding) + assert checked.content_bytes == prepared.context.content_bytes + + # An edit outside the cited files and a rebuilt index keep the proof valid. + (root / "README.md").write_text("Sample repository, edited.\n") + await asyncio.to_thread(fixture.service.index, fixture.scope_id, full=True) + await scoped.validate_reuse(prepared.reuse.binding) + + # Editing a cited file invalidates it; restoring the bytes makes it valid again. + budget = root / "src/sample/budget.py" + budget.write_text(FILES["src/sample/budget.py"] + "# edited\n") + with pytest.raises(ContextReuseError, match="source-changed"): + await scoped.validate_reuse(prepared.reuse.binding) + budget.write_text(FILES["src/sample/budget.py"]) + await scoped.validate_reuse(prepared.reuse.binding) + + # A removed cited file and an unavailable repository both fail closed. + budget.unlink() + with pytest.raises(ContextReuseError, match="source-changed"): + await scoped.validate_reuse(prepared.reuse.binding) + budget.write_text(FILES["src/sample/budget.py"]) + root.rename(root.with_name("moved")) + with pytest.raises(ContextReuseError, match="code-unavailable"): + await scoped.validate_reuse(prepared.reuse.binding) + root.with_name("moved").rename(root) + + asyncio.run(scenario()) + + +def test_a_cited_file_that_leaves_the_git_inventory_stops_reuse(tmp_path): + async def scenario(): + async with _code_runtime(tmp_path) as (scoped, root, fixture): + prepared = await scoped.prepare(_request()) + assert isinstance(prepared, PreparedContextWithReuse) + assert "src/sample/budget.py" in _cited_paths(prepared) + # The bytes are unchanged, but the file is no longer tracked and is ignored, so it is not indexable. + _git(root, "rm", "--cached", "-q", "src/sample/budget.py") + (root / ".gitignore").write_text("src/sample/budget.py\n") + await asyncio.to_thread(fixture.service.index, fixture.scope_id, full=True) + with pytest.raises(ContextReuseError, match="source-changed"): + await scoped.validate_reuse(prepared.reuse.binding) + + asyncio.run(scenario()) + + +def test_code_reuse_is_bound_to_its_scope(tmp_path): + async def scenario(): + async with _code_runtime(tmp_path) as (scoped, _root, fixture): + prepared = await scoped.prepare(_request()) + assert isinstance(prepared, PreparedContextWithReuse) + assert _cited_paths(prepared) + runtime = scoped._runtime + other = await runtime.scopes.create( + ScopeDraft(title="Other", summary="Another Scope", idempotency_key="code-reuse-other") + ) + with pytest.raises(ContextBindingError): + await runtime.context.for_scope(other.scope_id).validate_reuse(prepared.reuse.binding) + assert fixture.scope_id != other.scope_id + + asyncio.run(scenario()) + + +def _origin(scope_id: str, path: str) -> CodeEvidenceRef: + return CodeEvidenceRef( + scope_id=scope_id, + fingerprint="fingerprint", + path=path, + file_sha256="0" * 64, + start_line=1, + end_line=1, + snippet_sha256="1" * 64, + ) + + +@pytest.mark.parametrize( + ("origins", "reason"), + [ + ((_origin("other-scope", "a.py"),), "cross-scope"), + (tuple(_origin("scope", f"f{index}.py") for index in range(MAX_REUSE_DEPENDENCIES + 1)), "dependency-limit"), + ], +) +def test_code_dependencies_stay_in_scope_and_within_the_bound(origins, reason): + build = replace( + PreparedContextBuild(context=PreparedContext(status="empty", content=None, content_bytes=0), origins=()), + code_origins=origins, + ) + + async def scenario(): + with pytest.raises(ContextReuseError, match=reason): + await prepare_reuse( + object(), # ty: ignore[invalid-argument-type] the limit is checked before any read. + "scope", + build, + build.context, + codec=None, # ty: ignore[invalid-argument-type] nothing is sealed when the limit fails. + binding_identity="local", + now=datetime.now(UTC), + context=None, + ) + + asyncio.run(scenario()) + + +class _Snapshot: + """A Memory dependency snapshot with ancestors and Sources beyond its single selected root.""" + + def __init__(self, records: int) -> None: + self.records = records + + async def read_dependency_snapshot(self, roots, *, context=None, selected=None): + return [{"kind": "artifact", "index": index} for index in range(self.records)] + + +def test_the_full_memory_snapshot_and_code_share_the_dependency_bound(): + build = replace( + PreparedContextBuild(context=PreparedContext(status="empty", content=None, content_bytes=0), origins=()), + code_origins=(_origin("scope", "a.py"), _origin("scope", "b.py")), + ) + + async def scenario(): + with pytest.raises(ContextReuseError, match="dependency-limit"): + await prepare_reuse( + _Snapshot(MAX_REUSE_DEPENDENCIES - 1), # ty: ignore[invalid-argument-type] only the snapshot is read. + "scope", + build, + build.context, + codec=None, # ty: ignore[invalid-argument-type] nothing is sealed when the limit fails. + binding_identity="local", + now=datetime.now(UTC), + context=None, + ) + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_context_reuse.py b/tests/builtin/runtime/test_context_reuse.py index aaffcf76fc..cb6d2c4b87 100644 --- a/tests/builtin/runtime/test_context_reuse.py +++ b/tests/builtin/runtime/test_context_reuse.py @@ -196,8 +196,10 @@ async def scenario(): checked = await scoped.validate_reuse(prepared.reuse.binding) assert checked.content_bytes == 0 assert checked.ordered_roots == () - with pytest.raises(ContextReuseError, match="code-unsupported"): - await scoped.prepare(_request(include_code=True)) + # Without a configured code index, code is simply unavailable and the proof cites no code. + with_code = await scoped.prepare(_request(include_code=True)) + assert isinstance(with_code, PreparedContextWithReuse) + assert (await scoped.validate_reuse(with_code.reuse.binding)).ordered_roots == () asyncio.run(scenario())