Skip to content

Commit a56ba02

Browse files
committed
Verify foundation native proofs before cache reuse and publication resume
1 parent 1f3b3ad commit a56ba02

5 files changed

Lines changed: 171 additions & 7 deletions

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,3 +903,9 @@ worker 使用独立知识视图、阶段记录和订阅传输;唯一协调器
903903
预算限制;不可完整展示的行段显式保留为缺口。引用必须落在实际展示的单个区间
904904
中,不以最大行号冒充中间未读取的内容。旧功能仍使用原来源选择及覆盖判定,
905905
已覆盖页不重新提取。预算预留/结算采用原子操作;订阅费用未报告仍为未知。
906+
907+
完成但尚未发布的订阅知识缓存及已准备发布的恢复也必须先做只读原生证明复核。
908+
复核绑定原输入身份、源码 pin、KB 基点、有效 owner、两个模型的完整归档及
909+
精确正文/证据。失败在任何记录或发布写入前拒绝,恢复原归档后可以继续同一
910+
已准备的发布;已发布的历史记录保持不变。已有订阅工作检查点的输入发生变化
911+
时须建立新批次,不能静默覆盖或重新抽取。

‎plugins/infermatrix-copilot/skills/imkbinit/SKILL.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,9 @@ approved sections in deterministic order. Resume the same command/state to reuse
339339
completed native approvals; keep their external trace archives. Finite-budget
340340
runs retain the previous serial flow. New discovery features preferentially read
341341
the frozen report’s exact complete source spans; unread gaps cannot be cited.
342+
343+
Completed unpublished caches and prepared-publication resumes also replay the
344+
original native proofs before any write or model call. Missing or changed
345+
archives reject reuse without changing the genuine record; restore the original
346+
archive to continue. Changed frozen inputs require a new batch. Published
347+
historical records remain unchanged.

‎src/infermatrix_copilot/kb_service/init_knowledge.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,34 @@ def _input_options(self) -> dict:
126126
"zcode_start_interval_s": start_interval(self.rt)}
127127
return options
128128

129+
def _discovery_gate(self, chain: _Chain, pin: str) -> None:
130+
self._knowledge_run_pin = pin
131+
super()._discovery_gate(chain, pin)
132+
129133
def _cache_reusable(self, previous: InitRecord) -> bool:
134+
if previous.status == "published":
135+
return True # Preserve immutable historical publication records.
136+
if self.rt.unlimited_subscription and previous.status != "published":
137+
self._validate_unpublished_checkpoint(previous, previous.inputs_digest)
130138
return not (self.rt.unlimited_subscription and previous.unfinished)
131139

140+
def _resume_input_problems(self, previous: InitRecord, digest: str) -> list[str]:
141+
if self.rt.unlimited_subscription and previous.status != "published":
142+
# Raise before generic _blocked writes: restoring an archive must
143+
# leave the same genuine prepared publication resumable.
144+
self._validate_unpublished_checkpoint(previous, digest)
145+
return super()._resume_input_problems(previous, digest)
146+
147+
def _validate_unpublished_checkpoint(self, previous: InitRecord, digest: str) -> None:
148+
from .init_knowledge_parallel import validate_checkpoint
149+
validate_checkpoint(self, previous, digest)
150+
132151
def _restore_progress(self, previous: InitRecord | None) -> list[str]:
152+
if (self.rt.unlimited_subscription and previous is not None
153+
and previous.coverage.get("foundation_jobs")
154+
and previous.inputs_digest != self.record.inputs_digest):
155+
from .init_support import InitError
156+
raise InitError("foundation inputs changed; create a new pinned batch")
133157
if (self.rt.unlimited_subscription and previous is not None
134158
and previous.inputs_digest == self.record.inputs_digest):
135159
from copy import deepcopy

‎src/infermatrix_copilot/kb_service/init_knowledge_parallel.py‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def call_json(self, role, **kwargs):
114114

115115

116116
def _worker(stage, job):
117-
from .init_knowledge import SYSTEM_KNOWLEDGE, knowledge_prompt, knowledge_prompt, validate_sections
117+
from .init_knowledge import SYSTEM_KNOWLEDGE, knowledge_prompt, validate_sections
118118
from .init_support import generate
119119
from .init_stages import _one_line
120120
worker = copy.copy(stage)
@@ -274,6 +274,49 @@ def _apply(stage, result):
274274
return accepted
275275

276276

277+
def validate_checkpoint(stage, previous, digest):
278+
"""Read-only replay before cache reuse or any prepared publication write."""
279+
import tempfile
280+
from pathlib import Path
281+
from .feature_discovery_index import build_discovery_index, discovery_scope
282+
from .init_knowledge_inputs import source_owners
283+
284+
saved = previous.coverage.get("foundation_jobs", {})
285+
if (previous.stage != "knowledge" or previous.repo != stage.lifecycle.repo
286+
or previous.pin != stage._knowledge_run_pin
287+
or previous.kb_base_sha != stage._base_sha or previous.inputs_digest != digest
288+
or (saved and saved.get("binding") != digest)):
289+
raise InitError("foundation checkpoint frozen input identity differs")
290+
if not saved.get("tasks"):
291+
return # A genuine empty stage may reuse an already-covered merged KB.
292+
context = copy.copy(stage)
293+
context.record = copy.deepcopy(previous)
294+
context.base = {**stage.rt.knowledge.knowledge_files(previous.kb_base_sha),
295+
**{p[len("knowledge/"):]: text for p, text in stage.overlay.items()
296+
if p.startswith("knowledge/")}}
297+
context.tags = [stage.lifecycle.repo]
298+
context.today = stage.rt.today()
299+
problems = context._precheck()
300+
if problems:
301+
raise InitError("foundation checkpoint policy invalid: " + "; ".join(problems))
302+
upstream = stage.rt.upstream(stage.lifecycle.repo, stage.lifecycle.full_name)
303+
context.observer = upstream.observer(previous.pin, pull=stage.rt.pull)
304+
# Export and index live only in a temporary directory. Do not repair or
305+
# rewrite stage records, previews, pacing journals or shared index caches.
306+
with tempfile.TemporaryDirectory(prefix="kb-foundation-proof-") as scratch:
307+
tree = upstream.export(previous.pin, Path(scratch) / "tree")
308+
context._inputs(tree)
309+
if context.route_problem:
310+
raise InitError(context.route_problem)
311+
policy = getattr(context, "coverage_policy", None)
312+
docs = list(stage.lifecycle.init.doc_globs) + list(policy.catalog_sources if policy else ())
313+
index = build_discovery_index(tree, pin=previous.pin,
314+
scope=discovery_scope(stage.lifecycle.init, policy), doc_globs=sorted(set(docs)))
315+
context.owners = list(source_owners(list(index.production), context.owners, policy).values())
316+
for result in saved["tasks"].values():
317+
_validate_result(context, result)
318+
319+
277320
def restore_jobs(stage):
278321
saved = stage.record.coverage.get("foundation_jobs", {})
279322
if saved and saved.get("binding") != stage.record.inputs_digest:

‎test/test_kb_foundation_parallel.py‎

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,15 @@ def test_late_discovery_ranges_are_complete_and_gaps_are_not_offered(tmp_path):
111111
parallel.preferred_sources(stage, tmp_path, "new-feature", ["giant.ts"], 100000)
112112

113113

114-
@pytest.mark.parametrize("damage", ["judge", "generator", "owner", "facet", "offered", "section"])
115-
def test_native_checkpoint_replay_exact_approval_and_tamper_rejected(world, damage):
114+
def _complete_native(world, *, publish=False):
116115
seed_gateway = KnowledgeGateway()
117116
_chain(world, seed_gateway)
118-
state = _runtime(world).state_dir
117+
if publish:
118+
from test_kb_init_skeleton import _commit
119+
skeleton = InitRecord.load(_runtime(world).state_dir, "toy", "skeleton")
120+
modules = InitRecord.load(_runtime(world).state_dir, "toy", "modules")
121+
_commit(world["origin"], {**_tree(skeleton), **_tree(modules)}, "merge prerequisite pages")
122+
state = _runtime(world).state_dir if not publish else world["tmp"] / "publish-state"
119123
traces = TraceStore(state / "init" / "traces")
120124
offline = KnowledgeGateway()
121125

@@ -135,10 +139,24 @@ def complete(self, *, system, messages, model, effort, role, **kwargs):
135139
return Reply(blocks=[Block(type="text", text=text)], model=model)
136140

137141
gateway = ModelGateway(None, transport_factory=lambda _: NativeTransport(), recorder=trace_recorder(traces))
138-
rt = _runtime(world, gateway, generator=ModelRole("generator", "zcode", "GLM-5.3"))
142+
rt = _runtime(world, gateway, state_dir=state, generator=ModelRole("generator", "zcode", "GLM-5.3"))
143+
if publish:
144+
from test_kb_init_skeleton import FakeGh
145+
rt.gh_run = FakeGh(fail_create=1)
139146
rt.environ = {**rt.environ, "KB_KNOWLEDGE_CONCURRENCY": "2", "KB_KNOWLEDGE_START_INTERVAL_S": "0.001"}
140-
record = run_stage(rt, _modules_lifecycle(), "knowledge", dry_run=True, unlimited_subscription=True)
141-
assert record.status == "dry_run", record.problems
147+
if publish:
148+
rt.environ.update(ALLOW_PUSH="1", ALLOW_POST="1", KB_INIT_GIT_AUTHOR="Tester <tester@example.com>")
149+
record = run_stage(rt, _modules_lifecycle(), "knowledge", dry_run=not publish,
150+
from_existing=publish, unlimited_subscription=True)
151+
assert record.status == ("blocked" if publish else "dry_run"), record.problems
152+
assert not record.unfinished
153+
return rt, record, offline, traces
154+
155+
156+
157+
@pytest.mark.parametrize("damage", ["judge", "generator", "owner", "facet", "offered", "section"])
158+
def test_native_checkpoint_replay_exact_approval_and_tamper_rejected(world, damage):
159+
rt, record, offline, traces = _complete_native(world)
142160
artifacts = [a for task in record.coverage["foundation_jobs"]["tasks"].values() for a in task["artifacts"]]
143161
assert len(artifacts) == 12 and all(a["judge_receipt"]["native_trace_id"] for a in artifacts)
144162
first_tree = _tree(record)
@@ -261,3 +279,70 @@ def test_shared_page_assembly_preserves_existing_sections_and_both_owners():
261279
assert historical in stage.head[page]
262280
assert stage.head[page].index("owner=left") < stage.head[page].index("owner=right")
263281
assert len(stage.record.evidence) == 2
282+
283+
284+
@pytest.mark.parametrize("damage", ["missing_generator", "missing_judge", "tampered_generator", "tampered_judge", "binding", "config", "role"])
285+
def test_completed_cache_cannot_skip_proof_replay_or_overwrite_on_failure(world, damage):
286+
rt, record, offline, traces = _complete_native(world)
287+
assert record.status == "dry_run" and record.unfinished == []
288+
artifact = next(iter(record.coverage["foundation_jobs"]["tasks"].values()))["artifacts"][0]
289+
if damage.startswith("missing_") or damage.startswith("tampered_"):
290+
role = damage.rsplit("_", 1)[1]
291+
native = traces.get(artifact[role + "_receipt"]["native_trace_id"])
292+
attempt = traces.root / "attempts" / native["result"]["native_attempt_id"] / "attempt.json"
293+
if damage.startswith("missing_"):
294+
attempt.unlink()
295+
else:
296+
saved = json.loads(attempt.read_text())
297+
saved["status"] = "interrupted"
298+
attempt.write_text(json.dumps(saved))
299+
elif damage == "binding":
300+
record.coverage["foundation_jobs"]["binding"] = "different-inputs"
301+
record.save(rt.state_dir)
302+
elif damage == "config":
303+
rt.gateway._settings = SimpleNamespace(zcode_reasoning_level="low")
304+
else:
305+
rt.judge = ModelRole("judge", "codex", "different-model", "medium")
306+
path = InitRecord.path(rt.state_dir, "toy", "knowledge")
307+
before, calls = path.read_bytes(), len(offline.calls)
308+
with pytest.raises(InitError, match="foundation"):
309+
run_stage(rt, _modules_lifecycle(), "knowledge", dry_run=True, unlimited_subscription=True)
310+
assert path.read_bytes() == before and len(offline.calls) == calls
311+
312+
313+
@pytest.mark.parametrize("damage", ["missing_generator", "missing_judge", "digest", "role", "config"])
314+
def test_prepared_publication_checks_original_proofs_before_resume(world, damage):
315+
rt, record, offline, traces = _complete_native(world, publish=True)
316+
assert record.pr["prepared"] and record.status == "blocked" and not record.unfinished
317+
artifact = next(iter(record.coverage["foundation_jobs"]["tasks"].values()))["artifacts"][0]
318+
restore = None
319+
if damage.startswith("missing_"):
320+
role = damage.rsplit("_", 1)[1]
321+
native = traces.get(artifact[role + "_receipt"]["native_trace_id"])
322+
attempt = traces.root / "attempts" / native["result"]["native_attempt_id"] / "attempt.json"
323+
restore = attempt, attempt.read_bytes()
324+
attempt.unlink()
325+
elif damage == "digest":
326+
record.inputs_digest = "wrong-inputs"
327+
record.save(rt.state_dir)
328+
elif damage == "role":
329+
rt.judge = ModelRole("judge", "codex", "different-model", "medium")
330+
else:
331+
rt.gateway._settings = SimpleNamespace(zcode_reasoning_level="low")
332+
path = InitRecord.path(rt.state_dir, "toy", "knowledge")
333+
before, calls, pushes = path.read_bytes(), len(offline.calls), len(rt.gh_run.pushed)
334+
with pytest.raises(InitError, match="foundation"):
335+
run_stage(rt, _modules_lifecycle(), "knowledge", dry_run=False, from_existing=True, unlimited_subscription=True)
336+
assert path.read_bytes() == before and len(offline.calls) == calls and len(rt.gh_run.pushed) == pushes
337+
if restore:
338+
restore[0].write_bytes(restore[1])
339+
resumed = run_stage(rt, _modules_lifecycle(), "knowledge", dry_run=False, from_existing=True, unlimited_subscription=True)
340+
assert resumed.status == "published" and len(offline.calls) == calls
341+
# A previously published record remains immutable historical output,
342+
# including retained limitations; archive loss cannot trigger a rerun.
343+
resumed.unfinished.append("retained historical scan limitation")
344+
resumed.save(rt.state_dir)
345+
restore[0].unlink()
346+
published_bytes = path.read_bytes()
347+
cached = run_stage(rt, _modules_lifecycle(), "knowledge", dry_run=False, from_existing=True, unlimited_subscription=True)
348+
assert cached.status == "published" and path.read_bytes() == published_bytes and len(offline.calls) == calls

0 commit comments

Comments
 (0)