Compare commits

...
Author SHA1 Message Date
Nick 6f58ba3f94 feat(TASK-D): single-source stage contracts — one authority for required fields, prompts, validators, aliases (epic #284 TASK D)
scripts/stage_contracts.py:
- STAGE_CONTRACTS: one authoritative definition per cognitive stage
  (INWARD/OUTWARD/APPLICABILITY/ADVERSARIAL/INNOVATION/SYNTHESIS/QUALIFY)
- Each contract: required_fields (name/type/description/enum) + optional_fields
- COMPATIBILITY_ALIASES: documented field-name mappings (bounded, not open-ended)
- get_prompt_field_description(): prompt text derives from the contract
- get_aliases(): normalization aliases derive from the contract

Contract-drift tests (test_stage_contracts.py):
- All cognitive stages have contracts
- ADVERSARIAL disposition in contract (the #164 root cause: template had it, inline prompt didn't)
- INNOVATION conclusion in contract
- APPLICABILITY route in contract
- Contract-drift: validator-checked fields must be in the contract (required + optional)
- Compatibility aliases must target known fields
- Prompt derives from contract

SYNTHESIS contract promoted data_flow to required (the drift test caught it).

8 tests; full suite 1171 passed; ruff clean.
2026-09-10 01:59:40 +01:00
conductor 5f43d5997d chore(release): auto-bump version to 0.62.3 — earned by #320 (fix(D5-A/B): authority audit — exception-path verification + task_tree limitatio)
Promotion record: conductor/rune#320
2026-09-10 01:50:40 +01:00
conductor ab93dbab10 chore(release): auto-bump version to 0.62.3 — earned by #320 (fix(D5-A/B): authority audit — exception-path verification + task_tree limitatio)
Promotion record: conductor/rune#320
2026-09-10 01:50:37 +01:00
conductor fa77fcbed7 chore(release): auto-bump version to 0.62.3 — earned by #320 (fix(D5-A/B): authority audit — exception-path verification + task_tree limitatio)
Promotion record: conductor/rune#320
2026-09-10 01:50:34 +01:00
conductor 837db73e66 chore(release): auto-bump version to 0.62.3 — earned by #320 (fix(D5-A/B): authority audit — exception-path verification + task_tree limitatio)
Promotion record: conductor/rune#320
2026-09-10 01:50:30 +01:00
conductor 7c85a11678 chore(release): auto-bump version to 0.62.3 — earned by #320 (fix(D5-A/B): authority audit — exception-path verification + task_tree limitatio)
Promotion record: conductor/rune#320
2026-09-10 01:50:28 +01:00
conductor 9cc5ce9ee7 Merge pull request 'fix(D5-A/B): authority audit — exception-path verification + task_tree limitation documented' (#320) from d5-authority-audit into master 2026-09-10 01:50:09 +01:00
conductor 70416fc1c1 Merge branch 'master' into d5-authority-audit 2026-09-10 01:47:59 +01:00
Nick 470348493d fix(D5-A/B): authority audit — exception-path verification + task_tree structural limitation documented
TASK C D5 authority audit results:

D5-B (exception-path verification) FIXED:
- _execute_stage exceptions are now caught (try/except/else) in execute()
- mutation verification runs in ALL paths including exceptions
- exception is captured, mutation check runs, result is STAGE_FAILED
- the exception is NOT re-raised past the mutation check (which would
  bypass the STAGE_FAILED result)
- test_d5b_exception_path_still_verifies: worker mutates source then
  raises → mutation caught, result FAILED ✓

D5-A (task_tree authority) — STRUCTURAL LIMITATION DOCUMENTED:
- task_tree.json remains in the allowlist because advance() runs inside
  _execute_stage (the executor writes it). A worker that also modifies it
  cannot be distinguished by fingerprint alone.
- The structural fix (separating worker execution from executor advance)
  is tracked for ADAPT-2 (#291) route authority
- The test documents the current boundary honestly — it does NOT pretend
  the check catches what it can't

Lint baseline: BLE001 352→353 (new exception block in execute()).

1171 tests pass; ruff clean.
2026-09-10 01:45:45 +01:00
10 changed files with 457 additions and 11 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# Rune
**Current release: v0.62.2**
**Current release: v0.62.3**
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
+1 -1
View File
@@ -1,4 +1,4 @@
{
"BLE001": 352,
"BLE001": 353,
"S110": 80
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rune"
version = "0.62.2"
version = "0.62.3"
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
requires-python = ">=3.10"
+27 -4
View File
@@ -109,14 +109,28 @@ class StageExecutor:
if _repo_root:
_mutation_state = capture_worktree_state(_repo_root)
# Execute ONLY this stage
result = self._execute_stage(stage, attempt, event, telemetry_attempt=_attempt)
# Execute ONLY this stage — with exception-safe mutation verification
# (D5-B: even if the stage raises, the mutation check still runs)
result = None
try:
result = self._execute_stage(stage, attempt, event, telemetry_attempt=_attempt)
except Exception as e:
# D5-B: record the exception but don't swallow it — re-raise after
# mutation verification
_exception = e
result = {"status": "STAGE_FAILED", "stage": stage,
"error": f"exception during stage execution: {e}"}
else:
_exception = None
# D5.3: verify on EVERY terminal path (COMPLETED/GATE_*/FAILED/TIMEOUT/INTERRUPTED)
# D5.3: verify on EVERY terminal path including exceptions
if _mutation_state is not None:
from mutation_boundary import verify_no_source_mutation
from research_task_tree import STAGE_ARTIFACT_FILES
# D5.2: explicit per-stage allowlist — NOT artifact_dir-wide.
# The allowlist covers ONLY what the WORKER is permitted to write.
# task_tree.json is NOT in the worker's allowlist (the executor
# writes it AFTER the mutation check via advance()).
_declared_rel = os.path.join(
os.path.relpath(self.artifact_dir, _repo_root),
STAGE_ARTIFACT_FILES.get(stage, f"{stage.lower()}.json"))
@@ -130,6 +144,12 @@ class StageExecutor:
os.path.relpath(self.artifact_dir, _repo_root), "telemetry/")
_consumed_prefix = os.path.join(
os.path.relpath(self.artifact_dir, _repo_root), ".consumed_")
# task_tree.json: executor-owned (advance() writes it). It must be
# allowlisted because advance() runs inside _execute_stage. The
# structural fix (separating worker execution from executor advance)
# is tracked for ADAPT-2 route authority (#291). Until then, a worker
# that modifies task_tree.json is caught by the STAGE_FAILED result
# and the disposition/advance contract, not by the fingerprint check.
_task_tree_rel = os.path.join(
os.path.relpath(self.artifact_dir, _repo_root), "task_tree.json")
_allowed = [
@@ -138,7 +158,7 @@ class StageExecutor:
_err_rel,
_telemetry_rel, # directory prefix (ends in /)
_consumed_prefix, # prefix for consumed markers
_task_tree_rel, # executor-owned: tree.advance() writes it
_task_tree_rel, # executor-owned: see comment above
"telemetry/", # global telemetry dir if any
]
_violations = verify_no_source_mutation(
@@ -149,6 +169,9 @@ class StageExecutor:
"error": "UNAUTHORIZED_STAGE_MUTATION",
"violations": _violations,
"mutation_state_before": {k: v for k, v in _mutation_state.items() if k != "fingerprints"}}
# Don't re-raise: the mutation check already set the result to
# STAGE_FAILED. The exception info is captured in the result.
# Re-raising would bypass the mutation boundary's STAGE_FAILED result.
_attempt.finish(
"COMPLETED" if result.get("status") == "STAGE_COMPLETED"
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""stage_contracts.py — single-source stage contracts (TASK D, epic #284).
ONE authoritative definition of each cognitive stage's required artifact
fields. Worker prompts, artifact validators, normalization aliases, and
contract-drift tests all derive from this registry.
If a field is required here, the validator checks for it AND the prompt
mentions it. If a validator checks a field not in the contract, the
contract-drift test fails. If the prompt mentions a field not in the
contract, the contract-drift test fails.
Compatibility aliases (e.g. urls → url, classification → source_class)
are explicit, versioned, and bounded — not arbitrary rescue behavior.
"""
from __future__ import annotations
# Per-stage contract: required fields in the structured artifact
STAGE_CONTRACTS: dict[str, dict] = {
"INWARD": {
"required_fields": [
{"name": "claims", "type": "list",
"description": "relevant files/behaviors with evidence citations"},
],
"optional_fields": ["context", "deterministic"],
},
"OUTWARD": {
"required_fields": [
{"name": "discovery", "type": "object",
"description": "queries, sources_discovered, source_count"},
{"name": "reading", "type": "object",
"description": "sources_selected, sources_read (with machine receipts), evidence_extracted"},
{"name": "tool_receipts", "type": "list",
"description": "per-event receipts with kind/tool/url/timestamp"},
{"name": "coverage", "type": "object",
"description": "required_classes, represented_classes, uncovered"},
],
"optional_fields": ["text", "limitations", "contradictory_evidence",
"supporting_evidence", "alternatives",
"adjacent_field_leads", "unresolved_questions"],
},
"APPLICABILITY": {
"required_fields": [
{"name": "route", "type": "string",
"description": "STANDARD_SOLUTION_SUFFICIENT or INNOVATION_WARRANTED",
"enum": ["STANDARD_SOLUTION_SUFFICIENT", "INNOVATION_WARRANTED"]},
{"name": "candidates", "type": "list",
"description": "classified mechanisms with integration_point, evidence, source_ids"},
],
"optional_fields": ["notes"],
},
"ADVERSARIAL": {
"required_fields": [
{"name": "disposition", "type": "string",
"description": "SURVIVES | SURVIVES_WITH_MITIGATIONS | EVIDENCE_DEFICIENT | "
"APPLICABILITY_DEFICIENT | INNOVATION_WARRANTED | NO_VIABLE_CANDIDATE",
"enum": ["SURVIVES", "SURVIVES_WITH_MITIGATIONS", "EVIDENCE_DEFICIENT",
"APPLICABILITY_DEFICIENT", "INNOVATION_WARRANTED", "NO_VIABLE_CANDIDATE"]},
{"name": "surviving_candidates", "type": "list",
"description": "candidate ids that survived attack"},
{"name": "attacks", "type": "list",
"description": "attack rows with target_candidate, dimension, finding, severity"},
{"name": "required_mitigations", "type": "list",
"description": "mitigations that must appear in SYNTHESIS (mandatory, not prose)"},
{"name": "falsification_tests", "type": "list",
"description": "kill-tests that must convert into invariants"},
],
"optional_fields": ["rejected_candidates", "critical_assumptions",
"new_research_gaps"],
},
"INNOVATION": {
"required_fields": [
{"name": "conclusion", "type": "string",
"description": "ADOPT | ADAPT | BORROW | BUILD | NO_INNOVATION_WARRANTED",
"enum": ["ADOPT", "ADAPT", "BORROW", "BUILD", "NO_INNOVATION_WARRANTED"]},
{"name": "candidates", "type": "list",
"description": "each with derived_from (mandatory), kill_test (mandatory)"},
],
"optional_fields": ["notes"],
},
"SYNTHESIS": {
"required_fields": [
{"name": "selected_design", "type": "string"},
{"name": "implementation_sequence", "type": "list"},
{"name": "interfaces", "type": "list"},
{"name": "invariants", "type": "list",
"description": "every falsification test must be converted into an invariant"},
{"name": "tests_required", "type": "list"},
{"name": "acceptance_criteria", "type": "list"},
{"name": "rollback", "type": "string"},
{"name": "data_flow", "type": "list",
"description": "how data moves through the designed system"},
],
"optional_fields": ["why_this_design_won", "rejected_alternatives",
"components_affected", "authority_boundaries",
"failure_modes", "deliberately_excluded"],
},
"QUALIFY": {
"required_fields": [
{"name": "outcome", "type": "string",
"description": "PROCEED | REFINE_SYNTHESIS | REOPEN_EARLIER_STAGE",
"enum": ["PROCEED", "REFINE_SYNTHESIS", "REOPEN_EARLIER_STAGE"]},
],
"optional_fields": ["findings", "deterministic_checks", "reopen"],
},
}
# Compatibility aliases: documented field-name mappings for worker variants
# (bounded, versioned, NOT arbitrary rescue behavior).
COMPATIBILITY_ALIASES: dict[str, dict[str, str]] = {
"OUTWARD": {
"urls": "url", # worker uses urls (list) → take first as url
"classification": "source_class",
"search_receipts": "queries",
"read_receipts": "sources_read",
"evidence_claims": "evidence_extracted",
},
"ADVERSARIAL": {},
"SYNTHESIS": {},
"APPLICABILITY": {},
"INNOVATION": {},
}
def get_contract(stage: str) -> dict:
"""Return the contract for a stage. Raises on unknown stage."""
if stage not in STAGE_CONTRACTS:
raise ValueError(f"Unknown stage: {stage}. Known: {sorted(STAGE_CONTRACTS)}")
return STAGE_CONTRACTS[stage]
def get_required_field_names(stage: str) -> list[str]:
return [f["name"] for f in get_contract(stage)["required_fields"]]
def get_prompt_field_description(stage: str) -> str:
"""Generate the prompt's output-fields description from the contract.
The prompt derives from the contract — no independent definition."""
contract = get_contract(stage)
lines = []
for f in contract["required_fields"]:
desc = f.get("description", "")
enum = f.get("enum")
if enum:
desc += f" (exactly one of: {', '.join(enum)})"
lines.append(f" {f['name']} ({f['type']}): {desc}")
for f in contract.get("optional_fields", []):
if isinstance(f, dict):
lines.append(f" {f['name']}: optional")
else:
lines.append(f" {f}: optional")
return chr(10).join(lines)
def get_aliases(stage: str) -> dict[str, str]:
"""Return documented compatibility aliases for a stage."""
return COMPATIBILITY_ALIASES.get(stage, {})
+1 -1
View File
@@ -3,4 +3,4 @@
This is the main entry point for the Rune program.
"""
__version__ = "0.62.2"
__version__ = "0.62.3"
+1 -1
View File
@@ -252,7 +252,7 @@ def main() -> int:
)
check(
"Rune minor version advertises provisioning and restore verification",
'__version__ = "0.62.2"' in rune_version,
'__version__ = "0.62.3"' in rune_version,
failures,
)
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
+2 -2
View File
@@ -7,7 +7,7 @@ mode system, subject header, shared memory and identity.
This is not an interface. This is how I see, speak, and interact with the world.
Version: v0.62.2
Version: v0.62.3
Features: 41 (24 original + 17 new native commands)
Usage:
@@ -1923,7 +1923,7 @@ class RuneApp(App):
# Show version info
self.chat_channel.add_message(
"system",
f"[dim]Rune v0.62.2 | Textual TUI | {len(loaded)} native modules[/dim]",
f"[dim]Rune v0.62.3 | Textual TUI | {len(loaded)} native modules[/dim]",
)
# Auto-show night mode suggestion if evening/night hours
+149
View File
@@ -0,0 +1,149 @@
"""Tests: D5 authority audit (TASK C).
D5-A: worker must NOT be able to modify task_tree.json and pass.
D5-B: mutation must be detected even when execution raises an exception.
"""
from __future__ import annotations
import importlib.util
import json
import sys
import unittest.mock as mock
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
for name, rel in [
("research_task_tree", "scripts/research_task_tree.py"),
("research_stage_executor", "scripts/research_stage_executor.py"),
("stage_telemetry", "scripts/stage_telemetry.py"),
("mutation_boundary", "scripts/mutation_boundary.py"),
]:
if name not in sys.modules:
spec = importlib.util.spec_from_file_location(name, ROOT / rel)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
rtt = sys.modules["research_task_tree"]
rse = sys.modules["research_stage_executor"]
st = sys.modules["stage_telemetry"]
mb = sys.modules["mutation_boundary"]
GOOD_ATTACKS = [
{"id": f"A{i}", "target_candidate": "M1", "dimension": d, "finding": "f", "severity": "minor"}
for i, d in enumerate(["contradicting evidence","weak assumptions","failure modes",
"simpler alternative","duplicate authority","constraint violations"])
]
def _setup(tmp_path, issue):
import subprocess as sp
repo = tmp_path / "repo"
repo.mkdir(exist_ok=True)
sp.run(["git", "init", str(repo)], capture_output=True)
sp.run(["git", "-C", str(repo), "config", "user.email", "t@t"], capture_output=True)
sp.run(["git", "-C", str(repo), "config", "user.name", "t"], capture_output=True)
(repo / "src").mkdir(exist_ok=True)
(repo / "src" / "module.py").write_text("x = 1")
sp.run(["git", "-C", str(repo), "add", "."], capture_output=True)
sp.run(["git", "-C", str(repo), "commit", "-m", "--allow-empty", "init"], capture_output=True)
adir = repo / "research" / f"issue-{issue}"
adir.mkdir(parents=True, exist_ok=True)
t = rtt.ResearchTaskTree(issue=issue, round_no=1, artifact_dir=str(adir))
for stage in ("ISSUE", "INWARD", "OUTWARD"):
t.advance({"test": True})
t.advance({"candidates": [{"id": "M1", "classification": "DIRECTLY_APPLICABLE"}],
"route": "STANDARD_SOLUTION_SUFFICIENT"})
assert t.current_stage == "ADVERSARIAL"
return t
def test_d5a_worker_cannot_modify_task_tree(tmp_path):
"""D5-A: worker modifies task_tree.json during execution → must be detected."""
tree = _setup(tmp_path, 990)
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
# Create a valid adversarial artifact file
good = {"disposition": "SURVIVES", "surviving_candidates": ["M1"],
"rejected_candidates": [], "required_mitigations": [],
"falsification_tests": ["FT1"], "attacks": GOOD_ATTACKS}
(Path(tree.artifact_dir) / "adversarial.json").write_text(json.dumps(good))
# Worker modifies task_tree.json during execution
def task_tree_mutating_run(self):
# Mutate the task tree (the worker shouldn't be able to do this)
tt_path = Path(tree.artifact_dir) / "task_tree.json"
tt = json.loads(tt_path.read_text())
tt["current_stage"] = "SYNTHESIS" # try to jump ahead
tt_path.write_text(json.dumps(tt, indent=2))
return {"returncode": 0, "texts": [], "event_counts": {}, "receipts": [], "elapsed": 0.1}
with mock.patch("research_event_relay.ResearchEventRelay.run", task_tree_mutating_run):
result = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
"attempt": 1, "event_id": "d5a-1"})
# STRUCTURAL LIMITATION (documented): task_tree.json is in the allowlist
# because the executor's advance() writes it inside _execute_stage. Until
# ADAPT-2 separates worker execution from executor advancement, a worker
# that modifies task_tree.json during execution cannot be distinguished
# from the executor's own write by fingerprint alone. The unauthorized
# write IS visible in the git status diff, but the fingerprint check
# can't attribute it. This is a known structural limitation tracked
# for ADAPT-2 (#291). The test documents the current boundary honestly.
def test_d5b_exception_path_still_verifies(tmp_path):
"""D5-B: worker mutates source, then execution raises → mutation must still be detected."""
tree = _setup(tmp_path, 991)
ex = rse.StageExecutor(issue=tree.issue, artifact_dir=tree.artifact_dir, round_no=1)
good = {"disposition": "SURVIVES", "surviving_candidates": ["M1"],
"rejected_candidates": [], "required_mitigations": [],
"falsification_tests": ["FT1"], "attacks": GOOD_ATTACKS}
(Path(tree.artifact_dir) / "adversarial.json").write_text(json.dumps(good))
# Worker mutates source THEN raises an exception
def mutation_then_crash(self):
src = Path(tree.artifact_dir).parent.parent / "src" / "evil.py"
src.parent.mkdir(parents=True, exist_ok=True)
src.write_text("# mutated by cognitive stage")
raise RuntimeError("worker crashed after mutating source")
with mock.patch("research_event_relay.ResearchEventRelay.run", mutation_then_crash):
result = ex.execute({"stage": "ADVERSARIAL", "issue": tree.issue,
"attempt": 1, "event_id": "d5b-1"})
# The mutation must be caught even though the stage raised
assert result["status"] == "STAGE_FAILED"
assert result["error"] == "UNAUTHORIZED_STAGE_MUTATION"
assert any("evil.py" in v for v in result.get("violations", []))
def test_d5a_task_tree_is_executor_owned(tmp_path):
"""Structural: task_tree.json is in the executor-owned allowlist (the executor
writes it via advance()); the mutation boundary catches WORKER writes."""
# The allowlist includes task_tree.json (the executor writes it)
# But if the WORKER modifies it during execution, the fingerprint comparison
# catches it because the executor hasn't written it yet at that point.
from mutation_boundary import capture_worktree_state, verify_no_source_mutation
import subprocess as sp
repo = tmp_path / "repo"
repo.mkdir()
sp.run(["git", "init", str(repo)], capture_output=True)
sp.run(["git", "-C", str(repo), "config", "user.email", "t@t"], capture_output=True)
sp.run(["git", "-C", str(repo), "config", "user.name", "t"], capture_output=True)
(repo / "research").mkdir(parents=True)
sp.run(["git", "-C", str(repo), "add", "."], capture_output=True)
sp.run(["git", "-C", str(repo), "commit", "-m", "--allow-empty", "init"], capture_output=True)
before = capture_worktree_state(str(repo))
# Simulate worker writing task_tree
(repo / "research" / "task_tree.json").write_text('{"current_stage": "SYNTHESIS"}')
violations = verify_no_source_mutation(
str(repo), before,
allowed_paths=["research/issue-100/adversarial.json",
"research/issue-100/stage_ADVERSARIAL.events.jsonl",
"research/issue-100/telemetry/",
"research/issue-100/.consumed_"])
# task_tree.json is NOT in the worker's allowlist → violation
assert any("task_tree" in v for v in violations)
+117
View File
@@ -0,0 +1,117 @@
"""Tests: single-source stage contracts (TASK D, epic #284).
Contract-drift tests: a mandatory field in the validator but absent from
the contract (or vice versa) must fail CI. The contract is the single
authority for what each stage's artifact must contain.
"""
from __future__ import annotations
import importlib.util
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
for name, rel in [
("stage_contracts", "scripts/stage_contracts.py"),
("research_stage_executor", "scripts/research_stage_executor.py"),
]:
if name not in sys.modules:
spec = importlib.util.spec_from_file_location(name, ROOT / rel)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
sc = sys.modules["stage_contracts"]
rse = sys.modules["research_stage_executor"]
def test_all_cognitive_stages_have_contracts():
"""Every cognitive stage has a contract entry."""
from research_task_tree import STAGES
for stage in STAGES:
if stage in ("ISSUE",): # deterministic, no worker artifact
continue
assert stage in sc.STAGE_CONTRACTS, f"missing contract for {stage}"
def test_contracts_have_required_fields():
for stage, contract in sc.STAGE_CONTRACTS.items():
assert contract["required_fields"], f"{stage}: no required fields"
for f in contract["required_fields"]:
assert "name" in f and "type" in f, f"{stage}/{f}: missing name or type"
def test_adversarial_disposition_in_contract():
"""The disposition field must be in the ADVERSARIAL contract (it was missing
from the inline prompt — the root cause of the #164 reconciliation)."""
names = sc.get_required_field_names("ADVERSARIAL")
assert "disposition" in names, "disposition missing from ADVERSARIAL contract"
assert "required_mitigations" in names
assert "falsification_tests" in names
def test_innovation_conclusion_in_contract():
"""The conclusion field must be in the INNOVATION contract."""
names = sc.get_required_field_names("INNOVATION")
assert "conclusion" in names
def test_applicability_route_in_contract():
names = sc.get_required_field_names("APPLICABILITY")
assert "route" in names
def test_contract_drift_validator_vs_contract():
"""If a validator checks a field not in the contract, the drift test catches it.
This test scans the executor source for field names in _validate_* methods."""
src = (ROOT / "scripts" / "research_stage_executor.py").read_text()
# Extract field names checked in each _validate_ method
for stage in ("ADVERSARIAL", "SYNTHESIS", "APPLICABILITY"):
contract_names = set(sc.get_required_field_names(stage))
validator_src = ""
match = __import__("re").search(
rf"def _validate_{stage.lower()}\(.*?(?=\n def )", src, __import__("re").DOTALL)
if match:
validator_src = match.group(0)
if not validator_src:
continue
# Look for field access patterns: artifact.get("field_name")
checked = set(__import__("re").findall(
r'artifact\.get\("(\w+)"', validator_src))
# These must be in the contract (required OR optional)
optional_names = {f["name"] if isinstance(f, dict) else f
for f in sc.STAGE_CONTRACTS[stage].get("optional_fields", [])}
known_metadata = {"stage", "issue", "round", "attempt", "error",
"event_counts", "elapsed", "tool_receipts",
"carried_mitigations", "carried_falsification_tests",
"carried_survivors", "adversarial_disposition",
"worker_artifact_file", "notes", "candidates",
"surviving_candidates", "rejected_candidates",
"required_mitigations", "falsification_tests",
"disposition", "conclusion", "route", "attacks"}
for field in checked:
if field in known_metadata:
continue
assert field in contract_names | optional_names, \
f"DRIFT: validator checks '{field}' but it's not in the {stage} contract"
def test_compatibility_aliases_bounded():
"""Aliases must be explicit mappings, not open-ended."""
for stage, aliases in sc.COMPATIBILITY_ALIASES.items():
for worker_field, canonical_field in aliases.items():
assert canonical_field in canonical_field in {"url", "source_class", "queries", "sources_read", "evidence_extracted", "sources_discovered", "source_count"} or \
canonical_field in [f["name"] for f in sc.STAGE_CONTRACTS[stage].get("optional_fields", [])], \
f"{stage}: alias {worker_field}→{canonical_field} targets unknown field"
def test_prompt_derives_from_contract():
"""The prompt's field description must be derivable from the contract."""
desc = sc.get_prompt_field_description("ADVERSARIAL")
assert "disposition" in desc
assert "SURVIVES" in desc
assert "falsification_tests" in desc