From ed374ac4ce21019e04315e1218e0bfc3f0463174 Mon Sep 17 00:00:00 2001 From: Red Queen Date: Wed, 26 Aug 2026 05:20:38 +0000 Subject: [PATCH] fix(c5b): persist human-review object changes + read content_before from parent commit - C5A objects table: object_id nullable + reason_code column so HUMAN_REVIEW/ SECRET_DETECTED object changes (object_id=None) are persisted, not silently dropped (was: object_id TEXT NOT NULL, no reason_code field) - C5B poll_once: content_before now read from parent_sha (state BEFORE the change) instead of sha, so MODIFIED changes classify as CONTENT_UPDATE instead of being misclassified (Checker-Befund) - test_c5b: add test_modified_content_reads_parent regression test C5B 40/40, C5A 25/25, real-repo dry run: 233 object changes (109 IN_SCOPE with valid id, 124 HUMAN_REVIEW), idempotent. --- tolaria/c5-sync-service/rq_c5a.py | 19 ++++++++++--------- tolaria/c5-sync-service/rq_c5b.py | 9 ++++++--- tolaria/c5-sync-service/test_c5b.py | 21 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/tolaria/c5-sync-service/rq_c5a.py b/tolaria/c5-sync-service/rq_c5a.py index 11b28ab..ec3ab0a 100644 --- a/tolaria/c5-sync-service/rq_c5a.py +++ b/tolaria/c5-sync-service/rq_c5a.py @@ -280,7 +280,7 @@ class C5AStore: CREATE TABLE IF NOT EXISTS objects ( id INTEGER PRIMARY KEY AUTOINCREMENT, commit_sha TEXT NOT NULL, - object_id TEXT NOT NULL, + object_id TEXT, path_before TEXT, path_after TEXT, operation TEXT NOT NULL, @@ -290,6 +290,7 @@ class C5AStore: metadata_hash_after TEXT, representation TEXT, state TEXT, + reason_code TEXT, UNIQUE(commit_sha, object_id, operation) ) """) @@ -472,7 +473,7 @@ class C5AStore: # -- Objects ------------------------------------------------------------ - def add_object_change(self, obj: Dict[str, Any]) -> Dict[str, Any]: + def add_object_change(self, obj: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Fügt eine Object-Change hinzu (idempotent per commit_sha+object_id+operation).""" if obj.get("operation") not in OPERATIONS: raise InvalidOperationError(f"Unbekannte Operation: {obj.get('operation')}") @@ -481,22 +482,22 @@ class C5AStore: """ INSERT OR IGNORE INTO objects (commit_sha, object_id, path_before, path_after, operation, content_hash_before, content_hash_after, - metadata_hash_before, metadata_hash_after, representation, state) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + metadata_hash_before, metadata_hash_after, representation, state, reason_code) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - obj["commit_sha"], obj["object_id"], obj.get("path_before"), + obj["commit_sha"], obj.get("object_id"), obj.get("path_before"), obj.get("path_after"), obj["operation"], obj.get("content_hash_before"), obj.get("content_hash_after"), obj.get("metadata_hash_before"), obj.get("metadata_hash_after"), - obj.get("representation"), obj.get("state"), + obj.get("representation"), obj.get("state"), obj.get("reason_code"), ), ) - return self.get_object_change(obj["commit_sha"], obj["object_id"], obj["operation"]) + return self.get_object_change(obj["commit_sha"], obj.get("object_id"), obj["operation"]) - def get_object_change(self, commit_sha: str, object_id: str, operation: str) -> Optional[Dict[str, Any]]: + def get_object_change(self, commit_sha: str, object_id: Optional[str], operation: str) -> Optional[Dict[str, Any]]: row = self._conn.execute( - "SELECT * FROM objects WHERE commit_sha = ? AND object_id = ? AND operation = ?", + "SELECT * FROM objects WHERE commit_sha = ? AND object_id IS ? AND operation = ?", (commit_sha, object_id, operation), ).fetchone() return dict(row) if row else None diff --git a/tolaria/c5-sync-service/rq_c5b.py b/tolaria/c5-sync-service/rq_c5b.py index 819a828..c94df20 100644 --- a/tolaria/c5-sync-service/rq_c5b.py +++ b/tolaria/c5-sync-service/rq_c5b.py @@ -663,15 +663,18 @@ class C5BPoller: # Diff gegen Parent changes = self.reader.diff_name_status(sha) + parent_sha = meta.get("parent_sha") object_changes = [] for ch in changes: path_before: Optional[str] = ch.get("path_before") path_after: Optional[str] = ch.get("path_after") - # Inhalte read-only lesen + # Inhalte read-only lesen. + # content_before stammt aus dem PARENT-Commit (der Zustand VOR der + # Aenderung), content_after aus dem aktuellen Commit (sha). content_before: Optional[str] = None content_after: Optional[str] = None - if path_before: - content_before = self.reader.file_content(sha, path_before) + if path_before and parent_sha: + content_before = self.reader.file_content(parent_sha, path_before) if path_after: content_after = self.reader.file_content(sha, path_after) diff --git a/tolaria/c5-sync-service/test_c5b.py b/tolaria/c5-sync-service/test_c5b.py index 3a5c192..ddd75b8 100644 --- a/tolaria/c5-sync-service/test_c5b.py +++ b/tolaria/c5-sync-service/test_c5b.py @@ -374,6 +374,27 @@ class TestC5BPoller(unittest.TestCase): finally: fx.cleanup() + def test_modified_content_reads_parent(self): + """Regression: content_before muss aus dem PARENT-Commit gelesen werden, + nicht aus dem aktuellen Commit. Sonst wird ein MODIFIED-Change faelschlich + als METADATA_UPDATE/kein CONTENT_UPDATE klassifiziert (Checker-Befund).""" + fx = FixtureRepo() + try: + fx.write("modul-09.md", _fm("object/77b02661-d67b-4bae-b612-01d1287cea6b") + "body v1") + fx.commit("c1") + fx.write("modul-09.md", _fm("object/77b02661-d67b-4bae-b612-01d1287cea6b") + "body v2") + sha2 = fx.commit("c2") + store = self._new_store() + poller = C5BPoller(store, fx.dir) + r = poller.poll_once() + self.assertEqual(r["commits_discovered"], 2) + objs = store.list_object_changes(sha2) + ops = [o["operation"] for o in objs] + self.assertIn(OP_CONTENT_UPDATE, ops, f"erwartet CONTENT_UPDATE, habe {ops}") + store.close() + finally: + fx.cleanup() + def test_lost_poll_recovery(self): fx = FixtureRepo() try: