From 86d3c842e379d704b779e7806134513f8ba4adc2 Mon Sep 17 00:00:00 2001 From: jackie <2557911191@qq.com> Date: Wed, 11 Nov 2026 10:28:59 +0800 Subject: [PATCH] feat(context-reuse): explain invalidation with bounded internal attribution Revalidation of a prepared-context binding now attaches a content-free explanation to every context-reuse-source-changed it raises, readable by in-process callers of ScopedContextApplication.validate_reuse as ContextReuseError.invalidation. Error codes, exception types, check_context and HTTP output, the binding format and the 64/8 bounds are unchanged. - A bound root that is no longer its active head is named with revised, forgotten, merged or retired from the same authorized read; validation still stops at the first failing root, so the explanation is partial. - A changed snapshot is compared node by node; only nodes that are bound roots are named, ancestors and Sources are reported by subject only. - Changed or no-longer-indexable cited files are reported without paths. - Explanations hold at most 16 changes and 4096 UTF-8 bytes of canonical JSON, truncate deterministically, and fall back to unavailable if they cannot be built, leaving the original rejection intact. RFC 2086 (en/zh) documents the explanation, disclosure rules and bounds. Refs #2522 Co-Authored-By: Claude Opus 5.5 --- ...re-inference-gates-and-optional-gateway.md | 37 +++ ...re-inference-gates-and-optional-gateway.md | 32 +++ .../builtin/runtime/atomic_memory.py | 27 +- .../builtin/runtime/context_reuse.py | 162 ++++++++++- .../runtime/test_code_context_reuse.py | 23 +- tests/builtin/runtime/test_context_reuse.py | 263 +++++++++++++++++- 6 files changed, 521 insertions(+), 23 deletions(-) diff --git a/docs/en/rfcs/2086-core-inference-gates-and-optional-gateway.md b/docs/en/rfcs/2086-core-inference-gates-and-optional-gateway.md index 8b8c5e823f..bb402d732f 100644 --- a/docs/en/rfcs/2086-core-inference-gates-and-optional-gateway.md +++ b/docs/en/rfcs/2086-core-inference-gates-and-optional-gateway.md @@ -553,6 +553,42 @@ Gateways/hosts retain exact prepared content and metadata across tool rounds; ch Embedding. Invalid, expired or unverifiable dependencies stop old reuse. The host explicitly refreshes into a new context boundary rather than rewriting a historical injection. +### Explaining a rejected reuse in process + +Revalidation keeps one decision and one public code per failure. A changed, inactive or unverifiable dependency still +rejects the old binding; nothing repairs it, re-prepares, extends its expiry or follows a new head. In-process callers of +`ScopedContextApplication.validate_reuse` may additionally read a content-free explanation from +`ContextReuseError.invalidation`. The check operation keeps returning `context-refresh-required` and HTTP keeps the +generic invalid-request error, so remote hosts receive no new reason. + +| Field | Meaning | +| --- | --- | +| `attribution` | `complete`: every difference between the bound and current evidence is listed. `partial`: listed changes are evidenced, but validation stopped before comparing everything or changes were truncated. `unavailable`: nothing could be established safely. | +| `changes` | Ordered `subject` and `change`; only a root also carries its exact bound `ArtifactRef`. | +| `truncated` | At least one evidenced change was dropped to respect the bounds. | + +Only `context-reuse-source-changed` raised by revalidation carries an explanation; every other code, binding error, +access denial and preparation failure carries none. The explanation comes from the same authorized read that decides: + +- A bound root that is no longer its active head stops validation at that root. The change is `revised` for an active + root at a newer revision, including one written by a restore, so it does not imply edited content; otherwise it is + the lifecycle state `forgotten`, `merged` or `retired`, which outranks a revision written by the same operation. + Later roots and the root's ancestry are not examined, so the explanation is `partial` and never mentions an + unexamined root. +- When every root is current but the bounded snapshot differs, nodes are compared by identity as `changed`, `added` or + `removed`. A node that is itself a bound root is named; an ancestor or Source is reported only by subject, because + the binding records no root-to-dependency map. Cited code is compared afterwards, so this explanation is `complete` + only when no code was cited. +- A cited file whose digest differs is `code` `changed`, or `removed` when the repository would no longer index it. + +Disclosure is limited to identities the caller already received as citations. Explanations contain no ancestor or +Source identity, current revision, merge target, content, digest or path. Missing, unreadable, ineligible or +unauthorized evidence keeps its existing code without an explanation, so an explanation cannot distinguish absent from +denied. An explanation lists at most 16 changes, and its canonical JSON form (sorted keys, UTF-8) is at most 4096 bytes. +Changes are ordered roots, ancestors, Sources, each in bound-snapshot order followed by additions; the bounds drop +trailing changes. Building an explanation reads nothing beyond the existing snapshot and file digests. If it fails, the +explanation is `unavailable` and the original rejection is raised unchanged. + ### One check operation with two explicit phases | Proposed POST path | operationId | Phase | @@ -878,6 +914,7 @@ Private Gateway state IPC is not another public context operation. - Verify one optimization/send owner when SDK and external adapters compose. A caller claiming prior optimization cannot skip checks or trigger a second prepare. - Verify matching remote/in-process decisions under the same policy; forged plan/hash claims, expired constraints and opaque targets cannot upgrade coverage. - Verify strict default four-field versus opt-in envelope branches, capability negotiation and explicit rejection of unsupported opt-in. +- Verify in-process revalidation explains only evidenced changes: one changed root of several is named without blaming the others, lifecycle states stay distinct, missing or denied evidence explains nothing, bounds truncate deterministically and an explanation failure keeps the original rejection. - Reject wrong phases, missing/expired/cross-request request bindings and missing/mislocated historical blocks; verify multiple bindings, no HTTP-200 bypass and no repeated recall in checks. - Verify Rust UDS and Python in-process/HTTPS decisions for identical DTOs, obsolete generations after Core restart, and no coverage upgrade through opaque third-party mutations. diff --git a/docs/zh/rfcs/2086-core-inference-gates-and-optional-gateway.md b/docs/zh/rfcs/2086-core-inference-gates-and-optional-gateway.md index 70273bf6bf..931050186b 100644 --- a/docs/zh/rfcs/2086-core-inference-gates-and-optional-gateway.md +++ b/docs/zh/rfcs/2086-core-inference-gates-and-optional-gateway.md @@ -505,6 +505,37 @@ Core 在实际 select/render/fit 完成后生成完整、有界依赖: 网关/宿主在工具轮次保留准确准备值及其元信息;检查不重新 prepare、搜索或 Embedding。 依赖失效、过期或不能确认时停止旧复用,由宿主显式刷新并建立新上下文边界,不能原地改写旧注入。 +### 进程内解释被拒绝的复用 + +重新验证对每种失败仍只作一个决定、给一个公开代码。依赖变化、失活或无法确认时仍拒绝旧 binding; +不修复、不重新 prepare、不延长期限,也不跟随新 head。`ScopedContextApplication.validate_reuse` 的进程内调用方 +可另从 `ContextReuseError.invalidation` 读取不含内容的解释。检查操作仍返回 `context-refresh-required`, +HTTP 仍返回通用 invalid-request 错误,远端宿主不会收到新原因。 + +| 字段 | 含义 | +| --- | --- | +| `attribution` | `complete`:列出绑定证据与当前证据之间的全部差异。`partial`:所列变化都有证据,但验证在比较完之前停止,或变化被截断。`unavailable`:无法安全确立任何变化。 | +| `changes` | 有序的 `subject` 与 `change`;只有 root 另带其准确的绑定 `ArtifactRef`。 | +| `truncated` | 为满足上限,至少丢弃了一条有证据的变化。 | + +只有重新验证抛出的 `context-reuse-source-changed` 带解释;其他代码、binding 错误、访问拒绝和准备阶段失败都不带。 +解释来自作出决定的同一次授权读取: + +- 绑定的 root 不再是活跃 head 时,验证停在该 root。活跃 root 已有新 revision 时记为 `revised`, + 包括 restore 写出的 revision,因此不表示内容被编辑; + 否则记为生命周期状态 `forgotten`、`merged` 或 `retired`,状态优先于同一操作写出的 revision。 + 其后的 root 和该 root 的祖先未检查,因此解释为 `partial`,也不会提到未检查的 root。 +- 所有 root 都是当前值但有界快照不同时,按节点身份比较,记为 `changed`、`added` 或 `removed`。 + 本身是绑定 root 的节点会被点名;祖先或 Source 只报告 subject,因为 binding 不记录 root 到依赖的映射。 + 引用代码在其后才比较,所以只有未引用代码时该解释才是 `complete`。 +- 引用文件摘要不同时记为 `code` `changed`;仓库不再索引该文件时记为 `removed`。 + +披露仅限调用方已作为引用收到的身份。解释不含祖先或 Source 身份、当前 revision、合并目标、内容、摘要或路径。 +缺失、不可读、不合格或未授权的证据保持原有代码且不带解释,因此解释无法区分不存在与被拒绝。 +一份解释最多 16 条变化,其规范 JSON 形式(键排序、UTF-8)最多 4096 字节。 +变化按 root、祖先、Source 排序,各自按绑定快照顺序,其后为新增项;超出上限时丢弃尾部变化。 +生成解释只使用已有快照和文件摘要,不做额外读取;生成失败时解释为 `unavailable`,原拒绝原样抛出。 + ### 一个检查操作,两个明确阶段 | 提议的 POST 路径 | operationId | 阶段 | @@ -805,6 +836,7 @@ UDS 与 HTTPS 使用同一组当前授权、过期/篡改、多历史块与 bloc - 覆盖 SDK 与外部适配器组合时唯一优化/发送所有者;客户端声称“已优化”不能跳过检查,也不能触发二次 prepare。 - 验证远端检查与进程内相同策略的决策一致,并拒绝伪造计划/hash、失效约束或不透明目标带来的覆盖升级。 - 验证默认四字段与 opt-in envelope 的严格分支、能力协商和不支持选项的明确拒绝。 +- 验证进程内重新验证只解释有证据的变化:多个 root 中只点名变化的那个、生命周期状态彼此区分、缺失或被拒绝的证据不作解释、上限按确定顺序截断、解释失败时保持原拒绝。 - 验证错误 phase、缺失/过期/跨请求 request_binding、历史块缺失/错误位置与多块绑定,不能以 HTTP 200 绕过决策;检查不重复召回。 - 验证 Rust UDS 与 Python 进程内/HTTPS 相同 DTO 的决策一致,Core 重启后旧 generation 不授予权限;不透明第三方转换不能提升覆盖声明。 diff --git a/src/powercontext/builtin/runtime/atomic_memory.py b/src/powercontext/builtin/runtime/atomic_memory.py index 5f71f41fd6..70b5bd26b8 100644 --- a/src/powercontext/builtin/runtime/atomic_memory.py +++ b/src/powercontext/builtin/runtime/atomic_memory.py @@ -24,7 +24,7 @@ from dataclasses import dataclass, field, replace from hashlib import sha256 from time import perf_counter -from typing import Any, cast +from typing import Any, Literal, cast from uuid import uuid4 import rfc8785 @@ -119,13 +119,30 @@ MAX_ATOMIC_MEMORY_DEPENDENCIES = 64 MAX_ATOMIC_MEMORY_DEPENDENCY_DEPTH = 8 AtomicMemoryDependencySnapshot = list[dict[str, Any]] +AtomicMemoryRootChange = Literal["revised", "forgotten", "merged", "retired"] + +# An active root fails only when its head moved; a lifecycle change outranks the revision it may also have written. +_ROOT_CHANGES: dict[AtomicMemoryStateValue, AtomicMemoryRootChange] = { + AtomicMemoryStateValue.ACTIVE: "revised", + AtomicMemoryStateValue.FORGOTTEN: "forgotten", + AtomicMemoryStateValue.MERGED: "merged", + AtomicMemoryStateValue.RETIRED: "retired", +} class AtomicMemoryDependencyError(InvalidRuntimeRequestError): - """Exact current roots or their bounded historical evidence cannot be validated.""" + """Exact current roots or their bounded historical evidence cannot be validated. + + ``root`` and ``root_change`` are set only when the authorized read found that a requested root is no longer + the active head it was bound at; every other failure leaves them unset. + """ - def __init__(self, reason: str) -> None: + def __init__( + self, reason: str, *, root: ArtifactRef | None = None, root_change: AtomicMemoryRootChange | None = None + ) -> None: self.reason = reason + self.root = root + self.root_change = root_change super().__init__(f"atomic-memory-{reason}") @@ -498,7 +515,9 @@ async def visit(ref: ArtifactRef, depth: int) -> None: # noqa: C901 - distingui connection, self.scope_id, ref.artifact_id, context ) if current.ref != ref or current.state is not AtomicMemoryStateValue.ACTIVE: - raise AtomicMemoryDependencyError("source-changed") + raise AtomicMemoryDependencyError( + "source-changed", root=ref, root_change=_ROOT_CHANGES.get(current.state) + ) expected = selected_by_ref.get((ref.artifact_id, ref.revision)) if selected is not None and ( expected is None diff --git a/src/powercontext/builtin/runtime/context_reuse.py b/src/powercontext/builtin/runtime/context_reuse.py index b31f2f1c44..ec1b213d3b 100644 --- a/src/powercontext/builtin/runtime/context_reuse.py +++ b/src/powercontext/builtin/runtime/context_reuse.py @@ -16,10 +16,12 @@ from __future__ import annotations -from dataclasses import dataclass +import functools +import json +from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from hashlib import sha256 -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, Literal, cast from pydantic import ValidationError @@ -37,17 +39,73 @@ from powercontext.builtin.runtime.prepared_context import PreparedContextBuild if TYPE_CHECKING: + from collections.abc import Callable + from powercontext.builtin.code.application import ScopedCodeApplication from powercontext.builtin.runtime.application import ScopedAtomicMemoryApplication MAX_REUSE_DEPENDENCIES = MAX_ATOMIC_MEMORY_DEPENDENCIES MAX_REUSE_DEPTH = MAX_ATOMIC_MEMORY_DEPENDENCY_DEPTH +MAX_REUSE_EXPLANATION_CHANGES = 16 +MAX_REUSE_EXPLANATION_BYTES = 4096 REUSE_RETENTION = timedelta(minutes=5) REUSE_PURPOSE = "context-reuse" +@dataclass(frozen=True) +class ContextReuseChange: + """One evidenced change. Only a root is named: the binding maps no ancestor, Source or file to a root.""" + + subject: Literal["root", "ancestor", "source", "code"] + change: Literal["revised", "forgotten", "merged", "retired", "changed", "added", "removed"] + # ArtifactRef is unhashable; equal changes still hash equally without it. + root: ArtifactRef | None = field(default=None, hash=False) + + +@dataclass(frozen=True) +class ContextReuseInvalidation: + """Content-free explanation of a rejected reuse for in-process callers; it never changes the rejection. + + ``complete`` lists every change between the bound and current evidence. ``partial`` lists only evidenced + changes, but validation stopped before comparing everything or ``truncated`` dropped changes beyond the count + or byte bound. ``unavailable`` lists none, because none could be established safely. + """ + + attribution: Literal["complete", "partial", "unavailable"] + changes: tuple[ContextReuseChange, ...] + truncated: bool + + def to_json(self) -> str: + """Canonical form; reuse validation raises only explanations within ``MAX_REUSE_EXPLANATION_BYTES`` of it.""" + return json.dumps( + { + "attribution": self.attribution, + "changes": [ + { + "subject": item.subject, + "change": item.change, + "root": None if item.root is None else item.root.model_dump(mode="json"), + } + for item in self.changes + ], + "truncated": self.truncated, + }, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + class ContextReuseError(InvalidRuntimeRequestError): - """A value cannot be frozen or reused with complete current provenance.""" + """A value cannot be frozen or reused with complete current provenance. + + Reuse validation sets ``invalidation`` on every ``context-reuse-source-changed`` it raises and on nothing else; + preparation, transports and checks keep reporting the code alone. + """ + + def __init__(self, code: str, *, invalidation: ContextReuseInvalidation | None = None) -> None: + super().__init__(code) + self.invalidation = invalidation @dataclass(frozen=True) @@ -119,18 +177,85 @@ 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.""" +def _bounded(changes: list[ContextReuseChange], *, complete: bool) -> ContextReuseInvalidation: + """Keep the leading changes that fit both bounds; dropping any makes the explanation partial.""" + kept = changes[:MAX_REUSE_EXPLANATION_CHANGES] + while True: + truncated = len(kept) < len(changes) + attribution = "unavailable" if not kept else "complete" if complete and not truncated else "partial" + explanation = ContextReuseInvalidation(attribution, tuple(kept), truncated) + if not kept or len(explanation.to_json().encode("utf-8")) <= MAX_REUSE_EXPLANATION_BYTES: + return explanation + kept = kept[:-1] + + +def _explained(explain: Callable[[], ContextReuseInvalidation]) -> ContextReuseInvalidation: + """An explanation that cannot be built safely explains nothing; it never replaces the rejection it explains.""" + try: + return explain() + except Exception: + return ContextReuseInvalidation("unavailable", (), truncated=False) + + +def _root_explanation(error: AtomicMemoryDependencyError) -> ContextReuseInvalidation: + """Name the root the authorized read stopped at; roots after it and its ancestry were not examined.""" + if error.root is None or error.root_change is None: + return ContextReuseInvalidation("unavailable", (), truncated=False) + return _bounded([ContextReuseChange("root", error.root_change, error.root)], complete=False) + + +def _snapshot_key(node: dict[str, Any]) -> tuple[str, str, object]: + ref = node["ref"] + if node["kind"] == "artifact": + return "artifact", ref["artifact_id"], ref["revision"] + if node["kind"] == "source": + return "source", ref["source_type"], ref["source_id"] + raise ValueError(node["kind"]) + + +def _snapshot_explanation( + roots: tuple[ArtifactRef, ...], recorded: object, current: list[dict[str, Any]], *, complete: bool +) -> ContextReuseInvalidation: + """Compare the bound and current snapshots node by node, roots first and otherwise in snapshot order. + + The snapshot maps no dependency to a root, so only a node that is itself a bound root is named. + """ + named = {("artifact", ref.artifact_id, ref.revision): ref for ref in roots} + before = {_snapshot_key(node): node for node in cast("list[dict[str, Any]]", recorded)} + after = {_snapshot_key(node): node for node in current} + changes: list[ContextReuseChange] = [] + for key in [*before, *(key for key in after if key not in before)]: + old, new = before.get(key), after.get(key) + if old == new: + continue + change = "added" if old is None else "removed" if new is None else "changed" + root = named.get(key) + subject = "source" if key[0] == "source" else "ancestor" if root is None else "root" + changes.append(ContextReuseChange(subject, change, root)) + changes.sort(key=lambda item: ("root", "ancestor", "source").index(item.subject)) + return _bounded(changes, complete=complete) + + +async def _stale_code( + code: ScopedCodeApplication | None, dependencies: list[dict[str, str]] +) -> list[ContextReuseChange]: + """Fail closed when the Scope's repository is unavailable; otherwise one change per cited file that differs. + + A file the repository would no longer index is removed. Paths are never reported. + """ if not dependencies: - return + 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") + return [ + ContextReuseChange("code", "removed" if current.get(item["path"]) is None else "changed") + for item in dependencies + if current.get(item["path"]) != item["file_sha256"] + ] async def prepare_reuse( @@ -158,7 +283,8 @@ async def prepare_reuse( # 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) + if await _stale_code(code, code_dependencies): + raise ContextReuseError("context-reuse-source-changed") expires_at = now.astimezone(UTC) + REUSE_RETENTION payload = { "scope_id": scope_id, @@ -212,10 +338,20 @@ async def validate_reuse( try: dependencies = await memory.read_dependency_snapshot(roots, context=context) except AtomicMemoryDependencyError as error: - raise ContextReuseError(f"context-reuse-{error.reason}") from error - if dependencies != payload.get("dependencies"): - raise ContextReuseError("context-reuse-source-changed") + invalidation = ( + _explained(functools.partial(_root_explanation, error)) if error.reason == "source-changed" else None + ) + raise ContextReuseError(f"context-reuse-{error.reason}", invalidation=invalidation) from error + recorded = payload.get("dependencies") + if dependencies != recorded: + # Code is compared only after Memory, so an explanation is complete only when no file was cited. + invalidation = _explained( + functools.partial(_snapshot_explanation, roots, recorded, dependencies, complete=not code_dependencies) + ) + raise ContextReuseError("context-reuse-source-changed", invalidation=invalidation) if len(dependencies) + len(code_dependencies) > MAX_REUSE_DEPENDENCIES: raise ContextReuseError("context-reuse-dependency-limit") - await _current_code(code, code_dependencies) + if stale := await _stale_code(code, code_dependencies): + invalidation = _explained(functools.partial(_bounded, stale, complete=True)) + raise ContextReuseError("context-reuse-source-changed", invalidation=invalidation) 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 index 8f8ad61d2e..47a7e9bc53 100644 --- a/tests/builtin/runtime/test_code_context_reuse.py +++ b/tests/builtin/runtime/test_code_context_reuse.py @@ -23,6 +23,7 @@ from dataclasses import replace from datetime import UTC, datetime from pathlib import Path +from typing import Literal import pytest @@ -39,7 +40,9 @@ from powercontext.builtin.runtime.context_bindings import ContextBindingError from powercontext.builtin.runtime.context_reuse import ( MAX_REUSE_DEPENDENCIES, + ContextReuseChange, ContextReuseError, + ContextReuseInvalidation, prepare_reuse, ) from powercontext.builtin.runtime.prepared_code import CodeEvidenceRef @@ -121,6 +124,10 @@ async def __aexit__(self, *error): return Fixture() +def _code_change(change: Literal["changed", "removed"]) -> ContextReuseInvalidation: + return ContextReuseInvalidation("complete", (ContextReuseChange("code", change),), truncated=False) + + def test_code_reuse_revalidates_cited_files_only(tmp_path): async def scenario(): async with _code_runtime(tmp_path) as (scoped, root, fixture): @@ -141,19 +148,26 @@ async def scenario(): # 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"): + with pytest.raises(ContextReuseError, match="source-changed") as edited: await scoped.validate_reuse(prepared.reuse.binding) + # The explanation counts changed cited files without naming any path. + explanation = edited.value.invalidation + assert explanation == _code_change("changed") + assert explanation is not None + assert "budget" not in explanation.to_json() 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"): + with pytest.raises(ContextReuseError, match="source-changed") as removed: await scoped.validate_reuse(prepared.reuse.binding) + assert removed.value.invalidation == _code_change("removed") budget.write_text(FILES["src/sample/budget.py"]) root.rename(root.with_name("moved")) - with pytest.raises(ContextReuseError, match="code-unavailable"): + with pytest.raises(ContextReuseError, match="code-unavailable") as unavailable: await scoped.validate_reuse(prepared.reuse.binding) + assert unavailable.value.invalidation is None root.with_name("moved").rename(root) asyncio.run(scenario()) @@ -169,8 +183,9 @@ async def scenario(): _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"): + with pytest.raises(ContextReuseError, match="source-changed") as removed: await scoped.validate_reuse(prepared.reuse.binding) + assert removed.value.invalidation == _code_change("removed") asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_context_reuse.py b/tests/builtin/runtime/test_context_reuse.py index cb6d2c4b87..eb3b1f6463 100644 --- a/tests/builtin/runtime/test_context_reuse.py +++ b/tests/builtin/runtime/test_context_reuse.py @@ -17,12 +17,14 @@ from __future__ import annotations import asyncio +from dataclasses import replace from datetime import UTC, datetime, timedelta from hashlib import sha256 from typing import Any import pytest +from powercontext.artifacts import ArtifactRef from powercontext.builtin.artifacts.atomic_memory.models import AtomicMemoryContent from powercontext.builtin.artifacts.memory import EmbeddingProfile from powercontext.builtin.inference import EmbeddingResult @@ -37,8 +39,19 @@ open_builtin_runtime, ) from powercontext.builtin.runtime.context_bindings import BindingCodec, ContextBindingError -from powercontext.builtin.runtime.context_reuse import ContextReuseError +from powercontext.builtin.runtime.context_reuse import ( + MAX_REUSE_EXPLANATION_BYTES, + MAX_REUSE_EXPLANATION_CHANGES, + ContextReuseChange, + ContextReuseError, + ContextReuseInvalidation, + prepare_reuse, + validate_reuse, +) +from powercontext.builtin.runtime.prepared_code import CodeEvidenceRef +from powercontext.builtin.runtime.prepared_context import PreparedContextBuild from powercontext.builtin.scope import ScopeDraft +from powercontext.limits import MAX_ARTIFACT_ID_LENGTH def _request(**changes): @@ -144,6 +157,250 @@ async def scenario(): asyncio.run(scenario()) +@pytest.mark.parametrize( + ("change", "reported"), + [("revise", "revised"), ("forget", "forgotten"), ("merge", "merged"), ("undo_merge", "retired")], +) +def test_invalidated_root_is_named_without_blaming_the_unchanged_root(change, reported): + async def scenario(): + async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: + scope = await _scope(runtime) + assert runtime.scopes is not None + assert runtime.atomic_memory is not None + memory = runtime.atomic_memory.for_scope(scope.scope_id) + steady = (await memory.create((AtomicMemoryContent(kind="fact", text="Alpha steady constraint."),))).primary + first = await memory.create((AtomicMemoryContent(kind="fact", text="Alpha first input."),)) + second = await memory.create((AtomicMemoryContent(kind="fact", text="Alpha second input."),)) + target = ( + await memory.merge( + (first.primary.as_read(), second.primary.as_read()), + AtomicMemoryContent(kind="fact", text="Alpha combined constraint."), + ) + ).primary + scoped = runtime.context.for_scope(scope.scope_id) + prepared = await scoped.prepare(_request()) + assert isinstance(prepared, PreparedContextWithReuse) + roots = (await scoped.validate_reuse(prepared.reuse.binding)).ordered_roots + assert sorted(ref.artifact_id for ref in roots) == sorted((steady.ref.artifact_id, target.ref.artifact_id)) + if change == "revise": + await runtime.records.for_scope(scope.scope_id).replace_artifact( + "atomic-memory", + target.ref.artifact_id, + '"revision:1"', + ArtifactWrite(content={"kind": "fact", "text": "Alpha combined replacement."}), + ) + elif change == "forget": + await memory.forget(target.ref.artifact_id, expected_revision=1, expected_state_version=0) + elif change == "merge": + other = await memory.create((AtomicMemoryContent(kind="fact", text="Another constraint."),)) + await memory.merge( + (target.as_read(), other.primary.as_read()), + AtomicMemoryContent(kind="fact", text="Alpha final constraint."), + ) + else: + await memory.restore(target.ref.artifact_id, operation="undo_merge") + with pytest.raises(ContextReuseError) as rejected: + await scoped.validate_reuse(prepared.reuse.binding) + # The decision and its code are unchanged; the explanation names only the root that changed. + assert rejected.value.code == "context-reuse-source-changed" + assert rejected.value.invalidation == ContextReuseInvalidation( + attribution="partial", + changes=(ContextReuseChange(subject="root", change=reported, root=target.ref),), + truncated=False, + ) + + asyncio.run(scenario()) + + +def test_denied_revalidation_reveals_nothing_about_changed_roots(tmp_path): + from powercontext.artifacts.search import ArtifactSearchExecutionContext + from powercontext.server.authz import AccessDeniedError, PrincipalRef + from tests.e2e.test_access_control_regressions import AUDIT, _grant, _server + from tests.e2e.test_access_control_regressions import _scope as http_scope + from tests.e2e.test_atomic_memory_prepare_lifecycle import _change, _remember, _revoke + + async def scenario(): + async with _server(tmp_path) as (app, client, access): + scope_id = await http_scope(client) + changed = await _remember(client, scope_id, "Alpha private constraint.") + await _remember(client, scope_id, "Beta unrelated note.") + grant = await _grant(client, scope_id, "alice", "scope.viewer") + context = ArtifactSearchExecutionContext( + principal=PrincipalRef(type="user", id="alice"), access=access, audit=AUDIT + ) + scoped = app.state.application.context.for_scope(scope_id) + + async def prepare(query): + prepared = await scoped.prepare(_request(query=query), atomic_context=context, binding_identity="alice") + assert isinstance(prepared, PreparedContextWithReuse) + return prepared.reuse.binding + + async def rejection(binding): + with pytest.raises((ContextReuseError, AccessDeniedError)) as rejected: + await scoped.validate_reuse(binding, atomic_context=context, binding_identity="alice") + return rejected.value + + stale, fresh = await prepare("alpha"), await prepare("beta") + await _change(client, scope_id, changed, "forget") + explained = await rejection(stale) + assert isinstance(explained, ContextReuseError) + assert explained.invalidation is not None + assert [item.change for item in explained.invalidation.changes] == ["forgotten"] + await scoped.validate_reuse(fresh, atomic_context=context, binding_identity="alice") + + # Without current authority, a changed and an unchanged binding fail identically and explain nothing. + await _revoke(client, grant) + denied_stale, denied_fresh = await rejection(stale), await rejection(fresh) + assert type(denied_stale) is type(denied_fresh) is AccessDeniedError + assert denied_stale.args == denied_fresh.args + assert not hasattr(denied_stale, "invalidation") + + asyncio.run(scenario()) + + +class _Evidence: + """One Atomic Memory dependency snapshot, as read when a binding is issued or revalidated.""" + + def __init__(self, snapshot: list[dict[str, Any]]) -> None: + self.snapshot = snapshot + + async def read_dependency_snapshot(self, roots, *, context=None, selected=None): + return self.snapshot + + +class _UnchangedFiles: + async def file_digests(self, paths): + return dict.fromkeys(paths, "0" * 64) + + +def _root(artifact_id: str) -> ArtifactRef: + return ArtifactRef(family="atomic-memory", artifact_id=artifact_id, revision=1) + + +def _artifact_node(ref: ArtifactRef, digest: str = "a") -> dict[str, Any]: + state = {"state": "active", "state_version": 0, "merged_into_id": None} + return {"kind": "artifact", "ref": ref.model_dump(mode="json"), "digest": digest * 64, "state": state} + + +def _source_node(source_id: str, digest: str = "a") -> dict[str, Any]: + ref = {"source_type": "document", "source_id": source_id} + return {"kind": "source", "ref": ref, "digest": digest * 64, "journal_position": 1} + + +def _revalidated(roots, recorded, current, *, cite_code=False) -> ContextReuseError: + """Issue a real binding over recorded evidence; return the rejection of revalidating it against current evidence.""" + codec, now = BindingCodec(), datetime.now(UTC) + cited = CodeEvidenceRef( + scope_id="scope", + fingerprint="fingerprint", + path="src/private.py", + file_sha256="0" * 64, + start_line=1, + end_line=1, + snippet_sha256="1" * 64, + ) + build = replace( + PreparedContextBuild( + context=PreparedContext(status="ready", content="Context", content_bytes=7), origins=roots + ), + code_origins=(cited,) if cite_code else (), + ) + + async def scenario(): + prepared = await prepare_reuse( + _Evidence(recorded), # ty: ignore[invalid-argument-type] only the dependency snapshot is read. + "scope", + build, + build.context, + codec=codec, + binding_identity="local", + now=now, + context=None, + code=_UnchangedFiles(), # ty: ignore[invalid-argument-type] only cited file digests are read. + ) + with pytest.raises(ContextReuseError) as rejected: + await validate_reuse( + _Evidence(current), # ty: ignore[invalid-argument-type] only the dependency snapshot is read. + "scope", + prepared.reuse.binding, + codec=codec, + binding_identity="local", + now=now, + context=None, + ) + return rejected.value + + return asyncio.run(scenario()) + + +@pytest.mark.parametrize(("cite_code", "attribution"), [(False, "complete"), (True, "partial")]) +def test_snapshot_changes_name_only_bound_roots_and_never_guess_ancestry(cite_code, attribution): + first, second = _root("root-a"), _root("root-b") + ancestor = ArtifactRef(family="atomic-memory", artifact_id="private-ancestor", revision=3) + recorded = [ + _artifact_node(first), + _artifact_node(ancestor), + _source_node("private-kept"), + _source_node("private-gone"), + _artifact_node(second), + ] + current = [ + _artifact_node(first), + _artifact_node(ancestor, "b"), + _source_node("private-kept"), + _artifact_node(second, "b"), + _source_node("private-new"), + ] + rejected = _revalidated((first, second), recorded, current, cite_code=cite_code) + assert rejected.code == "context-reuse-source-changed" + # Cited code is compared after Memory, so a Memory change cannot claim the files were unchanged. + assert rejected.invalidation == ContextReuseInvalidation( + attribution=attribution, + changes=( + ContextReuseChange("root", "changed", second), + ContextReuseChange("ancestor", "changed"), + ContextReuseChange("source", "removed"), + ContextReuseChange("source", "added"), + ), + truncated=False, + ) + assert rejected.invalidation is not None + assert "private" not in rejected.invalidation.to_json() + + +@pytest.mark.parametrize("identity", ["short", "long"]) +def test_explanation_keeps_a_deterministic_prefix_within_count_and_byte_bounds(identity): + def artifact_id(index: int) -> str: + return f"root-{index:02d}" if identity == "short" else f"{index:02d}" + "記" * (MAX_ARTIFACT_ID_LENGTH - 2) + + roots = tuple(_root(artifact_id(index)) for index in range(MAX_REUSE_EXPLANATION_CHANGES + 4)) + recorded, current = [_artifact_node(ref) for ref in roots], [_artifact_node(ref, "b") for ref in roots] + first, again = (_revalidated(roots, recorded, current) for _ in range(2)) + assert first.code == again.code == "context-reuse-source-changed" + assert first.invalidation == again.invalidation + explanation = first.invalidation + assert explanation is not None + assert explanation.attribution == "partial" + assert explanation.truncated + kept = len(explanation.changes) + assert explanation.changes == tuple(ContextReuseChange("root", "changed", ref) for ref in roots[:kept]) + assert len(explanation.to_json().encode("utf-8")) <= MAX_REUSE_EXPLANATION_BYTES + if identity == "short": + assert kept == MAX_REUSE_EXPLANATION_CHANGES + else: + assert 0 < kept < MAX_REUSE_EXPLANATION_CHANGES + + +@pytest.mark.parametrize("current", ["malformed", "reordered"]) +def test_unexplainable_snapshot_change_stays_rejected_and_explains_nothing(current): + roots = (_root("root-a"), _root("root-b")) + recorded = [_artifact_node(ref) for ref in roots] + changed = [{"kind": "unknown"}, *recorded] if current == "malformed" else recorded[::-1] + rejected = _revalidated(roots, recorded, changed) + assert rejected.code == "context-reuse-source-changed" + assert rejected.invalidation == ContextReuseInvalidation("unavailable", (), truncated=False) + + def test_merged_memory_keeps_exact_inactive_ancestry_eligible(): async def scenario(): async with open_builtin_runtime(BuiltinConfig(database=SQLiteConfig())) as runtime: @@ -478,7 +735,9 @@ async def scenario(): await connection.execute( update(SOURCES_TABLE).where(where).values(payload=json.dumps(payload).encode("utf-8")) ) - with pytest.raises(ContextReuseError, match="evidence-unavailable"): + with pytest.raises(ContextReuseError, match="evidence-unavailable") as rejected: await scoped.validate_reuse(prepared.reuse.binding) + # Unreadable or ineligible evidence is never explained, so it cannot distinguish missing from denied. + assert rejected.value.invalidation is None asyncio.run(scenario())