#!/usr/bin/env python3 """ Red Queen — C5 DELETE EXECUTION ENGINE (DeleteExecutor). Schliesst den DELETE_EXECUTION_CONTRACT_GAP: kontrollierter Tolaria-DELETE-Pfad NACH einer expliziten, persistenten Human-Approval. ZIELARCHITEKTUR (verbindlich, C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md): DELETE erkannt (C5B) -> HUMAN_REVIEW_REQUIRED (C5A/C5C, RC_AMBIGUOUS_DELETE) -> explizite Human Approval (CLI approve-delete, persistiert in C5AStore) -> kontrollierter Tolaria DELETE (dieser Executor) -> Read-Back-Verifikation (target absent + kein anderes Objekt veraendert) -> C5D Source Build -> Search Update -> Exact-Set-Verifikation -> APPLIED INVARIANTEN (A-N, verbindlich): A) DELETE bleibt HUMAN_GATED. B) Kein LLM/Agent entscheidet, dass ein DELETE genehmigt ist. C) Approval stammt explizit vom Human Gate (CLI approve-delete). D) Approval ist commit- und objectchange-spezifisch (keine globale DELETE_APPROVED=true-Semantik). E) Approval fuer Objekt A autorisiert nie Objekt B. F) Approval fuer Commit X autorisiert nie DELETE aus Commit Y. G) Approval ist persistent und crash-/restart-fest (SQLite in C5AStore). H) Execution ist idempotent (DELETE_ALREADY_AT_TARGET statt zweitem Write). I) DELETE betrifft ausschliesslich das exakt freigegebene Objekt. J) Kein Pfad nimmt beliebige freie Paths/IDs ohne ObjectChange + Approval. K) Tolaria DELETE wird vor und nach Execution eindeutig verifiziert. L) Keine manuelle SQLite-/SQL-State-Manipulation (nur Store-Methoden). M) Keine direkte APPLIED-Markierung (nur C5D nach vollem Search-PASS). N) Keine automatische Human-Gate-Deaktivierung. NO-MUTATING-PROBE-REGEL (Incident-Lesson): Dieser Executor fuehrt KEINE produktive Endpoint-Probe aus. Der Endpoint-/Payload-Contract (POST /delete mit {"path": ...}) ist aus vorhandenem Tolaria-Code/Router und dokumentierter Incident-Evidence bestimmt. """ from __future__ import annotations from typing import Any, Dict, List, Optional from rq_c5a import ( C5AStore, ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED, ST_DELETING, ST_UPDATING_SEARCH, RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH, RC_UNEXPECTED_TOLARIA_DRIFT, OP_DELETE_REQUEST, ) from rq_c5c import ( TolariaClient, C5CError, TolariaWriteError, TolariaUnavailableError, VAULT_PREFIX, ) # Idempotenz-Ergebnis: Zielobjekt ist bereits korrekt absent -> kein zweiter # destruktiver Write (Invariante H). DELETE_ALREADY_AT_TARGET = "DELETE_ALREADY_AT_TARGET" class DeleteExecutionError(C5CError): """Fehler im C5 DELETE-Execution-Pfad (FAIL CLOSED).""" class DeleteApprovalError(DeleteExecutionError): """Approval fehlt oder passt nicht exakt (Invarianten D/E/F).""" class DeleteExecutor: """ Fuehrt einen kontrollierten Tolaria-DELETE fuer einen ObjectChange aus, der eine persistierte, exakt passende Human-Approval besitzt. NUR ueber den CLI-Befehl `c5-delete execute` (bzw. `replay`) aufrufbar. Kein automatischer DELETE. Kein freier Pfad-Parameter. """ def __init__( self, store: C5AStore, client: Optional[TolariaClient] = None, ): self.store = store self.client = client or TolariaClient() # -- Pre-Delete-Gates (alle MÜSSEN passen, sonst FAIL CLOSED) ----------- def _find_delete_change(self, commit_sha: str) -> Dict[str, Any]: """Findet den DELETE-ObjectChange eines Commits. Genau einer erwartet.""" changes = self.store.list_object_changes(commit_sha) deletes = [c for c in changes if c.get("operation") == OP_DELETE_REQUEST] if len(deletes) != 1: raise DeleteApprovalError( f"Erwarte genau einen DELETE-ObjectChange in {commit_sha}, " f"gefunden: {len(deletes)}", RC_DELETE_APPROVAL_MISMATCH, ) return deletes[0] def _validate_approval( self, commit_sha: str, change: Dict[str, Any], ) -> Dict[str, Any]: """ Validiert, dass eine persistierte Approval exakt zu diesem ObjectChange passt (Invarianten D/E/F). Gibt die Approval zurueck oder wirft DeleteApprovalError (FAIL CLOSED). """ change_id = change.get("id") oid = change.get("object_id") path = change.get("path_before") or change.get("path_after") if change_id is None: raise DeleteApprovalError( f"DELETE-ObjectChange in {commit_sha} hat keine id", RC_DELETE_APPROVAL_MISMATCH, ) approval = self.store.get_delete_approval_for_change(commit_sha, change_id) if approval is None: raise DeleteApprovalError( f"Keine persistierte Human-Approval fuer ObjectChange {change_id} " f"in Commit {commit_sha}", RC_DELETE_APPROVAL_MISSING, ) if approval.get("approval_status") != "APPROVED": raise DeleteApprovalError( f"Approval {approval.get('approval_id')} ist nicht APPROVED " f"(Status: {approval.get('approval_status')})", RC_DELETE_APPROVAL_MISMATCH, ) # Invariante F: Approval bindet exakt diesen Commit. if approval.get("workflow_commit") != commit_sha: raise DeleteApprovalError( f"Approval {approval.get('approval_id')} bindet Commit " f"{approval.get('workflow_commit')}, nicht {commit_sha}", RC_DELETE_APPROVAL_MISMATCH, ) # Invariante E: Approval bindet exakt dieses Objekt. if approval.get("object_id") != oid: raise DeleteApprovalError( f"Approval {approval.get('approval_id')} bindet Objekt " f"{approval.get('object_id')}, nicht {oid}", RC_DELETE_APPROVAL_MISMATCH, ) # Invariante I: Approval bindet exakt diesen Pfad. if approval.get("path") != path: raise DeleteApprovalError( f"Approval {approval.get('approval_id')} bindet Pfad " f"{approval.get('path')}, nicht {path}", RC_DELETE_APPROVAL_MISMATCH, ) return approval def _pre_delete_drift_check(self, vault_path: str) -> None: """ Invariante K (vor): Das Zielobjekt MUSS in Tolaria noch existieren, sonst ist der erwartete aktuelle Zustand verletzt (FAIL CLOSED). """ current = self.client.read(vault_path) if current is None: # Objekt bereits absent -> kein blinder zweiter destruktiver Write. # Der Executor erkennt DELETE_ALREADY_AT_TARGET (Idempotenz, H). raise DeleteExecutionError( f"Zielobjekt {vault_path} ist bereits absent (DELETE_ALREADY_AT_TARGET)", RC_UNEXPECTED_TOLARIA_DRIFT, ) def _read_back_absent(self, vault_path: str) -> bool: """Invariante K (nach): Read-Back -> target absent == TRUE.""" return self.client.read(vault_path) is None def _verify_no_other_change(self, before_paths: set, after_paths: set) -> None: """ Stellt sicher, dass ausschliesslich das freigegebene Objekt entfernt wurde (kein anderes Objekt veraendert). before/after sind die Pfad-Sets aus list(). """ removed = before_paths - after_paths added = after_paths - before_paths if added: raise DeleteExecutionError( f"Unerwartete neue Objekte nach DELETE: {sorted(added)}", RC_UNEXPECTED_TOLARIA_DRIFT, ) # -- Execution ---------------------------------------------------------- def execute(self, commit_sha: str) -> Dict[str, Any]: """ Fuehrt den kontrollierten DELETE fuer einen Commit aus. Pre-Gates (alle MÜSSEN passen, sonst FAIL CLOSED): 1. ObjectChange existiert, operation == DELETE 2. Commit-Status == ST_DELETE_APPROVED 3. Approval existiert, APPROVED, passt exakt (Commit/Objekt/Pfad) 4. Pre-Delete-Drift-Check: Ziel existiert noch Nach DELETE: - Read-Back: target absent == TRUE - kein anderes Objekt veraendert - Approval -> USED (einmalig) - Commit -> ST_UPDATING_SEARCH (C5D uebernimmt Search-Pfad) Idempotenz (H): Wenn das Ziel bereits absent ist, wird KEIN zweiter destruktiver Write erzeugt; der Executor meldet DELETE_ALREADY_AT_TARGET und geht trotzdem zu ST_UPDATING_SEARCH (Zielzustand ist erreicht). """ # Gate 1: ObjectChange existiert + operation == DELETE change = self._find_delete_change(commit_sha) oid = change.get("object_id") path = change.get("path_before") or change.get("path_after") if not path: raise DeleteApprovalError( f"DELETE-ObjectChange {change.get('id')} hat keinen Pfad", RC_DELETE_APPROVAL_MISMATCH, ) vault_path = self._normalize_vault_path(path) # Gate 2: Commit-Status == ST_DELETE_APPROVED status = self.store.commit_status(commit_sha) if status != ST_DELETE_APPROVED: raise DeleteExecutionError( f"Commit {commit_sha} ist nicht DELETE_APPROVED (Status: {status})", RC_DELETE_APPROVAL_MISMATCH, ) # Gate 3: Approval existiert + passt exakt approval = self._validate_approval(commit_sha, change) # Gate 4: Pre-Delete-Drift-Check (Ziel existiert noch) try: self._pre_delete_drift_check(vault_path) except DeleteExecutionError as e: if "DELETE_ALREADY_AT_TARGET" in str(e): # Idempotenz: Ziel bereits korrekt absent. Kein zweiter Write. self.store.mark_delete_approval_used(approval["approval_id"]) self.store.transition_commit(commit_sha, ST_UPDATING_SEARCH) return { "commit_sha": commit_sha, "object_id": oid, "path": path, "status": ST_UPDATING_SEARCH, "idempotency": DELETE_ALREADY_AT_TARGET, "deleted": False, } raise return self._do_delete(commit_sha, change, approval, vault_path, oid, path) def _do_delete( self, commit_sha: str, change: Dict[str, Any], approval: Dict[str, Any], vault_path: str, oid: Optional[str], path: str, ) -> Dict[str, Any]: """ Kern der DELETE-Ausfuehrung (von execute() und replay()/ST_DELETING genutzt). Erwartet: Approval validiert, Ziel existiert noch, Commit in ST_DELETE_APPROVED oder ST_DELETING. Fuehrt den kontrollierten DELETE aus, Read-Back, kein anderes Objekt veraendert, Approval -> USED, Commit -> ST_UPDATING_SEARCH. """ # State: -> ST_DELETING (Crash-Window). Idempotent: wenn bereits # ST_DELETING (Replay nach Crash), keine erneute Transition noetig. if self.store.commit_status(commit_sha) != ST_DELETING: self.store.transition_commit(commit_sha, ST_DELETING) # Vorheriges Pfad-Set (fuer "kein anderes Objekt veraendert") before_paths = {o.get("path") for o in self.client.list(VAULT_PREFIX)} # Execution: kontrollierter Tolaria DELETE try: self.client.delete(vault_path) except (TolariaWriteError, TolariaUnavailableError) as e: # Fehler waehrend Execution -> zurueck zum Human Gate (FAIL CLOSED) self.store.set_commit_error(commit_sha, e.reason_code or RC_UNEXPECTED_TOLARIA_DRIFT, e.message) self.store.transition_commit(commit_sha, ST_HUMAN_REVIEW_REQUIRED) raise DeleteExecutionError( f"Tolaria DELETE fehlgeschlagen fuer {vault_path}: {e.message}", e.reason_code, ) # Read-Back: target absent == TRUE (Invariante K nach) if not self._read_back_absent(vault_path): self.store.set_commit_error( commit_sha, RC_UNEXPECTED_TOLARIA_DRIFT, f"Read-Back nach DELETE: {vault_path} ist nicht absent") self.store.transition_commit(commit_sha, ST_HUMAN_REVIEW_REQUIRED) raise DeleteExecutionError( f"Read-Back nach DELETE: {vault_path} ist nicht absent", RC_UNEXPECTED_TOLARIA_DRIFT, ) # Kein anderes Objekt veraendert (FAIL CLOSED bei Drift) after_paths = {o.get("path") for o in self.client.list(VAULT_PREFIX)} try: self._verify_no_other_change(before_paths, after_paths) except DeleteExecutionError as e: # Drift nach DELETE -> zurueck zum Human Gate (FAIL CLOSED), # NICHT in ST_DELETING haengen lassen. self.store.set_commit_error( commit_sha, e.reason_code or RC_UNEXPECTED_TOLARIA_DRIFT, e.message) self.store.transition_commit(commit_sha, ST_HUMAN_REVIEW_REQUIRED) raise # Approval -> USED (einmalig, verhindert Reuse) self.store.mark_delete_approval_used(approval["approval_id"]) # Commit -> ST_UPDATING_SEARCH (C5D uebernimmt Search-Pfad) self.store.transition_commit(commit_sha, ST_UPDATING_SEARCH) return { "commit_sha": commit_sha, "object_id": oid, "path": path, "status": ST_UPDATING_SEARCH, "idempotency": "EXECUTED", "deleted": True, } def replay(self, commit_sha: str) -> Dict[str, Any]: """ Idempotenter Replay nach Crash (Crash-Faelle B/C/D). - Commit in ST_DELETE_APPROVED: Approval persistiert, DELETE noch nicht ausgefuehrt -> normaler execute(). - Commit in ST_DELETING: DELETE evtl. schon ausgefuehrt -> Read-Back. Objekt absent -> DELETE_ALREADY_AT_TARGET, weiter zu ST_UPDATING_SEARCH. Objekt noch da -> DELETE erneut (Approval gueltig). - Commit in ST_UPDATING_SEARCH: bereits nach DELETE -> nichts zu tun. """ status = self.store.commit_status(commit_sha) if status == ST_UPDATING_SEARCH: return { "commit_sha": commit_sha, "status": ST_UPDATING_SEARCH, "idempotency": "ALREADY_AT_TARGET", "deleted": False, } if status == ST_DELETING: # Crash waehrend DELETE (Fall C/D): Read-Back entscheidet. change = self._find_delete_change(commit_sha) path = change.get("path_before") or change.get("path_after") if not path: raise DeleteApprovalError( f"DELETE-ObjectChange {change.get('id')} hat keinen Pfad", RC_DELETE_APPROVAL_MISMATCH, ) vault_path = self._normalize_vault_path(path) if self._read_back_absent(vault_path): # DELETE war bereits erfolgreich -> Zielzustand erreicht. change_id = change.get("id") approval = None if change_id is not None: approval = self.store.get_delete_approval_for_change( commit_sha, change_id) if approval and approval.get("approval_status") == "APPROVED": self.store.mark_delete_approval_used(approval["approval_id"]) self.store.transition_commit(commit_sha, ST_UPDATING_SEARCH) return { "commit_sha": commit_sha, "object_id": change.get("object_id"), "path": path, "status": ST_UPDATING_SEARCH, "idempotency": DELETE_ALREADY_AT_TARGET, "deleted": False, } # Objekt noch da -> DELETE erneut. Approval validieren + _do_delete. approval = self._validate_approval(commit_sha, change) return self._do_delete( commit_sha, change, approval, vault_path, change.get("object_id"), path) # ST_DELETE_APPROVED (Fall B) oder sonstiger Zustand -> normaler execute return self.execute(commit_sha) @staticmethod def _normalize_vault_path(path: str) -> str: """ Normalisiert einen Vault-Pfad unter VAULT_PREFIX (kein Traversal). SECURITY (Todo 11): Blockiert `..`-Traversal-Segmente und absolute Pfade ausserhalb des Vaults. Der Pfad stammt zwar aus dem ObjectChange (nicht frei), aber Defense-in-Depth verhindert, dass ein manipulierter ObjectChange-Pfad ein beliebiges Dateisystem-Ziel loeschen kann. """ p = path.replace("\\", "/").lstrip("/") # Traversal-Segmente hart blockieren (FAIL CLOSED). segments = [s for s in p.split("/") if s not in ("", ".")] if any(s == ".." for s in segments): raise DeleteExecutionError( f"Pfad-Traversal blockiert: {path!r}", RC_DELETE_APPROVAL_MISMATCH, ) if p.startswith(VAULT_PREFIX.lstrip("/")): return "/" + p return f"{VAULT_PREFIX}/{p}"