Skip to content

Commit 5ba4699

Browse files
committed
[guard.research.ρ.D8+] prove the durable loop across the real dispatch fence
The first real PostgreSQL acceptance stopped before provider I/O because migration 047 admitted only campaign preparation Jobs; memory-backed controller tests could not reveal that database-enforced boundary. Admit only research.loop.create as the second fenced dispatcher, require the new migration at startup, and keep shutdown from resurrecting timer work. The dynamic fake provider, repeatable PostgreSQL/security tests, and acceptance harness belong to the same action because they are the external witness that this exact fence change yields one deduplicated create-to-approval-to-RunSet-to-refresh-to-stop loop while secrets and cross-tenant artifacts remain inaccessible. Real Qwen endpoints and production migration remain deliberately outside this commit.
1 parent 6a4aa24 commit 5ba4699

9 files changed

Lines changed: 699 additions & 8 deletions
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
-- 050 · Admit the research-loop reasoner into the durable local dispatch fence.
2+
--
3+
-- 047 deliberately admitted only physics.rbfe-campaign.prepare. The research
4+
-- loop submits ai.research.propose under research.loop.create with the same
5+
-- client-key/outbox/fencing protocol; without this explicit second command the
6+
-- loop reaches its first context snapshot and then blocks before provider I/O.
7+
BEGIN;
8+
9+
CREATE OR REPLACE FUNCTION app.enforce_job_dispatch_fence()
10+
RETURNS trigger
11+
LANGUAGE plpgsql
12+
AS $$
13+
BEGIN
14+
IF TG_OP = 'DELETE' THEN
15+
IF EXISTS (
16+
SELECT 1 FROM app.job j
17+
WHERE j.id = OLD.job_id AND j.state IN ('queued','running')
18+
) THEN
19+
RAISE EXCEPTION USING
20+
ERRCODE = '23514',
21+
CONSTRAINT = 'job_dispatch_active_delete_forbidden',
22+
MESSAGE = 'an active Job dispatch may only leave through terminal cleanup';
23+
END IF;
24+
RETURN OLD;
25+
END IF;
26+
IF TG_OP = 'INSERT' AND NOT EXISTS (
27+
SELECT 1
28+
FROM app.job j
29+
WHERE j.id = NEW.job_id
30+
AND j.request_key IS NOT NULL
31+
AND j.command_id IN (
32+
'physics.rbfe-campaign.prepare',
33+
'research.loop.create')
34+
AND j.input_sha256 = NEW.payload_sha256
35+
AND j.request_digest = NEW.execution_digest
36+
AND j.state IN ('queued','running')
37+
) THEN
38+
RAISE EXCEPTION USING
39+
ERRCODE = '23514',
40+
CONSTRAINT = 'job_dispatch_requires_keyed_active_job',
41+
MESSAGE = 'job_dispatch requires an allow-listed active client-key Job';
42+
END IF;
43+
IF TG_OP = 'UPDATE' AND NEW.fencing_token < OLD.fencing_token THEN
44+
RAISE EXCEPTION USING
45+
ERRCODE = '23514',
46+
CONSTRAINT = 'job_dispatch_token_monotone',
47+
MESSAGE = 'job_dispatch fencing_token may not decrease';
48+
END IF;
49+
IF TG_OP = 'UPDATE' AND (
50+
NEW.job_id IS DISTINCT FROM OLD.job_id
51+
OR NEW.payload IS DISTINCT FROM OLD.payload
52+
OR NEW.payload_sha256 IS DISTINCT FROM OLD.payload_sha256
53+
OR NEW.execution_digest IS DISTINCT FROM OLD.execution_digest
54+
OR NEW.execution_adapter IS DISTINCT FROM OLD.execution_adapter
55+
) THEN
56+
RAISE EXCEPTION USING
57+
ERRCODE = '23514',
58+
CONSTRAINT = 'job_dispatch_witness_immutable',
59+
MESSAGE = 'job_dispatch admitted witness columns are immutable';
60+
END IF;
61+
IF TG_OP = 'UPDATE' AND (
62+
NEW.lease_owner IS DISTINCT FROM OLD.lease_owner
63+
OR NEW.claimed_at IS DISTINCT FROM OLD.claimed_at
64+
OR (OLD.state = 'running' AND NEW.state = 'claimed')
65+
) AND NEW.fencing_token <= OLD.fencing_token THEN
66+
RAISE EXCEPTION USING
67+
ERRCODE = '23514',
68+
CONSTRAINT = 'job_dispatch_reclaim_requires_new_token',
69+
MESSAGE = 'dispatch ownership/reclaim changes require a newer token';
70+
END IF;
71+
RETURN NEW;
72+
END;
73+
$$;
74+
75+
COMMENT ON FUNCTION app.enforce_job_dispatch_fence() IS
76+
'Rejects non-allow-listed/non-keyed witnesses, mutation, active deletion, and token rollback.';
77+
78+
INSERT INTO meta.migration (filename,sha256,content_sha256,hash_source)
79+
VALUES ('050_research_loop_dispatch.sql','\x5a923ab42cbd8a64f9117319e2823bdf38bd7ab2d305c544ea8ab90cb637c0b2'::bytea,
80+
'\x5a923ab42cbd8a64f9117319e2823bdf38bd7ab2d305c544ea8ab90cb637c0b2'::bytea,'content')
81+
ON CONFLICT (filename) DO NOTHING;
82+
83+
COMMIT;

‎backend/kernel.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -532,10 +532,16 @@ def build(*, dsn: str = DEFAULT_DSN, with_versions: bool = True,
532532
"SELECT to_regclass('app.research_loop_state') IS NOT NULL, "
533533
"to_regclass('app.research_loop_event') IS NOT NULL, "
534534
"to_regclass('app.research_loop_approval') IS NOT NULL, "
535-
"to_regclass('app.research_loop_artifact') IS NOT NULL")
535+
"to_regclass('app.research_loop_artifact') IS NOT NULL, "
536+
"EXISTS (SELECT 1 FROM meta.migration "
537+
" WHERE filename='050_research_loop_dispatch.sql'), "
538+
"position('research.loop.create' in pg_get_functiondef("
539+
" 'app.enforce_job_dispatch_fence()'::regprocedure)) > 0")
536540
loop_capability = cur.fetchone()
537541
if loop_capability is None or not all(loop_capability):
538-
raise RuntimeError('migration 049_research_loop.sql is not fully applied')
542+
raise RuntimeError(
543+
'migrations 049_research_loop.sql and '
544+
'050_research_loop_dispatch.sql are not fully applied')
539545
research_loops = ResearchLoopController(
540546
repository=ResearchLoopRepository(connect), service=svc,
541547
artifact_store=st, provider_registry=ai_providers,

‎backend/research/loop_controller.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def __init__(
4848
self._lock = threading.Lock()
4949
self._running = False
5050
self._wake_requested = False
51+
self._shutdown = False
5152
self._timer: threading.Timer | None = None
5253

5354
def create(self, payload: Mapping[str, Any], actor: Mapping[str, Any]) -> dict[str, Any]:
@@ -241,6 +242,8 @@ def control(self, payload: Mapping[str, Any], actor: Mapping[str, Any]) -> dict[
241242

242243
def wake(self) -> None:
243244
with self._lock:
245+
if self._shutdown:
246+
return
244247
if self._timer is not None:
245248
self._timer.cancel()
246249
self._timer = None
@@ -252,6 +255,8 @@ def wake(self) -> None:
252255

253256
def shutdown(self) -> None:
254257
with self._lock:
258+
self._shutdown = True
259+
self._wake_requested = False
255260
if self._timer is not None:
256261
self._timer.cancel()
257262
self._timer = None
@@ -274,11 +279,12 @@ def _drain(self) -> None:
274279
requested = self._wake_requested
275280
self._wake_requested = False
276281
self._running = False
277-
if not requested and delay is not None:
282+
shutdown = self._shutdown
283+
if not shutdown and not requested and delay is not None:
278284
self._timer = threading.Timer(max(0.05, delay), self.wake)
279285
self._timer.daemon = True
280286
self._timer.start()
281-
if requested:
287+
if requested and not shutdown:
282288
self.wake()
283289

284290
def _advance(self, claim: LoopClaim) -> None:

‎backend/tests/test_openai_compatible_provider.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,29 @@ def test_valid_response_returns_only_bounded_public_fields(self):
6464
self.assertNotIn("reasoning", repr(result))
6565
self.assertNotIn("test-secret-never-leak", repr(result))
6666

67+
def test_action_mode_binds_to_the_request_context_digest(self):
68+
self.server.mode = "action"
69+
result = self.provider.complete_json(
70+
self.profile,
71+
system_prompt="Return one bounded JSON object and never call tools.",
72+
context_json=("JSON research context:\n" + json.dumps({
73+
"research_context": {
74+
"digest": "sha256:" + "9" * 64,
75+
"facts": [],
76+
"available_actions": [{
77+
"template_id": "fep.run_selected_edge.v1",
78+
"subject_refs": [{
79+
"kind": "free_energy_transformation", "id": "edge-a-b",
80+
}],
81+
}],
82+
},
83+
})),
84+
)
85+
proposal = json.loads(result.content)
86+
self.assertEqual(proposal["context_digest"], "sha256:" + "9" * 64)
87+
self.assertEqual(proposal["candidate_actions"][0]["subject_ref"]["id"],
88+
"edge-a-b")
89+
6790
def test_reasoning_content_is_discarded(self):
6891
self.server.mode = "reasoning"
6992
result = self.complete()

‎backend/tests/test_research_loop_controller.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,17 @@ def test_prepare_prerequisite_resumes_run_without_second_reasoner_call(self):
171171
self.assertNotIn("resume_candidate_after_snapshot",
172172
snapshot_transition["updates"]["outputs"])
173173

174+
def test_shutdown_prevents_a_late_timer_from_resubmitting_work(self):
175+
controller = self.controller()
176+
controller._lock = __import__("threading").Lock()
177+
controller._shutdown = True
178+
controller._running = False
179+
controller._wake_requested = False
180+
controller._timer = None
181+
controller._pool = SimpleNamespace(
182+
submit=lambda *_args: self.fail("shutdown controller submitted work"))
183+
controller.wake()
184+
174185

175186
if __name__ == "__main__":
176187
unittest.main()

‎backend/tests/test_research_loop_repository.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
ROOT = pathlib.Path(__file__).resolve().parents[2]
2626
MIGRATION = ROOT / "backend/db/migrations/049_research_loop.sql"
27+
DISPATCH_MIGRATION = ROOT / "backend/db/migrations/050_research_loop_dispatch.sql"
2728

2829

2930
class ResearchLoopMigrationContractTests(unittest.TestCase):
@@ -51,18 +52,35 @@ def test_recorded_migration_hash_matches_normalized_file(self):
5152
normalized = source.replace(recorded, "PENDING")
5253
self.assertEqual(hashlib.sha256(normalized.encode()).hexdigest(), recorded)
5354

55+
def test_forward_dispatch_migration_admits_only_prepare_and_research_loop(self):
56+
source = DISPATCH_MIGRATION.read_text(encoding="utf-8")
57+
self.assertIn("'physics.rbfe-campaign.prepare'", source)
58+
self.assertIn("'research.loop.create'", source)
59+
self.assertIn("j.command_id IN", source)
60+
marker = "VALUES ('050_research_loop_dispatch.sql','\\x"
61+
recorded = source.split(marker, 1)[1][:64]
62+
normalized = source.replace(recorded, "PENDING")
63+
self.assertEqual(hashlib.sha256(normalized.encode()).hexdigest(), recorded)
64+
5465

5566
@unittest.skipUnless(os.environ.get("DIRAC_TEST_DSN") and PG_DRIVER,
56-
"requires isolated PostgreSQL DIRAC_TEST_DSN with migrations 000-049")
67+
"requires isolated PostgreSQL DIRAC_TEST_DSN with migrations 000-050")
5768
class ResearchLoopRepositoryPostgresTests(unittest.TestCase):
5869
@classmethod
5970
def setUpClass(cls):
6071
cls.dsn = os.environ["DIRAC_TEST_DSN"]
6172
cls.connect = staticmethod(lambda: PG_DRIVER.connect(cls.dsn))
6273
cls.repository = ResearchLoopRepository(cls.connect)
63-
cls.actor = {"kind": "human", "id": "chemist-loop-test"}
6474
with cls.connect() as conn, conn.cursor() as cur:
75+
cur.execute("SELECT current_database()")
76+
if "test" not in str(cur.fetchone()[0]).lower():
77+
raise RuntimeError("repository tests refuse a database without 'test' in its name")
78+
cur.execute(
79+
"UPDATE app.research_loop_state SET state='cancelled',stage='completed',"
80+
"lease_owner=NULL,lease_expires_at=NULL,finished_at=coalesce(finished_at,now()) "
81+
"WHERE state IN ('active','waiting_approval','blocked','paused')")
6582
code = "RL-" + uuid.uuid4().hex[:12]
83+
cls.actor = {"kind": "human", "id": code}
6684
cur.execute(
6785
"INSERT INTO design.project(code,name) VALUES (%s,%s) RETURNING id",
6886
(code, "Research loop repository test"),
@@ -154,6 +172,8 @@ def test_durable_create_lease_transition_approval_and_restart_read(self):
154172
cur.execute(
155173
"INSERT INTO app.artifact(blob_sha256,media_type,role,size_bytes) "
156174
"VALUES (%s,'application/json','research.action_preview',%s) "
175+
"ON CONFLICT (blob_sha256,role,encoding) DO UPDATE "
176+
"SET role=EXCLUDED.role "
157177
"RETURNING id", (preview_digest, len(preview)),
158178
)
159179
preview_artifact_id = str(cur.fetchone()[0])
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
import pathlib
6+
import tempfile
7+
import unittest
8+
import uuid
9+
10+
import failures
11+
from artifacts_pg import PostgresArtifactStore
12+
from research.loop_repository import ResearchLoopRepository
13+
from research.provider_registry import FileAiProviderRegistry
14+
15+
try:
16+
import psycopg
17+
except ImportError: # pragma: no cover - integration environment decides
18+
psycopg = None
19+
20+
21+
ROOT = pathlib.Path(__file__).resolve().parents[2]
22+
EXAMPLE = json.loads(
23+
(ROOT / "deploy/ai/providers.example.json").read_text(encoding="utf-8")
24+
)
25+
26+
27+
@unittest.skipUnless(os.environ.get("DIRAC_TEST_DSN") and psycopg,
28+
"requires isolated PostgreSQL DIRAC_TEST_DSN with migrations 000-050")
29+
class ResearchLoopSecurityTests(unittest.TestCase):
30+
@classmethod
31+
def setUpClass(cls):
32+
cls.dsn = os.environ["DIRAC_TEST_DSN"]
33+
cls.connect = staticmethod(lambda: psycopg.connect(cls.dsn))
34+
with cls.connect() as conn, conn.cursor() as cur:
35+
cur.execute("SELECT current_database()")
36+
if "test" not in str(cur.fetchone()[0]).lower():
37+
raise RuntimeError("security tests refuse a database without 'test' in its name")
38+
code = "SEC-" + uuid.uuid4().hex[:10]
39+
cls.actor = {"kind": "human", "id": code}
40+
cur.execute(
41+
"INSERT INTO design.project(code,name) VALUES (%s,%s) RETURNING id",
42+
(code, "Research-loop security test"),
43+
)
44+
cls.program_id = str(cur.fetchone()[0])
45+
cur.execute(
46+
"INSERT INTO design.campaign "
47+
"(program_id,name,objective,created_by_kind,created_by_id) "
48+
"VALUES (%s,%s,%s,'human',%s) RETURNING id",
49+
(cls.program_id, code, "verify private loop surfaces", code),
50+
)
51+
cls.campaign_id = str(cur.fetchone()[0])
52+
cls.repository = ResearchLoopRepository(cls.connect)
53+
cls.loop = cls.repository.create(
54+
request_key="security-loop", program_id=cls.program_id,
55+
campaign_id=cls.campaign_id, actor=cls.actor,
56+
intent="Verify tenant isolation and provider-secret redaction",
57+
autonomy_class="A2", provider_profile_id="qwen-local-isolated",
58+
provider_profile_digest=b"p" * 32,
59+
prompt_release_id="fep-action-proposal-v1",
60+
prompt_release_digest=b"r" * 32,
61+
action_catalog_digest=b"a" * 32, data_classification="restricted",
62+
policy={"cloud_egress_approved": False},
63+
budget_remaining={"reasoner_calls": 1, "fep_runsets": 0},
64+
)
65+
cls.store = PostgresArtifactStore(cls.connect)
66+
cls.artifact = cls.store.put(
67+
b'{"claim_boundary":"model_proposal_not_scientific_evidence"}',
68+
role="research.context_snapshot", media_type="application/json",
69+
metadata={"visibility": "private"},
70+
)
71+
cls.repository.link_artifact(
72+
run_id=cls.loop["run_id"], artifact_id=cls.artifact.id,
73+
role="research.context_snapshot", data_classification="restricted",
74+
)
75+
with cls.connect() as conn, conn.cursor() as cur:
76+
cur.execute(
77+
"UPDATE app.research_loop_state SET state='paused' WHERE run_id=%s",
78+
(cls.loop["run_id"],),
79+
)
80+
81+
def test_loop_artifact_uuid_and_digest_are_not_authorization_capabilities(self):
82+
owner = self.store.read_authorized(self.artifact.id, self.actor)
83+
self.assertEqual(owner[1],
84+
b'{"claim_boundary":"model_proposal_not_scientific_evidence"}')
85+
intruder = {"kind": "human", "id": "intruder-" + uuid.uuid4().hex}
86+
for address in (self.artifact.id, "sha256:" + self.artifact.sha256):
87+
with self.subTest(address=address), self.assertRaises(failures.DiracNotFound):
88+
self.store.read_authorized(address, intruder)
89+
90+
def test_provider_secret_is_absent_from_every_loop_persisted_surface(self):
91+
secret = "research-loop-secret-" + uuid.uuid4().hex
92+
profile = dict(EXAMPLE["profiles"][1])
93+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", encoding="utf-8") as source:
94+
json.dump({"profiles": [profile]}, source)
95+
source.flush()
96+
registry = FileAiProviderRegistry(source.name, environ={
97+
"DIRAC_LOCAL_QWEN_BASE_URL": "http://127.0.0.1:8000/v1",
98+
"DIRAC_LOCAL_QWEN_API_KEY": secret,
99+
})
100+
resolved = registry.resolve("qwen-local-isolated")
101+
public_surfaces = {
102+
"repr": repr(resolved),
103+
"public": resolved.to_public_dict(),
104+
"attestation": registry.attest(
105+
resolved.profile_id, resolved.profile_digest, "restricted"),
106+
}
107+
self.assertNotIn(secret, json.dumps(public_surfaces, sort_keys=True))
108+
109+
with self.connect() as conn, conn.cursor() as cur:
110+
cur.execute(
111+
"SELECT row_to_json(ls)::text FROM app.research_loop_state ls "
112+
"WHERE run_id=%s UNION ALL "
113+
"SELECT row_to_json(le)::text FROM app.research_loop_event le "
114+
"WHERE run_id=%s UNION ALL "
115+
"SELECT row_to_json(la)::text FROM app.research_loop_artifact la "
116+
"WHERE run_id=%s",
117+
(self.loop["run_id"],) * 3,
118+
)
119+
persisted = "\n".join(str(row[0]) for row in cur.fetchall())
120+
cur.execute(
121+
"SELECT b.bytes,a.metadata::text FROM app.research_loop_artifact la "
122+
"JOIN app.artifact a ON a.id=la.artifact_id "
123+
"JOIN app.blob b ON b.sha256=a.blob_sha256 WHERE la.run_id=%s",
124+
(self.loop["run_id"],),
125+
)
126+
for raw, metadata in cur.fetchall():
127+
persisted += bytes(raw).decode("utf-8", errors="replace") + str(metadata)
128+
self.assertNotIn(secret, persisted)
129+
130+
131+
if __name__ == "__main__":
132+
unittest.main()

0 commit comments

Comments
 (0)