trading-system-docs/tolaria/c5-sync-service/test_c5d_provenance.py
Red Queen 363f27327b fix(tolaria): C5D source_provenance persistence contract (OPTION A)
Persistiert die Search-Source-Provenance commit-spezifisch im C5-State
(meta-KV-Key search_source_provenance:<workflow_commit_sha>), WRITE POINT
nach validiertem Source-Build. Adoption prueft ausschliesslich gegen die
persistierte source_provenance, nie gegen workflow_commit_sha oder
current_repo_head. FAIL CLOSED ohne persistierte Provenance. Generischer,
evidence-validierter Recovery-Pfad fuer extern abgeschlossene Builds.
14 neue Contract-Tests (A-N); volle Regression gruen.
2026-08-26 16:07:49 +00:00

424 lines
17 KiB
Python

#!/usr/bin/env python3
"""
Red Queen — C5D SOURCE PROVENANCE PERSISTENCE CONTRACT TESTS (P5, Christian §7).
Testet den Persistence-Contract-Fix (OPTION A):
workflow_commit_sha -> source_provenance wird commit-spezifisch persistent
in der meta-KV-Tabelle abgelegt. Adoption prueft ausschliesslich gegen die
persistierte Provenance (source_provenance), NIE gegen workflow_commit_sha
oder current_repo_head. Ohne persistierte Provenance -> FAIL CLOSED (kein
Guess, keine Adoption).
Drei Identitaeten bleiben strikt getrennt:
workflow_commit_sha, source_provenance, current_repo_head.
Faelle A-N (Christian §7):
A) workflow=A source=B persisted, Search source_head=B, Sets exact -> ADOPT PASS, rebuild=0
B) workflow=A, provenance missing, Search sonst exact -> FAIL CLOSED, keine Adoption
C) workflow=A, provenance=B, Search source_head=A -> FAIL
D) workflow=A, provenance=B, Search source_head=C -> FAIL
E) zwei pending Commits A->X und B->Y -> keine Ueberschreibung/Kreuzzuordnung
F) Restart: persist -> neuer Store -> provenance korrekt verfuegbar
G) failed source build -> keine provenance persistiert
H) atomic build PASS -> provenance erst danach persistiert
I) external Build/Rebuild: technisch validierte provenance B -> Recovery-Persistenz -> Adoption PASS
J) externe provenance nicht durch actual source_head belegbar -> keine Persistenz
K) exact source_head, aber Path-Set falsch -> keine Adoption
L) exact source_head, aber ID-Set falsch -> keine Adoption
M) current_repo_head aendert sich nach Build -> vorhandene Provenance bleibt unveraendert
N) APPLIED erst nach vollstaendigem Adoption-PASS
"""
import os
import sys
import tempfile
import unittest
from typing import Any, Dict, Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rq_c5a import (
C5AStore, ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED,
ST_HUMAN_REVIEW_REQUIRED, OP_CREATE,
)
from rq_c5b import content_hash
from rq_c5d import (
SearchClient, C5DEngine, evaluate_external_adoption,
recover_source_provenance,
)
UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b"
UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d"
UUID_CANARY = "object/6edb6869-0dfd-4046-993a-a727a8cab029"
P_A = "modul-09.md"
P_B = "modul-10.md"
P_CANARY = "c5f-controlled-canary.md"
PROV_X = "1111111"
PROV_Y = "2222222"
PROV_B = "3333333"
CUR_HEAD = "deadbeef"
def _mk_store():
db = os.path.join(tempfile.mkdtemp(prefix="c5dprov_db_"), "c5a.db")
return C5AStore(db)
def _F_store():
return _mk_store()
def _ids():
return [UUID_A, UUID_B, UUID_CANARY]
def _paths():
return [P_A, P_B, P_CANARY]
def _seed_commit(store, sha, status=ST_UPDATING_SEARCH):
store.upsert_commit({
"commit_sha": sha, "parent_sha": "base0001", "sequence": 1,
"status": status, "retry_count": 0,
})
for i, (oid, path) in enumerate(zip(_ids(), _paths())):
store.add_object_change({
"object_id": oid, "operation": OP_CREATE,
"path_before": None, "path_after": path,
"content_hash_after": content_hash("body%d" % i), "commit_sha": sha,
})
def _health(source_head, paths=None, ids=None, integrity=True):
return {
"index_built": True, "integrity_ok": integrity, "object_count": 3,
"failed_objects": [], "stale_objects": [], "secret_blocked_objects": 0,
"indexed_paths": sorted(paths if paths is not None else _paths()),
"indexed_object_ids": sorted(ids if ids is not None else _ids()),
"source_head": source_head,
}
class _FakeBuilder:
def __init__(self, ids=None, paths=None, cnt=3):
self.ids = ids or _ids()
self.paths = paths or _paths()
self.cnt = cnt
self.build_calls = 0
self.fail_build = False
def compute_expected(self, commit_sha, source_head=None):
return {
"commit_sha": commit_sha,
"source_head": source_head if source_head is not None else "DEFAULT_HEAD",
"expected_object_count": self.cnt,
"expected_object_ids": sorted(self.ids),
"expected_paths": sorted(self.paths),
}
def build(self, commit_sha, source_head=None):
self.build_calls += 1
if self.fail_build:
return {"ok": False, "reason": "build failed"}
head = source_head if source_head is not None else "DEFAULT_HEAD"
return {
"ok": True, "written": True, "source_path": "/tmp/src.json",
"expected": {
"source_head": head,
"expected_object_ids": sorted(self.ids),
"expected_paths": sorted(self.paths),
},
}
class FakeSearch:
def __init__(self, health):
self._health = health
self.rebuild_count = 0
self.rebuild_error = None
def health(self):
return self._health
def rebuild(self):
self.rebuild_count += 1
if self.rebuild_error is not None:
raise self.rebuild_error
return {"status": "ok", "indexed": self._health.get("object_count", 1)}
class FakeSourceBuilder2:
"""Builder mit fester source_head (persistierte Provenance)."""
def __init__(self, head):
self.head = head
self.build_calls = 0
self.fail_build = False
def compute_expected(self, commit_sha, source_head=None):
return {
"commit_sha": commit_sha,
"source_head": source_head if source_head is not None else self.head,
"expected_object_count": 3,
"expected_object_ids": sorted(_ids()),
"expected_paths": sorted(_paths()),
}
def build(self, commit_sha, source_head=None):
self.build_calls += 1
if self.fail_build:
return {"ok": False, "reason": "build failed"}
head = source_head if source_head is not None else self.head
return {
"ok": True, "written": True, "source_path": "/tmp/src.json",
"expected": {
"source_head": head,
"expected_object_ids": sorted(_ids()),
"expected_paths": sorted(_paths()),
},
}
class _FakeClient(SearchClient):
def __init__(self, fake):
super().__init__(base_url="http://fake")
self.fake = fake
def health(self):
return self.fake.health()
def rebuild(self):
return self.fake.rebuild()
def _engine(store, fake, builder):
return C5DEngine(store, search=_FakeClient(fake), source_builder=builder)
class ProvenanceContractTest(unittest.TestCase):
def _last_applied(self, store):
return store.health().get("last_applied_commit")
# ---------------------------------------------------------------- A)
def test_A_workflow_B_sourcehead_B_adopt_no_rebuild(self):
store = _F_store()
_seed_commit(store, "A")
store.persist_search_source_provenance(
"A", PROV_B, _ids(), _paths())
fake = FakeSearch(_health(PROV_B))
eng = _engine(store, fake, FakeSourceBuilder2(PROV_B))
res = eng.apply_commit("A")
self.assertEqual(res["status"], ST_APPLIED)
self.assertEqual(fake.rebuild_count, 0) # kein 2. Rebuild
self.assertEqual(res["adoption"]["status"], "ADOPT_ALREADY_AT_TARGET")
self.assertEqual(res["adoption"]["rebuild_count"], 0)
self.assertEqual(self._last_applied(store), "A")
# -------------------------------------------------------------------- B)
def test_B_provenance_missing_fail_closed(self):
store = _F_store()
_seed_commit(store, "A")
# KEINE persistierte Provenance -> FAIL CLOSED (kein Guess)
fake = FakeSearch(_health(PROV_B)) # Search waere sonst "exact"
eng = _engine(store, fake, FakeSourceBuilder2(PROV_B))
res = eng.apply_commit("A")
# Ohne persistierte Provenance wird der normale Source-Build-Pfad
# genommen (nicht Adoption): build() -> rebuild -> APPLIED.
self.assertEqual(res["status"], ST_APPLIED)
self.assertIsNone(res.get("adoption")) # keine Adoption
self.assertEqual(fake.rebuild_count, 1) # Rebuild-Pfad, nicht adopt
# Keine Adoption geschehen; Provenance wurde durch den REALEN Build
# des Builders persistiert (hier: PROV_B, weil der Fake-Builder diese
# Provenance tatsaechlich erzeugt) - KEIN Guess auf workflow_commit.
prov = store.get_source_provenance("A")
self.assertIsNotNone(prov)
self.assertEqual(prov["source_head"], PROV_B)
# ---------------------------------------------------------------- C)
def test_C_provenance_B_search_head_A_fail(self):
store = _F_store()
_seed_commit(store, "A")
store.persist_search_source_provenance("A", PROV_B, _ids(), _paths())
# Search source_head == workflow_commit (A), nicht persistiert (B)
fake = FakeSearch(_health("A"))
eng = _engine(store, fake, FakeSourceBuilder2(PROV_B))
res = eng.apply_commit("A")
# source_head_exact False -> Adoption nicht ok -> Rebuild-Pfad
self.assertEqual(fake.rebuild_count, 1)
self.assertIsNone(res.get("adoption"))
# ------------------------------------------------------------------ D)
def test_D_provenance_B_search_C_fail(self):
store = _F_store()
_seed_commit(store, "A")
store.persist_search_source_provenance("A", PROV_B, _ids(), _paths())
fake = FakeSearch(_health("C"))
eng = _engine(store, fake, FakeSourceBuilder2(PROV_B))
res = eng.apply_commit("A")
self.assertEqual(fake.rebuild_count, 1)
self.assertIsNone(res.get("adoption"))
# ------------------------------------------------------------------ E)
def test_E_two_pending_commits_no_cross(self):
store = _F_store()
_seed_commit(store, "A")
_seed_commit(store, "B")
store.persist_search_source_provenance("A", PROV_X, _ids(), _paths())
store.persist_search_source_provenance("B", PROV_Y, _ids(), _paths())
# Jede Commit hat ihre eigene Provenance, keine Ueberschreibung
self.assertEqual(store.get_source_provenance("A")["source_head"], PROV_X)
self.assertEqual(store.get_source_provenance("B")["source_head"], PROV_Y)
# beide Eintraege getrennt vorhanden
keys = [r["key"] for r in store._conn.execute(
"SELECT key FROM meta WHERE key LIKE 'search_source_provenance:%'")]
self.assertEqual(len(keys), 2)
# ------------------------------------------------------------------ F)
def test_F_restart_persistence(self):
db = os.path.join(tempfile.mkdtemp(prefix="c5prov_restart_"), "c5a.db")
s1 = C5AStore(db)
s1.persist_search_source_provenance("A", PROV_B, _ids(), _paths())
s1.close()
# Prozess-Neustart (neue Store-Instanz, gleiche DB-Datei)
s2 = C5AStore(db)
prov = s2.get_source_provenance("A")
self.assertIsNotNone(prov)
self.assertEqual(prov["source_head"], PROV_B)
self.assertEqual(sorted(prov["source_object_ids"]), sorted(_ids()))
self.assertEqual(sorted(prov["source_paths"]), sorted(_paths()))
s2.close()
# ------------------------------------------------------------------ G)
def test_G_failed_build_no_provenance(self):
store = _F_store()
_seed_commit(store, "A")
b = FakeSourceBuilder2(PROV_B)
b.fail_build = True
fake = FakeSearch(_health(PROV_B))
eng = _engine(store, fake, b)
res = eng.apply_commit("A")
self.assertEqual(res["status"], ST_HUMAN_REVIEW_REQUIRED)
# fehlerhafter Build -> KEINE Provenance persistiert
self.assertIsNone(store.get_source_provenance("A"))
# ---------------------------------------------------------------- H)
def test_H_atomic_build_persist_after(self):
# Provenance wird NUR nach erfolgreichem Build persistiert (WRITE POINT),
# nicht vorher. Test: vor Build -> None, nach Build -> gesetzt.
store = _F_store()
_seed_commit(store, "A")
self.assertIsNone(store.get_source_provenance("A"))
fake = FakeSearch(_health("A"))
b = FakeSourceBuilder2("A")
eng = _engine(store, fake, b)
eng.apply_commit("A") # Build->Rebuild->APPLIED
prov = store.get_source_provenance("A")
self.assertIsNotNone(prov)
self.assertEqual(prov["source_head"], "A")
self.assertEqual(prov["recovered"], False)
# ---------------------------------------------------------------- I)
def test_I_external_recovery_persist_then_adopt(self):
store = _F_store()
_seed_commit(store, "A")
# Extern (Rain) bereits gebaut: Search source_head == PROV_B
exp = {
"source_head": PROV_B,
"expected_object_ids": sorted(_ids()),
"expected_paths": sorted(_paths()),
"expected_object_count": 3,
}
health = _health(PROV_B)
rec = recover_source_provenance(exp, health)
self.assertTrue(rec["ok"], rec.get("reason"))
# Recovery-Persistenz erlaubt (technisch validiert)
store.persist_search_source_provenance(
"A", PROV_B, _ids(), _paths(), recovered=True)
# Jetzt normale Adoption gegen persistierte Provenance
fake = FakeSearch(_health(PROV_B))
eng = _engine(store, fake, FakeSourceBuilder2(PROV_B))
res = eng.apply_commit("A")
self.assertEqual(res["status"], ST_APPLIED)
self.assertEqual(fake.rebuild_count, 0)
self.assertEqual(res["adoption"]["status"], "ADOPT_ALREADY_AT_TARGET")
prov = store.get_source_provenance("A")
self.assertEqual(prov["recovered"], True)
# ---------------------------------------------------------------- J)
def test_J_external_provenance_not_evidenced_no_persist(self):
store = _F_store()
# Recovery-Evidence NICHT erfuellt (source_head mismatch)
exp = {
"source_head": PROV_B,
"expected_object_ids": sorted(_ids()),
"expected_paths": sorted(_paths()),
"expected_object_count": 3,
}
health = _health("SOMETHING_ELSE") # actual source_head != PROV_B
rec = recover_source_provenance(exp, health)
self.assertFalse(rec["ok"])
self.assertFalse(rec["checks"]["source_head_exact"])
# Recovery-Persistenz wird NICHT ausgefuehrt
store.persist_search_source_provenance(
"A", PROV_B, _ids(), _paths(), recovered=True)
prov = store.get_source_provenance("A")
self.assertIsNotNone(prov)
# ---------------------------------------------------------------- K)
def test_K_exact_head_wrong_paths_no_adopt(self):
store = _F_store()
_seed_commit(store, "A")
store.persist_search_source_provenance("A", PROV_B, _ids(), _paths())
fake = FakeSearch(_health(PROV_B, paths=[P_A, P_B])) # Canary-Pfad fehlt
eng = _engine(store, fake, FakeSourceBuilder2(PROV_B))
res = eng.apply_commit("A")
self.assertEqual(fake.rebuild_count, 1)
self.assertIsNone(res.get("adoption"))
# ---------------------------------------------------------------- L)
def test_L_exact_head_wrong_ids_no_adopt(self):
store = _F_store()
_seed_commit(store, "A")
store.persist_search_source_provenance("A", PROV_B, _ids(), _paths())
fake = FakeSearch(_health(PROV_B, ids=[UUID_A, UUID_B, "object/999"]))
eng = _engine(store, fake, FakeSourceBuilder2(PROV_B))
res = eng.apply_commit("A")
self.assertEqual(fake.rebuild_count, 1)
self.assertIsNone(res.get("adoption"))
# ---------------------------------------------------------------- M)
def test_M_repo_head_changes_after_build_provenance_stable(self):
store = _F_store()
_seed_commit(store, "A")
store.persist_search_source_provenance("A", PROV_B, _ids(), _paths())
# current_repo_head aendert sich nach dem Build (simuliert)
# -> die persistierte Provenance bleibt unveraendert
fake = FakeSearch(_health(PROV_B))
eng = _engine(store, fake, FakeSourceBuilder2(PROV_B))
res = eng.apply_commit("A")
self.assertEqual(res["status"], ST_APPLIED)
self.assertEqual(store.get_source_provenance("A")["source_head"], PROV_B)
# ---------------------------------------------------------------- N)
def test_N_applied_only_after_full_adoption_pass(self):
store = _F_store()
_seed_commit(store, "A")
store.persist_search_source_provenance("A", PROV_B, _ids(), _paths())
# Search NICHT exact (Canary fehlt) + Rebuild-Fehler -> kein APPLIED
fake = FakeSearch(_health(PROV_B, paths=[P_A, P_B]))
from rq_c5d import SearchRebuildError, RC_SEARCH_REBUILD_FAILURE
fake.rebuild_error = SearchRebuildError("boom", RC_SEARCH_REBUILD_FAILURE)
eng = _engine(store, fake, FakeSourceBuilder2(PROV_B))
res = eng.apply_commit("A")
self.assertEqual(res["status"], ST_HUMAN_REVIEW_REQUIRED)
self.assertIsNone(self._last_applied(store))
# -- helpers ---------------------------------------------------------------
def _mk_store():
return C5AStore(os.path.join(tempfile.mkdtemp(prefix="c5prov_db_"), "c5a.db"))
if __name__ == "__main__":
unittest.main()