Skip to content

Commit 12992a5

Browse files
fix: harden resumable discovery lifecycle integrity
1 parent 59adb16 commit 12992a5

2 files changed

Lines changed: 291 additions & 0 deletions

File tree

run_archive.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
AGENT_SLUG = "moonshine-core"
4444
STATE_SCHEMA_VERSION = 1
4545
SOURCE_CONTEXT_TOKEN_BUDGET = 60_000
46+
DISCOVERY_ROW_STATUSES = {"selecting", "proposed", "running", "verified", "failed", "stopped"}
4647

4748
BASE_EXPOSED_TOOLS = [
4849
"load_skill_definition",
@@ -280,6 +281,20 @@ def _validate_material_fingerprints(object_job: ObjectJob, row: Dict[str, object
280281
raise RunnerError("material content changed after this run started: %s" % expected_item["path"])
281282

282283

284+
def _validate_discovery_verified_archive(name: str, archive: str, expected_hash: str) -> None:
285+
"""Reject a verified discovery row whose published artifact is not intact."""
286+
path = Path(str(archive or ""))
287+
digest = str(expected_hash or "").strip()
288+
if not archive or not digest or not path.exists() or not path.is_file():
289+
raise RunnerError("verified discovery state has no intact archive for %s" % name)
290+
try:
291+
current_hash = _sha256_text(path.read_text(encoding="utf-8").strip())
292+
except (OSError, UnicodeDecodeError) as exc:
293+
raise RunnerError("verified discovery archive cannot be read: %s (%s)" % (path, exc)) from exc
294+
if current_hash != digest:
295+
raise RunnerError("verified discovery archive changed on disk: %s" % path)
296+
297+
283298
def _dedupe(items: Iterable[object]) -> List[str]:
284299
seen = set()
285300
result: List[str] = []
@@ -594,16 +609,24 @@ def load_or_create_state(job: JobFile) -> Dict[str, object]:
594609
]
595610
if list(state.get("concept_references") or []) != expected_references:
596611
raise RunnerError("state concept references do not match the discovery input")
612+
seen_discovery_names = set()
597613
for expected_index, row in enumerate(rows, start=1):
598614
if not isinstance(row, dict) or int(row.get("index") or 0) != expected_index:
599615
raise RunnerError("discovery state contains an invalid object record")
616+
status = str(row.get("status") or "")
617+
if status not in DISCOVERY_ROW_STATUSES:
618+
raise RunnerError("unknown discovery state status '%s'" % status)
600619
if str(row.get("project_slug") or "") != _discovery_project_slug(job, expected_index):
601620
raise RunnerError("discovery state project association is inconsistent")
602621
if not str(row.get("session_id") or ""):
603622
raise RunnerError("discovery state contains an unbound session record")
604623
name = str(row.get("name") or "").strip()
605624
archive = str(row.get("archive") or "").strip()
606625
if name:
626+
normalized_name = name.casefold()
627+
if normalized_name in seen_discovery_names:
628+
raise RunnerError("duplicate discovery object name: %s" % name)
629+
seen_discovery_names.add(normalized_name)
607630
branch = str(row.get("branch") or "").strip()
608631
if branch not in job.branches:
609632
raise RunnerError("discovery state contains an invalid branch for %s" % name)
@@ -621,8 +644,16 @@ def load_or_create_state(job: JobFile) -> Dict[str, object]:
621644
)
622645
if archive != expected_archive:
623646
raise RunnerError("state archive association is inconsistent for %s" % name)
647+
if status == "verified":
648+
_validate_discovery_verified_archive(
649+
name,
650+
archive,
651+
str(row.get("archive_sha256") or ""),
652+
)
624653
elif archive:
625654
raise RunnerError("discovery state has an archive path without an object name")
655+
elif status == "verified":
656+
raise RunnerError("verified discovery state has no intact archive")
626657
return state
627658

628659

@@ -874,6 +905,7 @@ def verify_archive(
874905
raise ValueError("object_name is required for branch discovery")
875906
if not selected_branch:
876907
raise ValueError("branch must be one of the supplied mathematical branches")
908+
selected_name = _ensure_discovery_name_unattempted(selected_name)
877909
else:
878910
selected_name = object_job.name
879911
selected_branch = object_job.branch
@@ -1253,6 +1285,17 @@ def _attempted_object_names() -> List[str]:
12531285
return sorted(names.values(), key=str.casefold)
12541286

12551287

1288+
def _ensure_discovery_name_unattempted(name: str) -> str:
1289+
"""Fail closed when a new discovery reuses an exact prior attempted name."""
1290+
candidate = str(name or "").strip()
1291+
if not candidate:
1292+
raise RunnerError("discovery object name is empty")
1293+
attempted = {item.casefold() for item in _attempted_object_names()}
1294+
if candidate.casefold() in attempted:
1295+
raise RunnerError("discovery object was already attempted: %s" % candidate)
1296+
return candidate
1297+
1298+
12561299
def _discovery_project_slug(job: JobFile, index: int) -> str:
12571300
return "math-object-archive-%s-%03d" % (slugify(job.key, prefix="batch"), index)
12581301

@@ -1743,6 +1786,7 @@ def publish_accepted(
17431786
name = candidate_map.get(name.casefold(), "")
17441787
if not name:
17451788
raise RunnerError("verified object is outside the supplied candidate pool")
1789+
name = _ensure_discovery_name_unattempted(name)
17461790
row["name"] = name
17471791
row["branch"] = branch
17481792
row["source_urls"] = []
@@ -1981,6 +2025,8 @@ def publish_accepted(
19812025
for candidate_name, _source in job.concept_references
19822026
}
19832027
name = candidate_map.get(name.casefold(), "")
2028+
if name:
2029+
name = _ensure_discovery_name_unattempted(name)
19842030
branch_map = {branch.casefold(): branch for branch in job.branches}
19852031
branch = branch_map.get(str(control.get("branch") or "").strip().casefold(), "")
19862032
if name and branch:
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""Regression coverage for branch-discovery lifecycle integrity.
2+
3+
These tests pin fail-closed contracts for resumable discovery state, verified
4+
artifact integrity, and duplicate-safe exact-name selection without requiring a
5+
real Moonshine runtime or model provider.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import tempfile
11+
import types
12+
import unittest
13+
from pathlib import Path
14+
15+
from tests.test_run_archive_offline import _load_runner_module, _stub_write_json
16+
17+
18+
PASS_REVIEW = {
19+
"mathematical": {"verdict": "pass", "issues": [], "rationale": "fixture"},
20+
"historical": {"verdict": "pass", "issues": [], "rationale": "fixture"},
21+
"format": {"verdict": "pass", "issues": [], "rationale": "fixture"},
22+
"repair_targets": [],
23+
"summary": "fixture passed",
24+
}
25+
26+
27+
class _VerificationProvider:
28+
def __init__(self):
29+
self.calls = 0
30+
31+
def generate_structured(self, **_kwargs):
32+
self.calls += 1
33+
return PASS_REVIEW
34+
35+
36+
class _Registry:
37+
def __init__(self):
38+
self.definition = None
39+
40+
def register(self, definition):
41+
self.definition = definition
42+
43+
44+
class DiscoveryStateIntegrityTests(unittest.TestCase):
45+
@classmethod
46+
def setUpClass(cls):
47+
cls.runner, cls._module_patcher = _load_runner_module()
48+
49+
@classmethod
50+
def tearDownClass(cls):
51+
cls._module_patcher.stop()
52+
53+
def setUp(self):
54+
self._temporary_directory = tempfile.TemporaryDirectory()
55+
self.addCleanup(self._temporary_directory.cleanup)
56+
self.temp_root = Path(self._temporary_directory.name)
57+
self.task_dir = self.temp_root / "Creative-Intelligence"
58+
self.task_dir.mkdir()
59+
self._original_task_dir = self.runner.TASK_DIR
60+
self.runner.TASK_DIR = self.task_dir
61+
self.addCleanup(setattr, self.runner, "TASK_DIR", self._original_task_dir)
62+
63+
def _job(self, target_archives=2):
64+
return self.runner.build_discovery_job(
65+
["Category Theory"],
66+
target_archives=target_archives,
67+
run_name="discovery-integrity",
68+
)
69+
70+
def _row(self, job, index, name, status="verified"):
71+
return {
72+
"index": index,
73+
"name": name,
74+
"branch": "Category Theory",
75+
"source_urls": [],
76+
"status": status,
77+
"project_slug": self.runner._discovery_project_slug(job, index),
78+
"session_id": "session-%s" % index,
79+
"archive": str(
80+
self.task_dir
81+
/ "archives"
82+
/ job.key
83+
/ ("%03d-%s.md" % (index, self.runner._safe_filename(name, "object")))
84+
),
85+
"archive_sha256": self.runner._sha256_text("fixture-%s" % index),
86+
"verification_submissions": 1,
87+
"failure_stage": "",
88+
"last_verification": {},
89+
"last_error": "",
90+
}
91+
92+
def _persist_state(self, job, rows):
93+
state = self.runner.load_or_create_state(job)
94+
state["objects"] = rows
95+
_stub_write_json(job.state_path, state)
96+
return state
97+
98+
def _register_verifier(self, *, discovery=True):
99+
registry = _Registry()
100+
app = types.SimpleNamespace(tool_registry=registry)
101+
shell_state = self.runner.ShellState(
102+
mode="chat",
103+
project_slug="discovery-project",
104+
session_id="discovery-session",
105+
agent_slug=self.runner.AGENT_SLUG,
106+
)
107+
object_job = self.runner.ObjectJob(
108+
index=1,
109+
name="" if discovery else "Yoneda lemma",
110+
materials=(),
111+
project_slug=shell_state.project_slug,
112+
archive_path=self.task_dir / "pending.md",
113+
branch="" if discovery else "Category Theory",
114+
)
115+
self.runner.register_verification_tool(
116+
app,
117+
object_job=object_job,
118+
shell_state=shell_state,
119+
format_specification="No fenced placeholders.",
120+
material_context="(none)",
121+
discovery_branches=["Category Theory"] if discovery else (),
122+
)
123+
self.assertIsNotNone(registry.definition)
124+
return registry.definition.handler, shell_state
125+
126+
def _write_attempt_history(self, name, status):
127+
path = self.task_dir / "runs" / ("history-%s.state.json" % status)
128+
_stub_write_json(
129+
path,
130+
{
131+
"schema_version": self.runner.STATE_SCHEMA_VERSION,
132+
"objects": [{"name": name, "status": status}],
133+
},
134+
)
135+
136+
def test_discovery_state_rejects_unknown_row_status(self):
137+
job = self._job(target_archives=1)
138+
self._persist_state(job, [self._row(job, 1, "Yoneda lemma", status="corrupted")])
139+
140+
with self.assertRaisesRegex(self.runner.RunnerError, "unknown.*status|invalid.*status"):
141+
self.runner.load_or_create_state(job)
142+
143+
def test_discovery_state_rejects_case_insensitive_duplicate_object_names(self):
144+
job = self._job(target_archives=2)
145+
self._persist_state(
146+
job,
147+
[
148+
self._row(job, 1, "Yoneda lemma", status="failed"),
149+
self._row(job, 2, "yoneda LEMMA", status="verified"),
150+
],
151+
)
152+
153+
with self.assertRaisesRegex(self.runner.RunnerError, "duplicate.*object|duplicate.*name"):
154+
self.runner.load_or_create_state(job)
155+
156+
def test_discovery_state_rejects_missing_verified_archive(self):
157+
job = self._job(target_archives=1)
158+
self._persist_state(job, [self._row(job, 1, "Yoneda lemma", status="verified")])
159+
160+
with self.assertRaisesRegex(self.runner.RunnerError, "verified.*archive|archive.*missing|intact"):
161+
self.runner.load_or_create_state(job)
162+
163+
def test_discovery_state_rejects_tampered_verified_archive(self):
164+
job = self._job(target_archives=1)
165+
row = self._row(job, 1, "Yoneda lemma", status="verified")
166+
archive = Path(row["archive"])
167+
archive.parent.mkdir(parents=True, exist_ok=True)
168+
archive.write_text("tampered\n", encoding="utf-8")
169+
self._persist_state(job, [row])
170+
171+
with self.assertRaisesRegex(self.runner.RunnerError, "verified.*changed|hash|integrity"):
172+
self.runner.load_or_create_state(job)
173+
174+
def test_discovery_state_accepts_intact_verified_archive(self):
175+
job = self._job(target_archives=1)
176+
row = self._row(job, 1, "Yoneda lemma", status="verified")
177+
archive = Path(row["archive"])
178+
archive.parent.mkdir(parents=True, exist_ok=True)
179+
archive.write_text("fixture-1\n", encoding="utf-8")
180+
self._persist_state(job, [row])
181+
182+
loaded = self.runner.load_or_create_state(job)
183+
self.assertEqual(loaded["objects"][0]["status"], "verified")
184+
185+
def test_discovery_verifier_rejects_previously_attempted_exact_name(self):
186+
for prior_status in ("verified", "failed"):
187+
with self.subTest(prior_status=prior_status):
188+
self._write_attempt_history("Yoneda lemma", prior_status)
189+
handler, shell_state = self._register_verifier(discovery=True)
190+
provider = _VerificationProvider()
191+
runtime = {
192+
"project_slug": shell_state.project_slug,
193+
"session_id": shell_state.session_id,
194+
"verification_provider": provider,
195+
}
196+
with self.assertRaisesRegex(
197+
self.runner.RunnerError,
198+
"already attempted|duplicate|previously attempted",
199+
):
200+
handler(
201+
runtime,
202+
archive="# Archive | yoneda LEMMA\n\nfixture",
203+
object_name="yoneda LEMMA",
204+
branch="Category Theory",
205+
)
206+
self.assertEqual(provider.calls, 0)
207+
208+
def test_discovery_verifier_allows_unattempted_name(self):
209+
self._write_attempt_history("Yoneda lemma", "verified")
210+
handler, shell_state = self._register_verifier(discovery=True)
211+
provider = _VerificationProvider()
212+
runtime = {
213+
"project_slug": shell_state.project_slug,
214+
"session_id": shell_state.session_id,
215+
"verification_provider": provider,
216+
}
217+
218+
result = handler(
219+
runtime,
220+
archive="# Archive | Kan extension\n\nfixture",
221+
object_name="Kan extension",
222+
branch="Category Theory",
223+
)
224+
225+
self.assertTrue(result["passed"])
226+
self.assertEqual(provider.calls, 1)
227+
228+
def test_non_discovery_verifier_keeps_existing_failed_repair_path_available(self):
229+
self._write_attempt_history("Yoneda lemma", "failed")
230+
handler, shell_state = self._register_verifier(discovery=False)
231+
provider = _VerificationProvider()
232+
runtime = {
233+
"project_slug": shell_state.project_slug,
234+
"session_id": shell_state.session_id,
235+
"verification_provider": provider,
236+
}
237+
238+
result = handler(runtime, archive="# Archive | Yoneda lemma\n\nfixture")
239+
240+
self.assertTrue(result["passed"])
241+
self.assertEqual(provider.calls, 1)
242+
243+
244+
if __name__ == "__main__":
245+
unittest.main()

0 commit comments

Comments
 (0)