Skip to content

Commit f62001a

Browse files
committed
[guard.research.ρ.D8+] close crash windows and preview authority gaps
The durable loop had stable request-key machinery, but the exact side-effect-before-state-commit windows were not executable evidence, and persisted previews were only rechecked for freshness rather than against their immutable Artifact, frozen template mapping, current command contract, and remaining budget. This could turn an accidental persisted mutation into authority the model never had. Add restart fault injection around reason submission, provider completion, preview persistence, command Job persistence, and physical RunSet persistence; prove repeated attempts reuse one provider/dispatch identity. Preserve negative/refused history, prohibit stale or non-typed facts from claiming evidence eligibility in the schema, and freeze the updated prompt digest. Dispatch now rejects preview digest, template/command, consequence, acknowledgement, command schema/version, budget, and fingerprint drift. Repair latent DiracInternal construction on schema failure so fail-closed paths remain typed instead of throwing TypeError.
1 parent a9d35a5 commit f62001a

11 files changed

Lines changed: 1228 additions & 252 deletions

‎backend/research/action_compiler.py‎

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,9 @@ def compile(
162162
self.preview_validator.iter_errors(preview), key=lambda error: list(error.path))
163163
if errors:
164164
error = errors[0]
165-
raise failures.DiracInternal(
166-
"compiled ActionPreview violates its frozen contract",
167-
details={"path": list(error.path), "message": error.message})
165+
raise failures.DiracInternal(ValueError(
166+
"compiled ActionPreview violates its frozen contract at "
167+
f"{list(error.path)}: {error.message}"))
168168
encoded = canonical_bytes(preview)
169169
return CompiledAction(
170170
preview=preview, preview_bytes=encoded,
@@ -184,6 +184,16 @@ def revalidate(
184184
clock = now or datetime.now(timezone.utc)
185185
expected_acks = set(preview.get("required_acknowledgements") or [])
186186
failures_seen = []
187+
preview_errors = sorted(
188+
self.preview_validator.iter_errors(preview),
189+
key=lambda error: list(error.path),
190+
)
191+
if preview_errors:
192+
failures_seen.append("preview_contract")
193+
expected_preview_digest = compiled.get("preview_artifact_sha256")
194+
if (not isinstance(expected_preview_digest, str)
195+
or canonical_digest(dict(preview)) != expected_preview_digest):
196+
failures_seen.append("preview_artifact_digest")
187197
preview_version = int(preview.get("loop_version", -1))
188198
loop_version = int(loop["version"])
189199
approved_per_action = (
@@ -205,9 +215,56 @@ def revalidate(
205215
if actor.get("kind") != "human" or not str(actor.get("id") or "").strip():
206216
failures_seen.append("human_actor")
207217
resolved = preview.get("resolved_command")
208-
if resolved and canonical_digest(dict(compiled.get("command_input") or {})) != resolved.get(
209-
"input_digest"):
210-
failures_seen.append("command_input_digest")
218+
template = self.actions.get(str(preview.get("template_id") or ""))
219+
if template is None:
220+
failures_seen.append("action_template")
221+
else:
222+
expected_command_id = template["execution"]["command_id"]
223+
actual_command_id = (
224+
resolved.get("command_id") if isinstance(resolved, Mapping) else None)
225+
if actual_command_id != expected_command_id:
226+
failures_seen.append("resolved_command")
227+
consequence = preview.get("consequence") or {}
228+
expected_consequence = template["consequence"]
229+
if any(consequence.get(key) != expected_consequence[key]
230+
for key in ("risk_class", "approval", "reversible")):
231+
failures_seen.append("consequence_policy")
232+
expected_required_acks = (
233+
{"physical_fep_compute", "completed_unvalidated_claim_boundary"}
234+
if expected_consequence["risk_class"] == "R3" else set())
235+
if expected_acks != expected_required_acks:
236+
failures_seen.append("required_acknowledgements")
237+
try:
238+
self._check_budget(
239+
loop, preview.get("estimate") or {}, str(preview["template_id"]))
240+
except failures.DiracBudgetExceeded:
241+
failures_seen.append("budget")
242+
command_input = dict(compiled.get("command_input") or {})
243+
if resolved:
244+
if canonical_digest(command_input) != resolved.get("input_digest"):
245+
failures_seen.append("command_input_digest")
246+
try:
247+
definition = self.commands.get(str(resolved.get("command_id") or ""))
248+
self.commands.validate_input(definition, command_input)
249+
if definition.version != resolved.get("command_version"):
250+
failures_seen.append("command_version")
251+
except failures.DiracFailure:
252+
failures_seen.append("command_contract")
253+
elif command_input:
254+
failures_seen.append("non_executing_command_input")
255+
fingerprint_payload = {
256+
"template_id": preview.get("template_id"),
257+
"subject_ref": preview.get("subject_ref"),
258+
"scientific_question": preview.get("scientific_question"),
259+
"command_version": (
260+
resolved.get("command_version") if isinstance(resolved, Mapping) else None),
261+
"input_digest": (
262+
resolved.get("input_digest") if isinstance(resolved, Mapping)
263+
else canonical_digest({})),
264+
"source_versions": dict(preview.get("source_versions") or {}),
265+
}
266+
if canonical_digest(fingerprint_payload) != preview.get("action_fingerprint"):
267+
failures_seen.append("action_fingerprint")
211268
if failures_seen:
212269
raise failures.DiracStalePreview(
213270
"scientific context changed after the action preview was created",

‎backend/research/context_builder.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ def build(self, loop: Mapping[str, Any],
6161
}
6262
missing = sorted(required_domain - set(domain))
6363
if missing:
64-
raise failures.DiracInternal(
65-
"FEP adapter returned an incomplete research context",
66-
details={"missing": missing})
64+
raise failures.DiracInternal(ValueError(
65+
"FEP adapter returned an incomplete research context; missing="
66+
+ ",".join(missing)))
6767
facts = self._ordered_facts(domain["facts"])
6868
base = {
6969
"schema_version": "1.0",
@@ -128,9 +128,9 @@ def _validate(self, document: Mapping[str, Any]) -> None:
128128
errors = sorted(self.validator.iter_errors(document), key=lambda error: list(error.path))
129129
if errors:
130130
first = errors[0]
131-
raise failures.DiracInternal(
132-
"deterministic FEP context violates its frozen schema",
133-
details={"path": list(first.path), "message": first.message})
131+
raise failures.DiracInternal(ValueError(
132+
"deterministic FEP context violates its frozen schema at "
133+
f"{list(first.path)}: {first.message}"))
134134

135135
@staticmethod
136136
def _ordered_facts(value: Any) -> list[dict[str, Any]]:

‎backend/research/loop_summary.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def build(
5656
key=lambda error: list(error.path))
5757
if errors:
5858
first = errors[0]
59-
raise failures.DiracInternal(
60-
"research loop summary violates its frozen schema",
61-
details={"path": list(first.path), "message": first.message})
59+
raise failures.DiracInternal(ValueError(
60+
"research loop summary violates its frozen schema at "
61+
f"{list(first.path)}: {first.message}"))
6262
return canonical_bytes(document)

‎backend/tests/test_research_action_catalog.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ def test_catalog_is_bounded_complete_and_model_projection_hides_commands(self):
2525
projection = json.dumps(catalog.to_model_catalog())
2626
self.assertNotIn("command_id", projection)
2727
self.assertNotIn("physics.rbfe", projection)
28+
resolved_commands = {
29+
template["execution"]["command_id"]
30+
for template in catalog.values()
31+
if template["execution"]["command_id"] is not None
32+
}
33+
self.assertEqual(resolved_commands, {
34+
"physics.rbfe-run.start",
35+
"physics.rbfe-system.prepare",
36+
"physics.rbfe-network",
37+
})
38+
self.assertFalse(any(command.startswith("program.")
39+
for command in resolved_commands))
2840

2941
def test_registry_refuses_unlisted_or_id_mismatched_templates(self):
3042
with tempfile.TemporaryDirectory() as temporary:

‎backend/tests/test_research_action_compiler.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def test_stale_source_version_expiry_and_input_tamper_all_fail_closed(self):
9696
loop=current_loop, context={"digest": "sha256:" + "e" * 64},
9797
proposal=proposal(), now=datetime(2026, 8, 18, 1, tzinfo=timezone.utc))
9898
pending = {"preview": dict(compiled.preview),
99+
"preview_artifact_sha256": compiled.preview_digest,
99100
"command_input": dict(compiled.command_input)}
100101
awaiting = {**current_loop, "version": 8}
101102
compiler.revalidate(
@@ -122,6 +123,60 @@ def test_stale_source_version_expiry_and_input_tamper_all_fail_closed(self):
122123
acknowledgements=list(compiled.preview["required_acknowledgements"]),
123124
actor={"kind": "human", "id": "chemist"}, now=clock)
124125

126+
def test_persisted_preview_cannot_be_rewritten_into_a_program_mutation(self):
127+
resolver = Resolver()
128+
compiler = ActionCompiler(resolver)
129+
current_loop = loop()
130+
compiled = compiler.compile(
131+
loop=current_loop, context={"digest": "sha256:" + "e" * 64},
132+
proposal=proposal(), now=datetime(2026, 8, 18, 1, tzinfo=timezone.utc))
133+
awaiting = {**current_loop, "version": 8}
134+
pending = {
135+
"preview": deepcopy(dict(compiled.preview)),
136+
"preview_artifact_sha256": compiled.preview_digest,
137+
"command_input": dict(compiled.command_input),
138+
}
139+
pending["preview"]["resolved_command"]["command_id"] = (
140+
"program.hypothesis.create")
141+
142+
with self.assertRaises(failures.DiracStalePreview) as caught:
143+
compiler.revalidate(
144+
pending, loop=awaiting,
145+
current_context_digest="sha256:" + "e" * 64,
146+
current_source_versions=resolver.source_versions,
147+
acknowledgements=list(compiled.preview["required_acknowledgements"]),
148+
actor={"kind": "human", "id": "chemist"},
149+
now=datetime(2026, 8, 18, 1, 1, tzinfo=timezone.utc))
150+
witnesses = caught.exception.details["stale_witnesses"]
151+
self.assertIn("preview_artifact_digest", witnesses)
152+
self.assertIn("resolved_command", witnesses)
153+
self.assertIn("command_contract", witnesses)
154+
155+
def test_budget_reduction_invalidates_a_previously_affordable_preview(self):
156+
resolver = Resolver()
157+
compiler = ActionCompiler(resolver)
158+
current_loop = loop()
159+
compiled = compiler.compile(
160+
loop=current_loop, context={"digest": "sha256:" + "e" * 64},
161+
proposal=proposal(), now=datetime(2026, 8, 18, 1, tzinfo=timezone.utc))
162+
pending = {
163+
"preview": dict(compiled.preview),
164+
"preview_artifact_sha256": compiled.preview_digest,
165+
"command_input": dict(compiled.command_input),
166+
}
167+
awaiting = deepcopy({**current_loop, "version": 8})
168+
awaiting["budget_remaining"]["fep_runsets"] = 0
169+
170+
with self.assertRaises(failures.DiracStalePreview) as caught:
171+
compiler.revalidate(
172+
pending, loop=awaiting,
173+
current_context_digest="sha256:" + "e" * 64,
174+
current_source_versions=resolver.source_versions,
175+
acknowledgements=list(compiled.preview["required_acknowledgements"]),
176+
actor={"kind": "human", "id": "chemist"},
177+
now=datetime(2026, 8, 18, 1, 1, tzinfo=timezone.utc))
178+
self.assertIn("budget", caught.exception.details["stale_witnesses"])
179+
125180
def test_template_outside_frozen_grant_cannot_compile(self):
126181
current = loop()
127182
current["policy"]["session_grant"]["allowed_template_ids"] = []

‎backend/tests/test_research_context_builder.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from copy import deepcopy
66
from uuid import uuid4
77

8+
import failures
9+
810
from research.context_builder import ContextBuilder
911

1012

@@ -115,3 +117,42 @@ def test_untrusted_content_cannot_add_actions_or_change_claim_boundary(self):
115117
row = next(item for item in built.document["facts"] if item["fact_id"] == "high")
116118
self.assertFalse(row["claim_boundary"]["eligible_as_scientific_evidence"])
117119
self.assertNotIn("command_id", json.dumps(built.document["available_actions"]))
120+
121+
def test_negative_and_human_refused_actions_survive_context_construction(self):
122+
source = domain()
123+
source["action_history"] = [
124+
{
125+
"action_fingerprint": "sha256:" + "4" * 64,
126+
"template_id": "fep.run_selected_edge.v1",
127+
"subject_ref": ref("free_energy_transformation", "edge-negative"),
128+
"result": "failed_negative_result",
129+
"human_rejected": False,
130+
},
131+
{
132+
"action_fingerprint": "sha256:" + "5" * 64,
133+
"template_id": "fep.defer_for_experiment.v1",
134+
"subject_ref": ref("free_energy_transformation", "edge-refused"),
135+
"result": "rejected",
136+
"human_rejected": True,
137+
},
138+
]
139+
140+
built = ContextBuilder().build(loop(), source)
141+
142+
self.assertEqual(built.document["action_history"], source["action_history"])
143+
144+
def test_unverified_or_stale_fact_cannot_be_marked_eligible_evidence(self):
145+
for source_class, stale in (
146+
("unverified_external", False),
147+
("method_result", False),
148+
("typed_evidence", True),
149+
):
150+
with self.subTest(source_class=source_class, stale=stale):
151+
source = domain()
152+
unsafe = fact("unsafe")
153+
unsafe["source_class"] = source_class
154+
unsafe["freshness"]["stale"] = stale
155+
unsafe["claim_boundary"]["eligible_as_scientific_evidence"] = True
156+
source["facts"] = [unsafe]
157+
with self.assertRaises(failures.DiracInternal):
158+
ContextBuilder().build(loop(), source)

0 commit comments

Comments
 (0)