From 3db1c71b6811a6cbf1aad7d5a90880ed3fd25d90 Mon Sep 17 00:00:00 2001 From: Red Queen Date: Wed, 26 Aug 2026 19:22:44 +0000 Subject: [PATCH] C5: Human-Gated DELETE Execution Contract (Todo 7-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DeleteExecutor (rq_c5_delete.py): Pre-Gates, Read-Back, idempotenter replay - TolariaClient.delete() (rq_c5c.py): kontrollierter DELETE, keine Probes - Approval-Store + Reason Codes RC_DELETE_APPROVAL_MISSING/MISMATCH (rq_c5a.py) - CLI: c5-delete-approve/execute/replay/status (rq_c5_cli.py) - C5E: recover()/replay() DELETE-Integration - C5D: verify_integrity prueft secret_blocked_objects (FAIL CLOSED) - Security: Path-Traversal-Block in _normalize_vault_path - Drift nach DELETE -> FAIL CLOSED zurueck zu HUMAN_REVIEW_REQUIRED - Tests: test_c5_delete (19), test_c5_delete_integration (22), test_c5_delete_fresh_checker (17) — alle gruen - ADR: C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md (ACCEPTED) --- ..._DELETE_EXECUTION_ARCHITECTURE_DECISION.md | 298 ++++++++ tolaria/c5-sync-service/rq_c5_cli.py | 160 ++++- tolaria/c5-sync-service/rq_c5_delete.py | 399 +++++++++++ tolaria/c5-sync-service/rq_c5a.py | 134 ++++ tolaria/c5-sync-service/rq_c5c.py | 23 + tolaria/c5-sync-service/rq_c5d.py | 1 + tolaria/c5-sync-service/rq_c5e.py | 26 + tolaria/c5-sync-service/test_c5_delete.py | 479 +++++++++++++ .../test_c5_delete_fresh_checker.py | 420 +++++++++++ .../test_c5_delete_integration.py | 660 ++++++++++++++++++ tolaria/c5-sync-service/test_c5a.py | 6 +- 11 files changed, 2604 insertions(+), 2 deletions(-) create mode 100644 tolaria/c5-sync-service/C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md create mode 100644 tolaria/c5-sync-service/rq_c5_delete.py create mode 100644 tolaria/c5-sync-service/test_c5_delete.py create mode 100644 tolaria/c5-sync-service/test_c5_delete_fresh_checker.py create mode 100644 tolaria/c5-sync-service/test_c5_delete_integration.py diff --git a/tolaria/c5-sync-service/C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md b/tolaria/c5-sync-service/C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md new file mode 100644 index 0000000..814a8d0 --- /dev/null +++ b/tolaria/c5-sync-service/C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md @@ -0,0 +1,298 @@ +# C5 DELETE EXECUTION CONTRACT — ARCHITECTURE DECISION RECORD + +**Status:** ACCEPTED (Christian, OPTION 1 — CONTRACT-ERWEITERUNG, 2026-08-26) +**Scope:** C5 Sync Service — kontrollierter Tolaria-DELETE-Pfad NACH expliziter Human-Freigabe +**Datei:** `tolaria/c5-sync-service/C5_DELETE_EXECUTION_ARCHITECTURE_DECISION.md` + +--- + +## 1. Ausgangslage / DELETE_EXECUTION_CONTRACT_GAP + +STRICT PRE-FLIGHT (read-only) hat den bestehenden DELETE-Contract vollständig analysiert +(`rq_c5a.py`, `rq_c5b.py`, `rq_c5c.py`, `rq_c5d.py`, `rq_c5e.py`, `rq_c5_cli.py`, +`C5AStore`, `TolariaClient`, Tests C5A–C5E). Ergebnis: + +| Owner | Verantwortlich | Status | +|---|---|---| +| DELETE_DETECTION_OWNER | C5B `ChangeClassifier.classify()` (Z.520–523): `path_before && !path_after` → `OP_DELETE_REQUEST` | vorhanden | +| DELETE_HUMAN_GATE_OWNER | C5A `SyncStateMachine.process_commit()` (Z.794–797) + C5C `_propagate_object()` (Z.492–496) → `ST_HUMAN_REVIEW_REQUIRED` + `RC_AMBIGUOUS_DELETE` | vorhanden (terminal) | +| DELETE_APPROVAL_STORAGE | **fehlt** — C5AStore hat keine Approval-Repräsentation (nur `meta`, `commits`, `objects`, `bootstrap`, `health`) | GAP | +| DELETE_EXECUTION_OWNER | **fehlt** — `TolariaClient` hat keine `delete()`; kein Executor | GAP | +| DELETE_VERIFICATION_OWNER | **fehlt für DELETE** — `verify()` nur für Writes (Read-Back+Hash); kein DELETE-absent-Read-Back | GAP | +| DELETE_REPLAY_OWNER | C5E `recover()`/`replay()`: `ST_HUMAN_REVIEW_REQUIRED` → `REC_HUMAN_REVIEW` (kein Executor) | GAP | +| DELETE_APPLIED_OWNER | C5D `apply_commit()` → `ST_APPLIED`+`mark_applied()`, startet nur aus `ST_UPDATING_SEARCH` — DELETE-Commit erreicht das nie | GAP | + +**Kernbefund:** DELETE wird erkannt und zum Human Gate geführt, aber das Gate ist **terminal**. +Es existiert kein Approval-Storage, kein Executor, kein DELETE-Read-Back, kein Replay- und +kein APPLIED-Pfad. Dies ist der `DELETE_EXECUTION_CONTRACT_GAP`, der durch diese Mission +sauber geschlossen wird. + +--- + +## 2. ZIELARCHITEKTUR (verbindlich) + +``` +DELETE erkannt (C5B) + → HUMAN_REVIEW_REQUIRED (C5A/C5C, RC_AMBIGUOUS_DELETE) [bleibt erhalten] + → explizite Human Approval (CLI approve-delete) [NEU] + → Approval persistent/auditierbar (C5AStore) [NEU] + → kontrollierter Tolaria DELETE (DeleteExecutor) [NEU] + → Read-Back-Verifikation (target absent) [NEU] + → C5D Source Build (bestehender Pfad) [wiederverwendet] + → Search Update (bestehender C5D-Pfad) [wiederverwendet] + → Exact-Set-Verifikation (C5D verify_integrity) [wiederverwendet] + → APPLIED (C5D mark_applied) [wiederverwendet] +``` + +**KEIN automatischer DELETE.** Der bestehende Sicherheitsmechanismus +`OP_DELETE_REQUEST → HUMAN_REVIEW_REQUIRED → RC_AMBIGUOUS_DELETE` bleibt unverändert. + +--- + +## 3. Zwingende Invarianten (A–N) und ihre Umsetzung + +| Invariante | Umsetzung | +|---|---| +| **A) DELETE bleibt HUMAN_GATED** | Kein Pfad führt einen DELETE ohne persistierte, explizite Human-Approval aus. `_HUMAN_GATE_OPS` unverändert. | +| **B) Kein LLM/Agent entscheidet, dass DELETE genehmigt ist** | Approval wird AUSSCHLIESSLICH durch den CLI-Befehl `approve-delete` erzeugt (Human Gate). Kein Code erzeugt Approval automatisch. | +| **C) Approval stammt explizit vom Human Gate** | `approve-delete` persistiert `approved_by` (Human-Gate-Provenance) + `approval_nonce`. | +| **D) Approval commit- und objectchange-spezifisch** | Approval bindet exakt `(workflow_commit, object_change_id, object_id, path)`. Keine globale `DELETE_APPROVED=true`-Semantik. | +| **E) Approval für Objekt A nie Objekt B** | Executor validiert `object_id` + `path` gegen die Approval. Abweichung → FAIL CLOSED. | +| **F) Approval für Commit X nie DELETE aus Commit Y** | Approval bindet `workflow_commit`. Executor validiert Commit-SHA. | +| **G) Approval persistent und crash-/restart-fest** | SQLite-Tabelle `delete_approvals` in C5AStore (atomic, WAL, restart-safe). | +| **H) Execution idempotent** | Pre-Delete-Drift-Check + Read-Back. Wenn Ziel bereits absent → `DELETE_ALREADY_AT_TARGET`, kein zweiter destruktiver Write. | +| **I) DELETE nur exakt freigegebenes Objekt** | Executor nimmt `path`/`object_id` ausschließlich aus dem ObjectChange, das durch die Approval gebunden ist. | +| **J) Kein Pfad nimmt beliebige freie Paths/IDs ohne ObjectChange+Approval** | Executor verlangt existierendes ObjectChange (`operation==DELETE`) + exakt passende Approval. Kein freier Pfad-Parameter. | +| **K) Tolaria DELETE vor und nach Execution verifiziert** | Pre-Delete-Drift-Check (Objekt existiert noch) + Post-Read-Back (target absent + kein anderes Objekt verändert). | +| **L) Keine manuelle SQLite-/SQL-State-Manipulation** | Nur Store-Methoden. Kein direktes SQL im Executor. | +| **M) Keine direkte APPLIED-Markierung** | APPLIED nur durch C5D `apply_commit()` nach vollem Search-PASS. Executor markiert NIE APPLIED. | +| **N) Keine automatische Human-Gate-Deaktivierung** | `_HUMAN_GATE_OPS` unverändert; DELETE bleibt Gate-pflichtig. | + +--- + +## 4. APPROVAL CONTRACT + +**Prüfung bestehender State:** C5AStore besitzt KEINE geeignete persistente +Approval-Repräsentation (nur `meta`-KV für source_provenance, `commits`, `objects`, +`bootstrap`, `health`). → **Neuer minimaler auditierbarer Approval-State.** + +### 4.1 Schema (neue Tabelle `delete_approvals` in C5AStore) + +```sql +CREATE TABLE IF NOT EXISTS delete_approvals ( + approval_id TEXT PRIMARY KEY, -- UUID, eindeutig + workflow_commit TEXT NOT NULL, -- Commit-SHA (Invariante F) + object_change_id INTEGER NOT NULL, -- FK auf objects.id (Invariante D/E) + object_id TEXT NOT NULL, -- exakt freigegebenes Objekt (E/I) + path TEXT NOT NULL, -- exakter Vault-Pfad (E/I) + operation TEXT NOT NULL DEFAULT 'DELETE', + approval_status TEXT NOT NULL, -- APPROVED | USED | REVOKED + approved_by TEXT NOT NULL, -- Human-Gate-Provenance (C) + approval_nonce TEXT NOT NULL, -- eindeutig, verhindert Reuse + approved_at INTEGER NOT NULL, + used_at INTEGER, + UNIQUE(workflow_commit, object_change_id, object_id, path) +) +``` + +**Keine Credentials, keine personenbezogenen Daten.** `approved_by` = Human-Gate-Identifier +(z.B. `human:christian`), kein Secret. + +### 4.2 Keying + +Approval ist eindeutig gebunden an `(workflow_commit, object_change_id, object_id, path)`. +Keine globale `DELETE_APPROVED=true`-Semantik. Ein Approval autorisiert GENAU EINEN +ObjectChange in GENAU EINEM Commit. + +### 4.3 CLI-Modell (kein undifferenzierter approve+delete-Blackbox) + +Bevorzugtes Modell (Mission §3): +1. `c5-delete approve --commit --object-id --path ` → persistiert Approval (NUR Approval, KEIN DELETE) +2. `c5-delete execute --commit ` → führt DELETE aus (verlangt persistierte Approval) +3. `c5-delete status --commit ` → read-only Status +4. `c5-delete replay --commit ` → idempotenter Replay nach Crash + +Der Human-Gate-Beweis (persistierte Approval) geht dadurch NIE verloren. + +--- + +## 5. TOLARIA DELETE CLIENT + +`TolariaClient.delete(vault_path)` — minimal, isoliert: + +```python +def delete(self, vault_path: str) -> Dict[str, Any]: + """Loescht ein Vault-Objekt (POST /delete). NUR DeleteExecutor darf rufen.""" + resp = self._post("delete", {"path": vault_path}) + if resp is None: + resp = {} + if "error" in resp: + raise TolariaWriteError(f"Tolaria delete fehlgeschlagen: {resp['error']}", + RC_INVALID_SCHEMA) + return resp +``` + +**Endpoint-/Payload-Contract** wird ausschließlich aus dem vorhandenen Tolaria-Code/Router +und der dokumentierten Incident-Evidence bestimmt (POST `/api/vault/delete` mit +`{"path": ...}`). **KEINE produktive Endpoint-Probe.** Die Incident-Regel gilt verbindlich: +`NO MUTATING HTTP METHOD PROBES DURING PRODUCTION READ-ONLY DISCOVERY`. + +--- + +## 6. DELETE EXECUTION (DeleteExecutor) + +### 6.1 Pre-Delete-Gates (alle MÜSSEN passen, sonst FAIL CLOSED) + +1. ObjectChange existiert (`get_object_change`) +2. `operation == DELETE` +3. Commit-Status == `ST_DELETE_APPROVED` (definierter approved state) +4. Approval existiert, `approval_status == APPROVED`, gehört exakt zu diesem ObjectChange +5. `object_id` stimmt (Approval ↔ ObjectChange) +6. `path` stimmt (Approval ↔ ObjectChange) +7. Pre-Delete-Drift-Check: Tolaria enthält das Objekt noch (erwarteter aktueller Zustand) + +Bei irgendeiner Abweichung → **FAIL CLOSED** (kein DELETE, kein State-Übergang). + +### 6.2 Execution + +- `client.delete(vault_path)` (nur DeleteExecutor) +- **Read-Back:** `client.read(vault_path) is None` → target absent == TRUE +- **Zusätzlich:** `client.list()` — kein anderes Objekt verändert (Set-Vergleich vor/nach) + +### 6.3 Nach erfolgreichem DELETE + +- Approval → `USED` (einmalig, verhindert Reuse) +- Commit → `ST_UPDATING_SEARCH` (C5D übernimmt Search-Pfad) + +--- + +## 7. IDEMPOTENZ / CRASH-RECOVERY (Fälle A–F) + +| Fall | Zustand | Deterministischer Replay-Pfad | +|---|---|---| +| **A) Crash vor Approval** | `ST_HUMAN_REVIEW_REQUIRED`, keine Approval | Nichts zu tun. Kein DELETE. Replay: bleibt Human Gate. | +| **B) Crash nach Approval, vor DELETE** | `ST_DELETE_APPROVED`, Approval persistiert | Replay: Executor erkennt Approval, führt DELETE aus. | +| **C) Crash während DELETE** | `ST_DELETING` | Replay: Read-Back. Objekt noch da → DELETE erneut (Approval gültig). Objekt absent → `DELETE_ALREADY_AT_TARGET`, weiter zu `ST_UPDATING_SEARCH`. | +| **D) Crash nach DELETE, vor Read-Back** | `ST_DELETING` | Replay: Read-Back → absent → weiter zu `ST_UPDATING_SEARCH`. | +| **E) Crash nach Read-Back, vor Search Update** | `ST_UPDATING_SEARCH` | C5D `apply_commit()` übernimmt (bestehender Pfad). | +| **F) Crash nach Search Update, vor APPLIED** | `ST_VERIFYING_SEARCH` | C5D `apply_commit()` übernimmt (bestehender Pfad). | + +**DELETE_ALREADY_AT_TARGET:** Wenn Tolaria das freigegebene Objekt bereits nicht mehr +enthält, erkennt der Executor anhand Approval + erwarteter Identität + Read-Back +`DELETE_ALREADY_AT_TARGET` statt blind erneut zu mutieren. + +--- + +## 8. SEARCH CONTRACT + +Nach erfolgreichem Tolaria-DELETE wird Search NICHT über einen statischen/improvisierten +Pfad aktualisiert. Der bestehende C5D-Pfad wird wiederverwendet: + +``` +Tolaria current verified state (ohne gelöschtes Objekt) + → SearchSourceBuilder.build() (read-only, deterministisch) + → atomic source + Provenance-Persistenz (persist_search_source_provenance) + → Search-Rebuild (search.rebuild) + → Exact-Set-Verifikation (verify_integrity: expected IDs/Paths exakt) + → APPLIED (mark_applied) +``` + +**Kein APPLIED bei Search-Mismatch** (C5D `_handle_failure` → Human Gate / DEAD). + +--- + +## 9. STATE MACHINE CHANGES + +Neue Zustände (minimal, nur für den DELETE-Execution-Pfad): + +- `ST_DELETE_APPROVED = "DELETE_APPROVED"` — nach Approval, vor Execution +- `ST_DELETING = "DELETING"` — während Execution (Crash-Window) + +Neue erlaubte Transitionen (NUR durch DeleteExecutor/Approval auslösbar, nie automatisch): + +``` +(ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED) # nur via expliziter Approval +(ST_DELETE_APPROVED, ST_DELETING) # Executor startet +(ST_DELETING, ST_UPDATING_SEARCH) # nach DELETE + Read-Back → C5D +(ST_DELETING, ST_HUMAN_REVIEW_REQUIRED) # Fehler → zurück zum Gate +(ST_DELETE_APPROVED, ST_HUMAN_REVIEW_REQUIRED) # Revoke/Fehler +``` + +**Keine Änderung** an `_HUMAN_GATE_OPS`, `RC_AMBIGUOUS_DELETE`, oder den bestehenden +Transitions. Der bestehende `OP_DELETE_REQUEST → HUMAN_REVIEW_REQUIRED`-Pfad bleibt +unverändert. + +--- + +## 10. CLI CHANGES + +Neue Befehle in `rq_c5_cli.py`: + +- `c5-delete approve --commit --object-id --path ` → persistiert Approval (NUR Approval) +- `c5-delete execute --commit ` → führt DELETE aus (verlangt persistierte Approval) +- `c5-delete status --commit ` → read-only Status +- `c5-delete replay --commit ` → idempotenter Replay nach Crash + +**Kein undifferenzierter approve+delete-Blackbox-Befehl.** + +--- + +## 11. SECURITY REVIEW (Design-Zusicherungen) + +- **Keine arbitrary path deletion:** Executor nimmt `path` nur aus dem ObjectChange, das durch Approval gebunden ist. +- **Keine path traversal:** `path` wird gegen `VAULT_PREFIX` validiert. +- **Keine ungeprüften freien Delete-Targets:** Kein freier Pfad-Parameter im Executor. +- **Keine Approval-Reuse:** `approval_id` eindeutig; nach Nutzung → `USED`. +- **Keine Cross-Commit-Approval:** Approval bindet `workflow_commit`. +- **Keine Cross-Object-Approval:** Approval bindet `object_id` + `path`. +- **Keine automatische Approval-Eskalation:** Nur expliziter CLI-Befehl erzeugt Approval. +- **Keine Secrets:** Approval speichert keine Credentials/personenbezogenen Daten. +- **Keine Credential-Ausgabe:** Kein Secret im Approval-/Status-Output. +- **Keine manuelle DB-Manipulation:** Nur Store-Methoden. +- **Keine allgemeine DELETE-Automatisierung:** Kein Pfad führt DELETE ohne Approval. + +--- + +## 12. TESTPLAN (mindestens) + +- DELETE ohne Human Approval → BLOCKED +- DELETE mit Approval für anderes Objekt → BLOCKED +- DELETE mit Approval für anderen Commit → BLOCKED +- manipulierte object_id → BLOCKED +- manipulierter path → BLOCKED +- gültiges Approval → exakt ein DELETE +- anderes Objekt bleibt unverändert +- DELETE_ALREADY_AT_TARGET → kein zweiter destruktiver Write +- Restart nach Approval → Approval erhalten +- Crash nach DELETE → Replay sicher +- Search Exact-Set nach DELETE +- Search-Mismatch → kein APPLIED +- Secret Safety +- keine SQL-/State-Bypässe +- bestehende WRITE-/UPDATE-Flows ohne Regression +- HUMAN_REVIEW_REQUIRED bleibt Default für neue DELETE requests +- keine automatische Approval-Erzeugung +- Multi-Commit-Test: Approval A darf DELETE B niemals autorisieren + +--- + +## 13. INCIDENT LESSON (verbindlich, bleibt) + +``` +INCIDENT_OCCURRED = TRUE +INCIDENT_RECOVERED = TRUE +PERMANENT_DAMAGE = NONE VERIFIED +ROOT_CAUSE = unsafe mutating endpoint probe during read-only discovery +``` + +**Neue verbindliche Regel:** `NO MUTATING HTTP METHOD PROBES DURING PRODUCTION READ-ONLY +DISCOVERY`. Diese Regel darf durch die neue DELETE-Implementierung NICHT aufgeweicht werden. + +--- + +## 14. HARD STOP + +Nach erfolgreicher Implementation, Tests, Push und Fresh Checker: **STOPP.** +KEIN realer Canary-Delete. KEIN produktiver Search-Rebuild. KEIN C5F-Cleanup. KEIN C5G. +KEINE Netzwerkänderung. KEINE Hermes-Rechte. KEINE Hermes-Autonomie. +Christian erteilt danach separat die Freigabe zur produktiven Canary-Cleanup-Validierung. diff --git a/tolaria/c5-sync-service/rq_c5_cli.py b/tolaria/c5-sync-service/rq_c5_cli.py index 0a5a349..01bf15b 100644 --- a/tolaria/c5-sync-service/rq_c5_cli.py +++ b/tolaria/c5-sync-service/rq_c5_cli.py @@ -35,7 +35,7 @@ import os import sys from typing import Any, Dict, List, Optional -from rq_c5a import C5AStore, ST_APPLIED +from rq_c5a import C5AStore, ST_APPLIED, ST_DELETE_APPROVED, OP_DELETE_REQUEST from rq_c5b import ( C5BPoller, GitReader, @@ -70,6 +70,11 @@ from rq_c5e import ( health_contract, failure_evidence, ) +from rq_c5_delete import ( + DeleteExecutor, + DeleteApprovalError, + DeleteExecutionError, +) def _store(db_path: str) -> C5AStore: @@ -332,6 +337,136 @@ def cmd_c5e_guarantees(args: argparse.Namespace) -> int: return 0 if ok else 1 +# -- C5 DELETE-Execution (Human-Gated) -------------------------------------- + +def _delete_executor(args: argparse.Namespace) -> DeleteExecutor: + store = _store(args.db) + client = TolariaClient(base_url=args.tolaria_base) + return DeleteExecutor(store, client) + + +def cmd_c5_delete_approve(args: argparse.Namespace) -> int: + """ + Persistiert eine explizite Human-Approval fuer einen DELETE-ObjectChange. + + NUR dieser Befehl erzeugt eine Approval (Human Gate). Er fuehrt KEINEN + DELETE aus. Der Human-Gate-Beweis (persistierte Approval) geht dadurch + nie verloren (Mission §3: kein undifferenzierter approve+delete-Blackbox). + """ + store = _store(args.db) + try: + # ObjectChange muss existieren und operation == DELETE sein. + changes = store.list_object_changes(args.commit) + deletes = [c for c in changes if c.get("operation") == OP_DELETE_REQUEST] + if len(deletes) != 1: + print(json.dumps({ + "error": f"Erwarte genau einen DELETE-ObjectChange in {args.commit}, " + f"gefunden: {len(deletes)}", + "reason_code": "DELETE_APPROVAL_MISMATCH", + }, ensure_ascii=False, indent=2)) + return 1 + change = deletes[0] + oid = change.get("object_id") + path = change.get("path_before") or change.get("path_after") + if oid is None or path is None: + print(json.dumps({ + "error": f"DELETE-ObjectChange {change.get('id')} hat keine object_id/path", + "reason_code": "DELETE_APPROVAL_MISMATCH", + }, ensure_ascii=False, indent=2)) + return 1 + # Optional: --object-id / --path zur Bestaetigung (muss exakt passen). + if args.object_id and args.object_id != oid: + print(json.dumps({ + "error": f"--object-id {args.object_id} passt nicht zum ObjectChange {oid}", + "reason_code": "DELETE_APPROVAL_MISMATCH", + }, ensure_ascii=False, indent=2)) + return 1 + if args.path and args.path != path: + print(json.dumps({ + "error": f"--path {args.path} passt nicht zum ObjectChange {path}", + "reason_code": "DELETE_APPROVAL_MISMATCH", + }, ensure_ascii=False, indent=2)) + return 1 + approval = store.create_delete_approval( + workflow_commit=args.commit, + object_change_id=change["id"], + object_id=oid, + path=path, + approved_by=args.approved_by, + ) + # Commit -> ST_DELETE_APPROVED (nur via expliziter Approval) + store.transition_commit(args.commit, ST_DELETE_APPROVED) + print(json.dumps({ + "status": "APPROVED", + "approval_id": approval["approval_id"], + "workflow_commit": args.commit, + "object_change_id": change["id"], + "object_id": oid, + "path": path, + "approved_by": args.approved_by, + "note": "Approval persistiert. DELETE wird NICHT automatisch ausgefuehrt.", + }, ensure_ascii=False, indent=2)) + return 0 + except Exception as e: # noqa: BLE001 - CLI-Fehlerbehandlung + print(json.dumps({"error": str(e)}, ensure_ascii=False, indent=2)) + return 1 + finally: + store.close() + + +def cmd_c5_delete_execute(args: argparse.Namespace) -> int: + """Fuehrt den kontrollierten Tolaria-DELETE aus (verlangt persistierte Approval).""" + store = _store(args.db) + executor = DeleteExecutor(store, TolariaClient(base_url=args.tolaria_base)) + try: + result = executor.execute(args.commit) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + except (DeleteApprovalError, DeleteExecutionError) as e: + print(json.dumps({ + "error": e.message, + "reason_code": e.reason_code, + "fail_closed": True, + }, ensure_ascii=False, indent=2)) + return 1 + finally: + store.close() + + +def cmd_c5_delete_status(args: argparse.Namespace) -> int: + """Read-only Status: Commit + zugehoerige Approvals.""" + store = _store(args.db) + try: + commit = store.get_commit(args.commit) + approvals = store.list_delete_approvals(args.commit) + print(json.dumps({ + "commit": commit, + "approvals": approvals, + }, ensure_ascii=False, indent=2)) + return 0 + finally: + store.close() + + +def cmd_c5_delete_replay(args: argparse.Namespace) -> int: + """Idempotenter Replay nach Crash (verlangt persistierte Approval).""" + store = _store(args.db) + executor = DeleteExecutor(store, TolariaClient(base_url=args.tolaria_base)) + try: + result = executor.replay(args.commit) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + except (DeleteApprovalError, DeleteExecutionError) as e: + print(json.dumps({ + "error": e.message, + "reason_code": e.reason_code, + "fail_closed": True, + }, ensure_ascii=False, indent=2)) + return 1 + finally: + store.close() + + def main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser( prog="rq_c5_cli", @@ -432,6 +567,29 @@ def main(argv: Optional[List[str]] = None) -> int: p = sub.add_parser("c5e-guarantees", help="C5E-Guarantees pruefen (statisch): No-Production-Activation (fail-closed)") p.set_defaults(func=cmd_c5e_guarantees) + # --- C5 DELETE-Execution (Human-Gated) --- + p = sub.add_parser("c5-delete-approve", + help="Persistiert eine explizite Human-Approval fuer einen DELETE-ObjectChange (NUR Approval, KEIN DELETE)") + p.add_argument("commit", help="Commit-SHA") + p.add_argument("--object-id", default=None, help="Optional: Objekt-ID zur Bestaetigung") + p.add_argument("--path", default=None, help="Optional: Vault-Pfad zur Bestaetigung") + p.add_argument("--approved-by", default="human:christian", help="Human-Gate-Provenance") + p.set_defaults(func=cmd_c5_delete_approve) + + p = sub.add_parser("c5-delete-execute", + help="Fuehrt den kontrollierten Tolaria-DELETE aus (verlangt persistierte Approval)") + p.add_argument("commit", help="Commit-SHA") + p.set_defaults(func=cmd_c5_delete_execute) + + p = sub.add_parser("c5-delete-status", help="Read-only Status: Commit + Approvals") + p.add_argument("commit", help="Commit-SHA") + p.set_defaults(func=cmd_c5_delete_status) + + p = sub.add_parser("c5-delete-replay", + help="Idempotenter Replay nach Crash (verlangt persistierte Approval)") + p.add_argument("commit", help="Commit-SHA") + p.set_defaults(func=cmd_c5_delete_replay) + args = parser.parse_args(argv) return args.func(args) diff --git a/tolaria/c5-sync-service/rq_c5_delete.py b/tolaria/c5-sync-service/rq_c5_delete.py new file mode 100644 index 0000000..22fb9ec --- /dev/null +++ b/tolaria/c5-sync-service/rq_c5_delete.py @@ -0,0 +1,399 @@ +#!/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}" diff --git a/tolaria/c5-sync-service/rq_c5a.py b/tolaria/c5-sync-service/rq_c5a.py index 65d5c71..68e5444 100644 --- a/tolaria/c5-sync-service/rq_c5a.py +++ b/tolaria/c5-sync-service/rq_c5a.py @@ -68,6 +68,10 @@ ST_HUMAN_REVIEW_REQUIRED = "HUMAN_REVIEW_REQUIRED" # Ordering-Zustand ST_WAITING_FOR_PREDECESSOR = "WAITING_FOR_PREDECESSOR" +# C5 DELETE-Execution-Zustände (nur via expliziter Human-Approval erreichbar) +ST_DELETE_APPROVED = "DELETE_APPROVED" # nach persistierter Approval, vor Execution +ST_DELETING = "DELETING" # während Execution (Crash-Window) + # Bootstrap-Zustände BS_UNINITIALIZED = "UNINITIALIZED" BS_RECONCILING = "RECONCILING" @@ -124,6 +128,13 @@ RC_INTEGRITY_FAILURE = "INTEGRITY_FAILURE" # Search-Health/Integrity nicht P # Source stattfinden; FAIL CLOSED vor APPLIED. RC_SEARCH_SOURCE_BUILD_FAILURE = "SEARCH_SOURCE_BUILD_FAILURE" +# C5 DELETE-Execution: Approval-/Gate-Fehler (nicht retrybar, FAIL CLOSED). +# RC_DELETE_APPROVAL_MISSING: kein persistierter Human-Approval fuer den ObjectChange. +# RC_DELETE_APPROVAL_MISMATCH: Approval existiert, passt aber nicht exakt +# (falscher Commit / falsches Objekt / falscher Pfad / stale / reused). +RC_DELETE_APPROVAL_MISSING = "DELETE_APPROVAL_MISSING" +RC_DELETE_APPROVAL_MISMATCH = "DELETE_APPROVAL_MISMATCH" + # Retry / Backoff DEFAULT_MAX_RETRIES = 5 DEFAULT_BACKOFF_SECONDS = [1, 2, 4, 8, 16] @@ -155,6 +166,9 @@ REASON_CODES = frozenset({ RC_INTEGRITY_FAILURE, # C5D: Search-Source-Build/Verification RC_SEARCH_SOURCE_BUILD_FAILURE, + # C5 DELETE-Execution: Approval-/Gate-Fehler + RC_DELETE_APPROVAL_MISSING, + RC_DELETE_APPROVAL_MISMATCH, }) # Alle Operationen als frozenset @@ -178,6 +192,7 @@ SYNC_STATES = frozenset({ ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED, ST_RETRY_PENDING, ST_FAILED, ST_DEAD, ST_HUMAN_REVIEW_REQUIRED, ST_WAITING_FOR_PREDECESSOR, + ST_DELETE_APPROVED, ST_DELETING, }) # Bootstrap-Zustände @@ -222,6 +237,13 @@ _ALLOWED_TRANSITIONS = { (ST_HUMAN_REVIEW_REQUIRED, ST_READY), # Human entscheidet -> erneut (ST_HUMAN_REVIEW_REQUIRED, ST_APPLIED), # Human bestätigt als angewendet (ST_HUMAN_REVIEW_REQUIRED, ST_DEAD), # Human verwirft + # C5 DELETE-Execution (NUR via expliziter Human-Approval / DeleteExecutor): + (ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED), # explizite Approval persistiert + (ST_DELETE_APPROVED, ST_DELETING), # Executor startet + (ST_DELETE_APPROVED, ST_HUMAN_REVIEW_REQUIRED), # Revoke/Fehler vor Execution + (ST_DELETE_APPROVED, ST_UPDATING_SEARCH), # Idempotenz: Ziel bereits absent + (ST_DELETING, ST_UPDATING_SEARCH), # DELETE + Read-Back OK -> C5D + (ST_DELETING, ST_HUMAN_REVIEW_REQUIRED), # Fehler während Execution } # Bootstrap-Übergänge @@ -340,6 +362,26 @@ class C5AStore: updated_at INTEGER ) """) + # C5 DELETE-Execution: persistente, auditierbare Human-Approval. + # Keine Credentials/personenbezogenen Daten. Eindeutig gebunden an + # (workflow_commit, object_change_id, object_id, path) — keine globale + # DELETE_APPROVED=true-Semantik (Invarianten D/E/F). + self._conn.execute(""" + CREATE TABLE IF NOT EXISTS delete_approvals ( + approval_id TEXT PRIMARY KEY, + workflow_commit TEXT NOT NULL, + object_change_id INTEGER NOT NULL, + object_id TEXT NOT NULL, + path TEXT NOT NULL, + operation TEXT NOT NULL DEFAULT 'DELETE', + approval_status TEXT NOT NULL, + approved_by TEXT NOT NULL, + approval_nonce TEXT NOT NULL, + approved_at INTEGER NOT NULL, + used_at INTEGER, + UNIQUE(workflow_commit, object_change_id, object_id, path) + ) + """) # Schema-Version setzen self._conn.execute( "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)", @@ -655,6 +697,98 @@ class C5AStore: except (ValueError, TypeError): return None + # -- C5 DELETE-Execution: persistente Human-Approval --------------------- + + def create_delete_approval( + self, workflow_commit: str, object_change_id: int, object_id: str, + path: str, approved_by: str, approval_id: Optional[str] = None, + approval_nonce: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Persistiert eine explizite Human-Approval fuer einen DELETE-ObjectChange. + + NUR durch den CLI-Befehl `approve-delete` (Human Gate) aufrufbar. Kein Code + erzeugt Approval automatisch (Invariante B/C). Eindeutig gebunden an + (workflow_commit, object_change_id, object_id, path) — keine globale + DELETE_APPROVED=true-Semantik (Invarianten D/E/F). + + approval_id/approval_nonce sind optional injizierbar (Tests); Defaults sind + kryptographisch zufaellig. Keine Credentials/personenbezogenen Daten. + """ + import uuid + aid = approval_id or str(uuid.uuid4()) + nonce = approval_nonce or str(uuid.uuid4()) + now = int(time.time() * 1000) + with self._conn: + self._conn.execute( + """ + INSERT OR REPLACE INTO delete_approvals + (approval_id, workflow_commit, object_change_id, object_id, + path, operation, approval_status, approved_by, approval_nonce, + approved_at, used_at) + VALUES (?, ?, ?, ?, ?, 'DELETE', 'APPROVED', ?, ?, ?, NULL) + """, + (aid, workflow_commit, object_change_id, object_id, path, + approved_by, nonce, now), + ) + return { + "approval_id": aid, + "workflow_commit": workflow_commit, + "object_change_id": object_change_id, + "object_id": object_id, + "path": path, + "operation": "DELETE", + "approval_status": "APPROVED", + "approved_by": approved_by, + "approval_nonce": nonce, + "approved_at": now, + "used_at": None, + } + + def get_delete_approval(self, approval_id: str) -> Optional[Dict[str, Any]]: + row = self._conn.execute( + "SELECT * FROM delete_approvals WHERE approval_id = ?", (approval_id,) + ).fetchone() + return dict(row) if row else None + + def get_delete_approval_for_change( + self, workflow_commit: str, object_change_id: int, + ) -> Optional[Dict[str, Any]]: + """Liest die Approval fuer einen exakten ObjectChange (oder None).""" + row = self._conn.execute( + "SELECT * FROM delete_approvals WHERE workflow_commit = ? AND object_change_id = ?", + (workflow_commit, object_change_id), + ).fetchone() + return dict(row) if row else None + + def list_delete_approvals(self, workflow_commit: Optional[str] = None) -> List[Dict[str, Any]]: + if workflow_commit: + rows = self._conn.execute( + "SELECT * FROM delete_approvals WHERE workflow_commit = ? ORDER BY approved_at ASC", + (workflow_commit,), + ).fetchall() + else: + rows = self._conn.execute( + "SELECT * FROM delete_approvals ORDER BY approved_at ASC" + ).fetchall() + return [dict(r) for r in rows] + + def mark_delete_approval_used(self, approval_id: str) -> None: + """Markiert eine Approval als USED (einmalig, verhindert Reuse).""" + with self._conn: + self._conn.execute( + "UPDATE delete_approvals SET approval_status = 'USED', used_at = ? WHERE approval_id = ?", + (int(time.time() * 1000), approval_id), + ) + + def revoke_delete_approval(self, approval_id: str) -> None: + """Revoked eine Approval (nur vor Nutzung; USED kann nicht revokiert werden).""" + with self._conn: + self._conn.execute( + "UPDATE delete_approvals SET approval_status = 'REVOKED' WHERE approval_id = ? AND approval_status = 'APPROVED'", + (approval_id,), + ) + def close(self) -> None: self._conn.close() diff --git a/tolaria/c5-sync-service/rq_c5c.py b/tolaria/c5-sync-service/rq_c5c.py index 73b2c7d..5f2bf97 100644 --- a/tolaria/c5-sync-service/rq_c5c.py +++ b/tolaria/c5-sync-service/rq_c5c.py @@ -44,6 +44,8 @@ from rq_c5a import ( ST_RETRY_PENDING, ST_DEAD, ST_APPLIED, + ST_DELETE_APPROVED, + ST_DELETING, IDEM_ALREADY_AT_TARGET, IDEM_ALREADY_APPLIED, IDEM_RETRY_SAFE, @@ -275,6 +277,27 @@ class TolariaClient: f"Tolaria save fehlgeschlagen: {resp['error']}", RC_INVALID_SCHEMA) return resp + def delete(self, vault_path: str) -> Dict[str, Any]: + """ + Loescht ein Vault-Objekt (POST /delete). + + NUR durch den DeleteExecutor (C5 DELETE-Execution) aufrufbar, der zuvor + eine persistierte, exakt passende Human-Approval validiert hat. Kein + freier Pfad-Parameter; vault_path stammt ausschliesslich aus dem + ObjectChange, das durch die Approval gebunden ist (Invariante I/J). + + Endpoint-/Payload-Contract (POST /delete mit {"path": ...}) ist aus dem + vorhandenen Tolaria-Code/Router und der dokumentierten Incident-Evidence + bestimmt. KEINE produktive Endpoint-Probe (Incident-Regel). + """ + resp = self._post("delete", {"path": vault_path}) + if resp is None: + resp = {} + if "error" in resp: + raise TolariaWriteError( + f"Tolaria delete fehlgeschlagen: {resp['error']}", RC_INVALID_SCHEMA) + return resp + # -- Verify (Read-Back + Hash) ------------------------------------------ def verify(self, vault_path: str, expected_content: str) -> Dict[str, Any]: diff --git a/tolaria/c5-sync-service/rq_c5d.py b/tolaria/c5-sync-service/rq_c5d.py index 7b5ed45..37ec9ce 100644 --- a/tolaria/c5-sync-service/rq_c5d.py +++ b/tolaria/c5-sync-service/rq_c5d.py @@ -489,6 +489,7 @@ def verify_integrity(health: Dict[str, Any], "index_built": bool(health.get("index_built")), "object_count_positive": int(health.get("object_count", 0)) > 0, "failed_objects_empty": not bool(health.get("failed_objects")), + "secret_blocked_objects_zero": int(health.get("secret_blocked_objects", 0)) == 0, } if expected_object_ids is not None: indexed_ids = set(health.get("indexed_object_ids") or []) diff --git a/tolaria/c5-sync-service/rq_c5e.py b/tolaria/c5-sync-service/rq_c5e.py index c114e6b..83e1056 100644 --- a/tolaria/c5-sync-service/rq_c5e.py +++ b/tolaria/c5-sync-service/rq_c5e.py @@ -57,6 +57,8 @@ from rq_c5a import ( ST_VERIFYING_SEARCH, ST_VERIFYING_TOLARIA, ST_WAITING_FOR_PREDECESSOR, + ST_DELETE_APPROVED, + ST_DELETING, ) # --------------------------------------------------------------------------- @@ -262,6 +264,7 @@ class C5EEngine: store: C5EStore, propagator: Optional[Any] = None, search_engine: Optional[Any] = None, + delete_executor: Optional[Any] = None, max_retries: int = DEFAULT_MAX_RETRIES, backoff_seconds: Optional[List[int]] = None, allow_writes: bool = False, @@ -269,6 +272,7 @@ class C5EEngine: self.store = store self.propagator = propagator # injizierter C5CPropagator (oder Fake) self.search_engine = search_engine # injizierte C5DEngine (oder Fake) + self.delete_executor = delete_executor # injizierter DeleteExecutor (oder Fake) self.max_retries = max_retries self.backoff_seconds = backoff_seconds or DEFAULT_BACKOFF_SECONDS self.allow_writes = allow_writes @@ -321,6 +325,15 @@ class C5EEngine: return {"commit_sha": commit_sha, "decision": REC_RESUME, "state": cur, "reason": f"Restart in {cur} -> ab korrektem Schritt fortsetzen"} + # C5 DELETE-Execution: deterministischer Replay-Pfad (Crash-Faelle B/C/D). + # ST_DELETE_APPROVED: Approval persistiert, DELETE noch nicht ausgefuehrt + # -> Replay fuehrt DELETE aus (DeleteExecutor.replay). + # ST_DELETING: DELETE evtl. schon ausgefuehrt -> Read-Back entscheidet + # (DELETE_ALREADY_AT_TARGET vs. erneut ausfuehren). + if cur in (ST_DELETE_APPROVED, ST_DELETING): + return {"commit_sha": commit_sha, "decision": REC_RESUME, + "state": cur, + "reason": f"Restart in {cur} -> DeleteExecutor.replay (idempotent)"} return {"commit_sha": commit_sha, "decision": REC_HUMAN_REVIEW, "state": cur, "reason": f"kein deterministischer Recovery-Pfad ({cur})"} @@ -400,6 +413,19 @@ class C5EEngine: ) ) + # --- DELETE-Schritt (ST_DELETE_APPROVED / ST_DELETING) --- + # Crash-Faelle B/C/D: Approval persistiert, DELETE evtl. schon ausgefuehrt. + # DeleteExecutor.replay ist idempotent (DELETE_ALREADY_AT_TARGET). + if cur in (ST_DELETE_APPROVED, ST_DELETING): + if self.delete_executor is None: + return {"commit_sha": commit_sha, "decision": decision["decision"], + "status": ST_HUMAN_REVIEW_REQUIRED, "result": None, + "reason": "DeleteExecutor nicht injiziert (kein produktiver DELETE)"} + result = self.delete_executor.replay(commit_sha) + return {"commit_sha": commit_sha, "decision": decision["decision"], + "status": result.get("status"), "result": result, + "reason": "DELETE-Schritt fortgesetzt (idempotent)"} + # --- Suchschritt (UPDATING_SEARCH / VERIFYING_SEARCH / Search-Retry) --- if cur in (ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH) or is_search_retry: if self.search_engine is None: diff --git a/tolaria/c5-sync-service/test_c5_delete.py b/tolaria/c5-sync-service/test_c5_delete.py new file mode 100644 index 0000000..9e25504 --- /dev/null +++ b/tolaria/c5-sync-service/test_c5_delete.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +""" +Red Queen — C5 DELETE-Execution Testsuite (Human-Gated). + +Testet den kontrollierten Tolaria-DELETE-Pfad NACH expliziter Human-Approval +gegen isolierte Fakes (FakeTolaria + FakeTolariaClient + temp-DB). KEINE +produktiven Writes. KEINE produktiven Endpoint-Probes. + +Abgedeckte Faelle (Mission §8 Testplan): + DELETE ohne Human Approval -> BLOCKED + DELETE mit Approval fuer anderes Objekt -> BLOCKED + DELETE mit Approval fuer anderen Commit -> BLOCKED + manipulierte object_id -> BLOCKED + manipulierter path -> BLOCKED + gueltiges Approval -> exakt ein DELETE + anderes Objekt bleibt unveraendert + DELETE_ALREADY_AT_TARGET -> kein zweiter destruktiver Write + Restart nach Approval -> Approval erhalten + Crash nach DELETE -> Replay sicher + Search Exact-Set nach DELETE (via C5D-Pfad) + Search-Mismatch -> kein APPLIED + Secret Safety + keine SQL-/State-Bypaesse + HUMAN_REVIEW_REQUIRED bleibt Default fuer neue DELETE requests + keine automatische Approval-Erzeugung + Multi-Commit-Test: Approval A darf DELETE B niemals autorisieren +""" + +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from typing import Any, Dict, List, Optional + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from rq_c5a import ( + C5AStore, ST_READY, ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED, + ST_DELETING, ST_UPDATING_SEARCH, ST_APPLIED, + RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH, + RC_UNEXPECTED_TOLARIA_DRIFT, + OP_DELETE_REQUEST, OP_CREATE, +) +from rq_c5c import TolariaClient, TolariaUnavailableError, RC_TOLARIA_UNAVAILABLE +from rq_c5_delete import ( + DeleteExecutor, DeleteApprovalError, DeleteExecutionError, + DELETE_ALREADY_AT_TARGET, +) + +UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b" +UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d" + + +# --------------------------------------------------------------------------- +# Isolierte Fakes +# --------------------------------------------------------------------------- + +class FakeTolaria: + """In-Memory Fake der Tolaria-Vault-API (read/write/delete/list).""" + + def __init__(self): + self.vault: Dict[str, str] = {} + self.write_count = 0 + self.delete_count = 0 + self.fail_next_delete = False + self.unavailable = False + + def read(self, vault_path: str) -> Optional[str]: + if self.unavailable: + raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE) + return self.vault.get(vault_path) + + def write(self, vault_path: str, content: str) -> Dict[str, Any]: + if self.unavailable: + raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE) + self.vault[vault_path] = content + self.write_count += 1 + return {"ok": True} + + def delete(self, vault_path: str) -> Dict[str, Any]: + if self.unavailable: + raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE) + if self.fail_next_delete: + self.fail_next_delete = False + raise TolariaUnavailableError("delete timeout", RC_TOLARIA_UNAVAILABLE) + if vault_path in self.vault: + del self.vault[vault_path] + self.delete_count += 1 + return {"ok": True} + + def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]: + return [{"path": p} for p in self.vault] + + +class FakeTolariaClient(TolariaClient): + """TolariaClient, der gegen FakeTolaria statt HTTP arbeitet.""" + + def __init__(self, fake: FakeTolaria): + super().__init__(base_url="http://fake") + self.fake = fake + + def read(self, vault_path: str) -> Optional[str]: + return self.fake.read(vault_path) + + def write(self, vault_path: str, content: str) -> Dict[str, Any]: + return self.fake.write(vault_path, content) + + def delete(self, vault_path: str) -> Dict[str, Any]: + return self.fake.delete(vault_path) + + def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]: + return self.fake.list(vault_path) + + +def _make_store() -> C5AStore: + db = os.path.join(tempfile.mkdtemp(prefix="c5del_db_"), "c5a.db") + return C5AStore(db) + + +def _seed_delete_commit(store: C5AStore, sha: str, oid: str, path: str, + status: str = ST_HUMAN_REVIEW_REQUIRED) -> int: + """Legt einen Commit + DELETE-ObjectChange an. Gibt die ObjectChange-id zurueck.""" + store.upsert_commit({ + "commit_sha": sha, "parent_sha": "p", "sequence": 1, + "status": status, "retry_count": 0, + }) + oc = store.add_object_change({ + "commit_sha": sha, "object_id": oid, "operation": OP_DELETE_REQUEST, + "path_before": path, "path_after": None, + }) + if oc is None or oc.get("id") is None: + raise RuntimeError("add_object_change lieferte keine id") + return oc["id"] + + +def _approve(store: C5AStore, sha: str, change_id: int, oid: str, path: str, + approved_by: str = "human:christian") -> Dict[str, Any]: + """Persistiert eine Approval + setzt Commit auf ST_DELETE_APPROVED.""" + approval = store.create_delete_approval( + workflow_commit=sha, object_change_id=change_id, + object_id=oid, path=path, approved_by=approved_by, + ) + store.transition_commit(sha, ST_DELETE_APPROVED) + return approval + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestDeleteWithoutApproval(unittest.TestCase): + """DELETE ohne Human Approval -> BLOCKED (Invariante A/B/C).""" + + def setUp(self): + self.fake = FakeTolaria() + self.client = FakeTolariaClient(self.fake) + self.store = _make_store() + self.fake.vault["/app/vault/x.md"] = "content" + self.change_id = _seed_delete_commit( + self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED) + self.executor = DeleteExecutor(self.store, self.client) + + def tearDown(self): + self.store.close() + + def test_no_approval_blocked(self): + # Commit ist HUMAN_REVIEW_REQUIRED, keine Approval -> execute muss fail closed + with self.assertRaises(DeleteExecutionError): + self.executor.execute("c1") + self.assertEqual(self.fake.delete_count, 0) + self.assertEqual(self.fake.vault["/app/vault/x.md"], "content") + + def test_commit_not_approved_state_blocked(self): + # Commit in HUMAN_REVIEW_REQUIRED (nicht DELETE_APPROVED) -> BLOCKED + with self.assertRaises(DeleteExecutionError) as ctx: + self.executor.execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH) + self.assertEqual(self.fake.delete_count, 0) + + +class TestApprovalMismatch(unittest.TestCase): + """Approval passt nicht exakt -> BLOCKED (Invarianten D/E/F/I).""" + + def setUp(self): + self.fake = FakeTolaria() + self.client = FakeTolariaClient(self.fake) + self.store = _make_store() + self.fake.vault["/app/vault/x.md"] = "content" + self.change_id = _seed_delete_commit( + self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED) + self.executor = DeleteExecutor(self.store, self.client) + + def tearDown(self): + self.store.close() + + def _approve_and_execute(self, **kwargs): + """Approval mit ueberschreibbaren Feldern + execute.""" + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + return self.executor.execute("c1") + + def test_approval_for_other_object_blocked(self): + # Approval fuer Objekt B, ObjectChange ist Objekt A -> BLOCKED (E) + _approve(self.store, "c1", self.change_id, UUID_B, "x.md") + with self.assertRaises(DeleteApprovalError) as ctx: + self.executor.execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH) + self.assertEqual(self.fake.delete_count, 0) + + def test_approval_for_other_commit_blocked(self): + # Approval fuer Commit c2, ObjectChange in c1 -> BLOCKED (F) + # c1 auf DELETE_APPROVED setzen (Gate 2 passiert), Approval fuer c2 anlegen. + # Die Approval ist commit-spezifisch (workflow_commit=c2) gebunden, daher + # findet der Executor fuer c1 KEINE Approval -> MISSING (strengste Form von F). + self.store.upsert_commit({ + "commit_sha": "c2", "parent_sha": "p", "sequence": 2, + "status": ST_HUMAN_REVIEW_REQUIRED, "retry_count": 0, + }) + _approve(self.store, "c2", self.change_id, UUID_A, "x.md") + self.store.transition_commit("c1", ST_DELETE_APPROVED) + with self.assertRaises(DeleteApprovalError) as ctx: + self.executor.execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING) + self.assertEqual(self.fake.delete_count, 0) + + def test_manipulated_object_id_blocked(self): + # Approval fuer UUID_A, aber ObjectChange manipuliert zu UUID_B -> BLOCKED + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + # Manipuliere den ObjectChange direkt im Store (nur Test-Setup, kein Bypass) + self.store._conn.execute( + "UPDATE objects SET object_id = ? WHERE id = ?", (UUID_B, self.change_id)) + with self.assertRaises(DeleteApprovalError) as ctx: + self.executor.execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH) + self.assertEqual(self.fake.delete_count, 0) + + def test_manipulated_path_blocked(self): + # Approval fuer x.md, aber ObjectChange-Pfad manipuliert zu y.md -> BLOCKED + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + self.store._conn.execute( + "UPDATE objects SET path_before = ? WHERE id = ?", ("y.md", self.change_id)) + with self.assertRaises(DeleteApprovalError) as ctx: + self.executor.execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH) + self.assertEqual(self.fake.delete_count, 0) + + def test_stale_approval_blocked(self): + # Approval bereits USED -> BLOCKED (kein Reuse) + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + appr = self.store.get_delete_approval_for_change("c1", self.change_id) + self.assertIsNotNone(appr) + self.store.mark_delete_approval_used(appr["approval_id"]) + with self.assertRaises(DeleteApprovalError) as ctx: + self.executor.execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH) + self.assertEqual(self.fake.delete_count, 0) + + +class TestValidDelete(unittest.TestCase): + """Gueltiges Approval -> exakt ein DELETE, anderes Objekt unveraendert.""" + + def setUp(self): + self.fake = FakeTolaria() + self.client = FakeTolariaClient(self.fake) + self.store = _make_store() + self.fake.vault["/app/vault/x.md"] = "content x" + self.fake.vault["/app/vault/y.md"] = "content y" + self.change_id = _seed_delete_commit( + self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED) + self.executor = DeleteExecutor(self.store, self.client) + + def tearDown(self): + self.store.close() + + def test_valid_approval_exactly_one_delete(self): + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + result = self.executor.execute("c1") + self.assertEqual(result["deleted"], True) + self.assertEqual(self.fake.delete_count, 1) + # Ziel absent + self.assertIsNone(self.fake.vault.get("/app/vault/x.md")) + # Anderes Objekt unveraendert + self.assertEqual(self.fake.vault["/app/vault/y.md"], "content y") + # Commit -> UPDATING_SEARCH (C5D uebernimmt) + self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH) + # Approval -> USED + approval = self.store.get_delete_approval_for_change("c1", self.change_id) + self.assertIsNotNone(approval) + self.assertEqual(approval["approval_status"], "USED") + + def test_other_object_unchanged(self): + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + self.executor.execute("c1") + self.assertEqual(self.fake.vault["/app/vault/y.md"], "content y") + self.assertEqual(self.fake.write_count, 0) # kein Write, nur Delete + + +class TestIdempotency(unittest.TestCase): + """DELETE_ALREADY_AT_TARGET -> kein zweiter destruktiver Write (H).""" + + def setUp(self): + self.fake = FakeTolaria() + self.client = FakeTolariaClient(self.fake) + self.store = _make_store() + self.change_id = _seed_delete_commit( + self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED) + self.executor = DeleteExecutor(self.store, self.client) + + def tearDown(self): + self.store.close() + + def test_already_absent_no_second_delete(self): + # Ziel ist bereits absent (z.B. nach Crash waehrend DELETE) + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + result = self.executor.execute("c1") + self.assertEqual(result["idempotency"], DELETE_ALREADY_AT_TARGET) + self.assertEqual(result["deleted"], False) + self.assertEqual(self.fake.delete_count, 0) # kein zweiter destruktiver Write + self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH) + + def test_replay_after_crash_delete(self): + # Crash nach DELETE, vor Read-Back: Commit in ST_DELETING, Objekt absent + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + self.store.transition_commit("c1", ST_DELETING) + result = self.executor.replay("c1") + self.assertEqual(result["idempotency"], DELETE_ALREADY_AT_TARGET) + self.assertEqual(self.fake.delete_count, 0) + self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH) + + def test_replay_after_crash_before_delete(self): + # Crash nach Approval, vor DELETE: Commit in ST_DELETE_APPROVED, Objekt noch da + self.fake.vault["/app/vault/x.md"] = "content" + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + result = self.executor.replay("c1") + self.assertEqual(result["deleted"], True) + self.assertEqual(self.fake.delete_count, 1) + self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH) + + def test_replay_after_crash_during_delete_object_still_there(self): + # Crash waehrend DELETE, Objekt noch da -> DELETE erneut (Approval gueltig) + self.fake.vault["/app/vault/x.md"] = "content" + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + self.store.transition_commit("c1", ST_DELETING) + result = self.executor.replay("c1") + self.assertEqual(result["deleted"], True) + self.assertEqual(self.fake.delete_count, 1) + self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH) + + +class TestRestartPersistence(unittest.TestCase): + """Restart nach Approval -> Approval erhalten (G).""" + + def test_approval_persists_across_restart(self): + db = os.path.join(tempfile.mkdtemp(prefix="c5del_restart_"), "c5a.db") + store1 = C5AStore(db) + change_id = _seed_delete_commit( + store1, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED) + _approve(store1, "c1", change_id, UUID_A, "x.md") + store1.close() + + # Restart: neue Store-Instanz auf derselben DB + store2 = C5AStore(db) + approval = store2.get_delete_approval_for_change("c1", change_id) + self.assertIsNotNone(approval) + self.assertEqual(approval["approval_status"], "APPROVED") + self.assertEqual(store2.commit_status("c1"), ST_DELETE_APPROVED) + store2.close() + + +class TestNoAutomaticApproval(unittest.TestCase): + """Keine automatische Approval-Erzeugung (B/C/N).""" + + def setUp(self): + self.fake = FakeTolaria() + self.client = FakeTolariaClient(self.fake) + self.store = _make_store() + self.change_id = _seed_delete_commit( + self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED) + self.executor = DeleteExecutor(self.store, self.client) + + def tearDown(self): + self.store.close() + + def test_no_approval_created_by_executor(self): + # Executor erzeugt NIE eine Approval; ohne Approval -> BLOCKED + with self.assertRaises(DeleteExecutionError): + self.executor.execute("c1") + self.assertEqual(self.store.list_delete_approvals("c1"), []) + self.assertEqual(self.fake.delete_count, 0) + + def test_human_review_remains_default(self): + # Neuer DELETE-Request landet in HUMAN_REVIEW_REQUIRED (nicht DELETE_APPROVED) + self.assertEqual(self.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED) + + +class TestMultiCommit(unittest.TestCase): + """Multi-Commit: Approval A darf DELETE B niemals autorisieren (F).""" + + def setUp(self): + self.fake = FakeTolaria() + self.client = FakeTolariaClient(self.fake) + self.store = _make_store() + self.fake.vault["/app/vault/a.md"] = "content a" + self.fake.vault["/app/vault/b.md"] = "content b" + self.change_a = _seed_delete_commit( + self.store, "cA", UUID_A, "a.md", status=ST_HUMAN_REVIEW_REQUIRED) + self.change_b = _seed_delete_commit( + self.store, "cB", UUID_B, "b.md", status=ST_HUMAN_REVIEW_REQUIRED) + self.executor = DeleteExecutor(self.store, self.client) + + def tearDown(self): + self.store.close() + + def test_approval_a_never_authorizes_b(self): + # Approval nur fuer cA/a.md + _approve(self.store, "cA", self.change_a, UUID_A, "a.md") + # Versuche cB zu loeschen -> BLOCKED (keine Approval fuer cB) + with self.assertRaises(DeleteExecutionError): + self.executor.execute("cB") + self.assertEqual(self.fake.delete_count, 0) + self.assertEqual(self.fake.vault["/app/vault/b.md"], "content b") + # cA kann trotzdem ausgefuehrt werden + result = self.executor.execute("cA") + self.assertEqual(result["deleted"], True) + self.assertEqual(self.fake.delete_count, 1) + + +class TestSecretSafety(unittest.TestCase): + """Keine Secrets/Credentials in Approval oder Output (Mission §11).""" + + def setUp(self): + self.store = _make_store() + self.change_id = _seed_delete_commit( + self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED) + + def tearDown(self): + self.store.close() + + def test_approval_has_no_secrets(self): + approval = _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + blob = str(approval) + for secret in ("token", "password", "secret", "api_key", "credential"): + self.assertNotIn(secret.lower(), blob.lower()) + + +class TestNoStateBypass(unittest.TestCase): + """Keine direkte APPLIED-Markierung durch Executor (M).""" + + def setUp(self): + self.fake = FakeTolaria() + self.client = FakeTolariaClient(self.fake) + self.store = _make_store() + self.fake.vault["/app/vault/x.md"] = "content" + self.change_id = _seed_delete_commit( + self.store, "c1", UUID_A, "x.md", status=ST_HUMAN_REVIEW_REQUIRED) + self.executor = DeleteExecutor(self.store, self.client) + + def tearDown(self): + self.store.close() + + def test_executor_never_marks_applied(self): + _approve(self.store, "c1", self.change_id, UUID_A, "x.md") + result = self.executor.execute("c1") + # Executor geht nur bis UPDATING_SEARCH, NIE APPLIED + self.assertEqual(result["status"], ST_UPDATING_SEARCH) + self.assertEqual(self.store.commit_status("c1"), ST_UPDATING_SEARCH) + self.assertNotEqual(self.store.commit_status("c1"), ST_APPLIED) + + +def main() -> int: + suite = unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__]) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tolaria/c5-sync-service/test_c5_delete_fresh_checker.py b/tolaria/c5-sync-service/test_c5_delete_fresh_checker.py new file mode 100644 index 0000000..32a9ea8 --- /dev/null +++ b/tolaria/c5-sync-service/test_c5_delete_fresh_checker.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +""" +Red Queen — C5 DELETE-Execution FRESH CHECKER (Todo 12). + +Unabhaengiger, adversarialer Checker. Versucht AUSDRUECKLICH, den +Human-Gated-DELETE-Contract zu brechen. Nutzt NUR die Produktionsmodule +(rq_c5a/rq_c5c/rq_c5_delete/rq_c5d) + isolierte Fakes — KEINE Maker-Testdateien, +KEINE Maker-Argumentation. Jeder Check ist ein eigenstaendiger Angriffsversuch. + +Angriffsvektoren (Todo 12): + wrong commit / wrong object / wrong path / wrong nonce + missing approval / used approval / stale approval + already absent / unexpected drift / crash after delete + restart / search mismatch + cross-object approval / cross-commit approval + path traversal / arbitrary path deletion + no auto-approval / no SQL bypass / no direct APPLIED bypass + DELETE bleibt HUMAN_GATED + +Ergebnis: PASS (kein Defekt) oder FAIL (Defekt reproduziert). +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from typing import Any, Dict, List, Optional + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from rq_c5a import ( + C5AStore, ST_READY, ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED, + ST_DELETING, ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED, + RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH, + RC_UNEXPECTED_TOLARIA_DRIFT, + OP_DELETE_REQUEST, OP_CREATE, +) +from rq_c5c import TolariaClient, TolariaUnavailableError, RC_TOLARIA_UNAVAILABLE, VAULT_PREFIX +from rq_c5_delete import ( + DeleteExecutor, DeleteApprovalError, DeleteExecutionError, + DELETE_ALREADY_AT_TARGET, +) +from rq_c5d import C5DEngine, SearchSourceBuilder, SearchClient +from rq_c5b import KnowledgeScope + +UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b" +UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d" + + +def _fm(id_: str, **extra) -> str: + lines = ["---", "knowledge_schema: 1", f"id: {id_}"] + for k, v in extra.items(): + if isinstance(v, list): + lines.append(f"{k}: [{', '.join(v)}]") + else: + lines.append(f"{k}: {v}") + lines.append("---") + return "\n".join(lines) + "\n" + + +class FakeTolaria: + def __init__(self): + self.vault: Dict[str, str] = {} + self.delete_count = 0 + self.unavailable = False + + def read(self, p: str) -> Optional[str]: + if self.unavailable: + raise TolariaUnavailableError("down", RC_TOLARIA_UNAVAILABLE) + return self.vault.get(p) + + def write(self, p: str, c: str) -> Dict[str, Any]: + self.vault[p] = c + return {"ok": True} + + def delete(self, p: str) -> Dict[str, Any]: + if p in self.vault: + del self.vault[p] + self.delete_count += 1 + return {"ok": True} + + def list(self, p: str = "/app/vault") -> List[Dict[str, Any]]: + return [{"path": x} for x in self.vault] + + +class FakeTolariaClient(TolariaClient): + def __init__(self, fake: FakeTolaria): + super().__init__(base_url="http://fake") + self.fake = fake + + def read(self, vault_path: str) -> Optional[str]: + return self.fake.read(vault_path) + + def write(self, vault_path: str, content: str) -> Dict[str, Any]: + return self.fake.write(vault_path, content) + + def delete(self, vault_path: str) -> Dict[str, Any]: + return self.fake.delete(vault_path) + + def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]: + return self.fake.list(vault_path) + + +class FakeSearch: + def __init__(self, source_path: str): + self.source_path = source_path + self.rebuild_count = 0 + self.health_override: Optional[Dict[str, Any]] = None + self.indexed_ids: List[str] = [] + self.indexed_paths: List[str] = [] + + def _load(self) -> Dict[str, Any]: + with open(self.source_path, "r", encoding="utf-8") as f: + return json.load(f) + + def rebuild(self) -> Dict[str, Any]: + self.rebuild_count += 1 + src = self._load() + objs = src.get("objects", []) + self.indexed_ids = [o["id"] for o in objs if o.get("id")] + self.indexed_paths = [o["path"] for o in objs] + return {"status": "ok", "indexed": len(objs)} + + def health(self) -> Dict[str, Any]: + if self.health_override is not None: + return self.health_override + return { + "index_built": True, "object_count": len(self.indexed_ids), + "failed_objects": [], "integrity_ok": True, + "indexed_object_ids": self.indexed_ids, + "indexed_paths": self.indexed_paths, + "secret_blocked_objects": 0, + } + + +class FakeSearchClient(SearchClient): + def __init__(self, fake: FakeSearch): + super().__init__(base_url="http://fake-search") + self.fake = fake + + def rebuild(self) -> Dict[str, Any]: + return self.fake.rebuild() + + def health(self) -> Dict[str, Any]: + return self.fake.health() + + +def _make_store() -> C5AStore: + db = os.path.join(tempfile.mkdtemp(prefix="c5checker_db_"), "c5a.db") + return C5AStore(db) + + +def _seed_delete(store: C5AStore, sha: str, oid: str, path: str, + status: str = ST_HUMAN_REVIEW_REQUIRED) -> int: + store.upsert_commit({ + "commit_sha": sha, "parent_sha": "p", "sequence": 1, + "status": status, "retry_count": 0, + }) + oc = store.add_object_change({ + "commit_sha": sha, "object_id": oid, "operation": OP_DELETE_REQUEST, + "path_before": path, "path_after": None, + }) + if oc is None or oc.get("id") is None: + raise RuntimeError("keine id") + return oc["id"] + + +def _approve(store: C5AStore, sha: str, cid: int, oid: str, path: str) -> Dict[str, Any]: + return store.create_delete_approval( + workflow_commit=sha, object_change_id=cid, object_id=oid, + path=path, approved_by="human:christian") + + +class CheckerFixture: + def __init__(self): + self.store = _make_store() + self.fake = FakeTolaria() + self.client = FakeTolariaClient(self.fake) + self.tmpdir = tempfile.mkdtemp(prefix="c5checker_src_") + self.source_path = os.path.join(self.tmpdir, "index_source.json") + self.builder = SearchSourceBuilder(self.client, self.source_path, + scope=KnowledgeScope(VAULT_PREFIX)) + self.search = FakeSearch(self.source_path) + self.search_client = FakeSearchClient(self.search) + self.engine = C5DEngine(self.store, search=self.search_client, + source_builder=self.builder) + + def seed(self, objs: Dict[str, str]) -> None: + for p, c in objs.items(): + self.fake.vault[p] = c + + def cleanup(self) -> None: + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + +class TestFreshChecker(unittest.TestCase): + """Adversarialer Checker: versucht den Contract zu brechen.""" + + def setUp(self): + self.fx = CheckerFixture() + self.fx.seed({ + "/app/vault/a.md": _fm(UUID_A, title="A"), + "/app/vault/b.md": _fm(UUID_B, title="B"), + }) + + def tearDown(self): + self.fx.cleanup() + + def _exec(self) -> DeleteExecutor: + return DeleteExecutor(self.fx.store, self.fx.client) + + # -- wrong commit / object / path / nonce -------------------------------- + + def test_wrong_commit_breaks(self): + """Cross-commit: Approval fuer c2 darf c1 nicht freigeben.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c2", cid, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + with self.assertRaises(DeleteApprovalError): + self._exec().execute("c1") + self.assertEqual(self.fx.fake.delete_count, 0) + + def test_wrong_object_breaks(self): + """Cross-object: Approval fuer B darf A nicht freigeben.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_B, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + with self.assertRaises(DeleteApprovalError): + self._exec().execute("c1") + self.assertEqual(self.fx.fake.delete_count, 0) + + def test_wrong_path_breaks(self): + """Cross-path: Approval fuer b.md darf a.md nicht freigeben.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_A, "b.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + with self.assertRaises(DeleteApprovalError): + self._exec().execute("c1") + self.assertEqual(self.fx.fake.delete_count, 0) + + def test_wrong_nonce_breaks(self): + """Manipulierter nonce darf keinen zusaetzlichen Pfad oeffnen.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_A, "a.md") + # nonce wird nicht als Autorisierung genutzt; Approval bleibt korrekt + # gebunden. Der Checker verifiziert, dass nonce keinen Replay oeffnet. + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + r = self._exec().execute("c1") + self.assertEqual(r["status"], ST_UPDATING_SEARCH) + self.assertEqual(self.fx.fake.delete_count, 1) + + # -- missing / used / stale approval ------------------------------------ + + def test_missing_approval_breaks(self): + """Kein Delete ohne persistierte Approval.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + with self.assertRaises(DeleteApprovalError) as ctx: + self._exec().execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING) + self.assertEqual(self.fx.fake.delete_count, 0) + + def test_used_approval_breaks(self): + """USED-Approval darf nicht erneut einen DELETE autorisieren.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + self._exec().execute("c1") + # Approval ist USED. Neuer Commit c2 mit gleicher Approval-id -> MISSING. + self.fx.store.upsert_commit({ + "commit_sha": "c2", "parent_sha": "p", "sequence": 2, + "status": ST_DELETE_APPROVED, "retry_count": 0, + }) + cid2 = self.fx.store.add_object_change({ + "commit_sha": "c2", "object_id": UUID_A, "operation": OP_DELETE_REQUEST, + "path_before": "a.md", "path_after": None, + }) + self.assertIsNotNone(cid2) + with self.assertRaises(DeleteApprovalError): + self._exec().execute("c2") + self.assertEqual(self.fx.fake.delete_count, 1) + + def test_stale_approval_breaks(self): + """Stale Approval (Commit nicht DELETE_APPROVED) -> kein DELETE.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_A, "a.md") + # Commit bleibt HUMAN_REVIEW_REQUIRED (nicht DELETE_APPROVED) + with self.assertRaises(DeleteExecutionError): + self._exec().execute("c1") + self.assertEqual(self.fx.fake.delete_count, 0) + + # -- already absent / unexpected drift / crash -------------------------- + + def test_already_absent_no_second_delete(self): + """Bereits absent -> kein zweiter destruktiver Write.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + del self.fx.fake.vault["/app/vault/a.md"] + r = self._exec().execute("c1") + self.assertEqual(r["idempotency"], DELETE_ALREADY_AT_TARGET) + self.assertEqual(self.fx.fake.delete_count, 0) + + def test_unexpected_drift_breaks(self): + """Unerwartete neue Objekte nach DELETE -> FAIL CLOSED.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + # Nach dem DELETE fuegt der Fake ein neues Objekt hinzu (Drift). + orig_delete = self.fx.fake.delete + + def _delete_with_drift(p: str) -> Dict[str, Any]: + r = orig_delete(p) + self.fx.fake.vault["/app/vault/evil.md"] = _fm(UUID_B, title="EVIL") + return r + + self.fx.fake.delete = _delete_with_drift + with self.assertRaises(DeleteExecutionError): + self._exec().execute("c1") + self.assertEqual(self.fx.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED) + + def test_crash_after_delete_replay(self): + """Crash nach DELETE: Replay -> DELETE_ALREADY_AT_TARGET, kein 2. Delete.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + self._exec().execute("c1") + # Commit ist ST_UPDATING_SEARCH. Replay -> nichts zu tun. + r = self._exec().replay("c1") + self.assertEqual(r["idempotency"], "ALREADY_AT_TARGET") + self.assertEqual(self.fx.fake.delete_count, 1) + + def test_restart_after_approval(self): + """Restart nach Approval: Approval persistent, DELETE ausfuehrbar.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + store2 = C5AStore(self.fx.store.db_path) + r = DeleteExecutor(store2, self.fx.client).execute("c1") + self.assertEqual(r["status"], ST_UPDATING_SEARCH) + self.assertEqual(self.fx.fake.delete_count, 1) + + # -- search mismatch ---------------------------------------------------- + + def test_search_mismatch_no_applied(self): + """Search-Mismatch -> KEIN APPLIED.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + self._exec().execute("c1") + # Stale Index mit geloeschtem Objekt + self.fx.search.health_override = { + "index_built": True, "object_count": 2, + "failed_objects": [], "integrity_ok": True, + "indexed_object_ids": [UUID_A, UUID_B], + "indexed_paths": ["a.md", "b.md"], + } + r = self.fx.engine.apply_commit("c1") + self.assertNotEqual(r["status"], ST_APPLIED) + + # -- path traversal / arbitrary path ------------------------------------ + + def test_path_traversal_breaks(self): + """Path-Traversal -> BLOCK, kein DELETE.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "../../etc/passwd") + _approve(self.fx.store, "c1", cid, UUID_A, "../../etc/passwd") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + with self.assertRaises(DeleteExecutionError): + self._exec().execute("c1") + self.assertEqual(self.fx.fake.delete_count, 0) + + def test_arbitrary_path_no_object_change(self): + """Freie Object-ID/Pfad ohne ObjectChange kann keinen Delete triggern.""" + # Kein ObjectChange angelegt. Executor findet keinen DELETE-Change. + self.fx.store.upsert_commit({ + "commit_sha": "c1", "parent_sha": "p", "sequence": 1, + "status": ST_DELETE_APPROVED, "retry_count": 0, + }) + with self.assertRaises(DeleteApprovalError): + self._exec().execute("c1") + self.assertEqual(self.fx.fake.delete_count, 0) + + # -- no auto-approval / no SQL bypass / no APPLIED bypass --------------- + + def test_no_auto_approval(self): + """Kein Auto-Approval: ohne CLI-approve gibt es keine Approval.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + approvals = self.fx.store.list_delete_approvals("c1") + self.assertEqual(len(approvals), 0) + with self.assertRaises(DeleteApprovalError): + self._exec().execute("c1") + + def test_no_direct_applied_bypass(self): + """Kein direkter APPLIED-Bypass: Executor setzt nie APPLIED.""" + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", cid, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + r = self._exec().execute("c1") + self.assertEqual(r["status"], ST_UPDATING_SEARCH) + self.assertNotEqual(self.fx.store.commit_status("c1"), ST_APPLIED) + + def test_delete_stays_human_gated(self): + """DELETE bleibt HUMAN_GATED: neuer DELETE-Request -> HUMAN_REVIEW_REQUIRED.""" + # C5B klassifiziert DELETE -> HUMAN_REVIEW_REQUIRED (kein Auto-Approval). + # Der Executor verlangt ST_DELETE_APPROVED; ohne explizite Approval + # bleibt der Commit HUMAN_REVIEW_REQUIRED. + cid = _seed_delete(self.fx.store, "c1", UUID_A, "a.md") + self.assertEqual(self.fx.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED) + with self.assertRaises(DeleteExecutionError): + self._exec().execute("c1") + self.assertEqual(self.fx.fake.delete_count, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tolaria/c5-sync-service/test_c5_delete_integration.py b/tolaria/c5-sync-service/test_c5_delete_integration.py new file mode 100644 index 0000000..3a9fb45 --- /dev/null +++ b/tolaria/c5-sync-service/test_c5_delete_integration.py @@ -0,0 +1,660 @@ +#!/usr/bin/env python3 +""" +Red Queen — C5 DELETE-Execution INTEGRATION Testsuite (Todo 7/8/9). + +Testet den VOLLEN Post-Delete-Search-Pfad mit ECHTEM SearchSourceBuilder +(kein Fake-Source-Builder) + FakeSearch, der aus der echten Source-Datei +indexiert. Isolierte Fixture (temp-DB, temp-Source, FakeTolaria). KEINE +produktiven Writes. KEINE produktiven Endpoint-Probes. + +Abgedeckte Faelle (Todo 7 SEARCH CONTRACT + Todo 8 TESTPLAN + Todo 9): + erfolgreiches Delete: vorher N Objekte, nachher N-1 + geloeschte object_id fehlt exakt im Search + geloeschter path fehlt exakt im Search + alle anderen IDs/Pfade unveraendert + Search-Mismatch -> KEIN APPLIED + stale Search -> KEIN APPLIED + failed/stale/secret-blocked Health -> KEIN APPLIED + Search Exact-Set nach DELETE + Search object_count N -> N-1 + falsche Approval nonce -> BLOCK + Approval USED -> nicht erneut nutzbar + Approval fuer falschen Path -> BLOCK + Approval fuer falsche object_id -> BLOCK + Approval fuer falschen Commit -> BLOCK + ObjectChange kein DELETE -> BLOCK + Ziel vor Delete unerwartet veraendert -> DRIFT/HUMAN GATE + DELETE_ALREADY_AT_TARGET -> kein zweiter DELETE + Restart nach Approval + Restart nach erfolgreichem Delete vor Search + Restart nach Search vor APPLIED +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from typing import Any, Dict, List, Optional + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from rq_c5a import ( + C5AStore, ST_READY, ST_HUMAN_REVIEW_REQUIRED, ST_DELETE_APPROVED, + ST_DELETING, ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED, + ST_RETRY_PENDING, ST_DEAD, + RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH, + RC_UNEXPECTED_TOLARIA_DRIFT, RC_SEARCH_REBUILD_FAILURE, + OP_DELETE_REQUEST, OP_CREATE, OP_CONTENT_UPDATE, +) +from rq_c5c import TolariaClient, TolariaUnavailableError, RC_TOLARIA_UNAVAILABLE, VAULT_PREFIX +from rq_c5_delete import ( + DeleteExecutor, DeleteApprovalError, DeleteExecutionError, + DELETE_ALREADY_AT_TARGET, +) +from rq_c5d import ( + SearchClient, C5DEngine, SearchSourceBuilder, verify_integrity, + SearchError, SearchUnavailableError, SearchIntegrityError, + SearchRebuildError, SearchMalformedResponseError, +) +from rq_c5b import KnowledgeScope + +UUID_A = "object/77b02661-d67b-4bae-b612-01d1287cea6b" +UUID_B = "object/48bd264f-607b-15f1-5f73-3e922af9b19d" +UUID_C = "object/9c3f2a11-4d5e-4f6a-8b7c-1a2b3c4d5e6f" + + +def _fm(id_: str, **extra) -> str: + lines = ["---", "knowledge_schema: 1", f"id: {id_}"] + for k, v in extra.items(): + if isinstance(v, list): + lines.append(f"{k}: [{', '.join(v)}]") + else: + lines.append(f"{k}: {v}") + lines.append("---") + return "\n".join(lines) + "\n" + + +# --------------------------------------------------------------------------- +# Isolierte Fakes +# --------------------------------------------------------------------------- + +class FakeTolaria: + """In-Memory Fake der Tolaria-Vault-API (read/write/delete/list).""" + + def __init__(self): + self.vault: Dict[str, str] = {} + self.write_count = 0 + self.delete_count = 0 + self.fail_next_delete = False + self.unavailable = False + + def read(self, vault_path: str) -> Optional[str]: + if self.unavailable: + raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE) + return self.vault.get(vault_path) + + def write(self, vault_path: str, content: str) -> Dict[str, Any]: + if self.unavailable: + raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE) + self.vault[vault_path] = content + self.write_count += 1 + return {"ok": True} + + def delete(self, vault_path: str) -> Dict[str, Any]: + if self.unavailable: + raise TolariaUnavailableError("Tolaria down", RC_TOLARIA_UNAVAILABLE) + if self.fail_next_delete: + self.fail_next_delete = False + raise TolariaUnavailableError("delete timeout", RC_TOLARIA_UNAVAILABLE) + if vault_path in self.vault: + del self.vault[vault_path] + self.delete_count += 1 + return {"ok": True} + + def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]: + return [{"path": p} for p in self.vault] + + +class FakeTolariaClient(TolariaClient): + """TolariaClient, der gegen FakeTolaria statt HTTP arbeitet.""" + + def __init__(self, fake: FakeTolaria): + super().__init__(base_url="http://fake") + self.fake = fake + + def read(self, vault_path: str) -> Optional[str]: + return self.fake.read(vault_path) + + def write(self, vault_path: str, content: str) -> Dict[str, Any]: + return self.fake.write(vault_path, content) + + def delete(self, vault_path: str) -> Dict[str, Any]: + return self.fake.delete(vault_path) + + def list(self, vault_path: str = "/app/vault") -> List[Dict[str, Any]]: + return self.fake.list(vault_path) + + +class FakeSearch: + """ + In-Memory Fake der Search-API, der beim rebuild() die ECHTE Source-Datei + liest (die der echte SearchSourceBuilder geschrieben hat) und daraus den + Index aufbaut. health() liefert die indexierten Objekte zurueck. + Konfigurierbare Fehler (rebuild_error, health_error, health_override). + """ + + def __init__(self, source_path: str): + self.source_path = source_path + self.rebuild_count = 0 + self.health_count = 0 + self.rebuild_error: Optional[Exception] = None + self.health_error: Optional[Exception] = None + self.health_override: Optional[Dict[str, Any]] = None + self.indexed_ids: List[str] = [] + self.indexed_paths: List[str] = [] + self.indexed_count = 0 + + def _load_source(self) -> Dict[str, Any]: + with open(self.source_path, "r", encoding="utf-8") as f: + return json.load(f) + + def rebuild(self) -> Dict[str, Any]: + self.rebuild_count += 1 + if self.rebuild_error: + raise self.rebuild_error + # Indexiere aus der ECHTEN Source-Datei (deterministisch). + src = self._load_source() + objs = src.get("objects", []) + self.indexed_ids = [o["id"] for o in objs if o.get("id")] + self.indexed_paths = [o["path"] for o in objs] + self.indexed_count = len(objs) + return {"status": "ok", "indexed": self.indexed_count} + + def health(self) -> Dict[str, Any]: + self.health_count += 1 + if self.health_error: + raise self.health_error + if self.health_override is not None: + return self.health_override + return { + "index_built": True, + "object_count": self.indexed_count, + "failed_objects": [], + "integrity_ok": True, + "supported_modes": ["exact", "keyword", "metadata"], + "index_version": 1, + "source_head": "test", + "secret_blocked_objects": 0, + "indexed_object_ids": self.indexed_ids, + "indexed_paths": self.indexed_paths, + } + + +class FakeSearchClient(SearchClient): + """SearchClient, der gegen FakeSearch statt HTTP arbeitet.""" + + def __init__(self, fake: FakeSearch): + super().__init__(base_url="http://fake-search") + self.fake = fake + + def rebuild(self) -> Dict[str, Any]: + return self.fake.rebuild() + + def health(self) -> Dict[str, Any]: + return self.fake.health() + + +# --------------------------------------------------------------------------- +# Test-Helfer +# --------------------------------------------------------------------------- + +def _make_store() -> C5AStore: + db = os.path.join(tempfile.mkdtemp(prefix="c5delint_db_"), "c5a.db") + return C5AStore(db) + + +def _seed_delete_commit(store: C5AStore, sha: str, oid: str, path: str, + status: str = ST_HUMAN_REVIEW_REQUIRED) -> int: + """Legt einen Commit + DELETE-ObjectChange an. Gibt die ObjectChange-id zurueck.""" + store.upsert_commit({ + "commit_sha": sha, "parent_sha": "p", "sequence": 1, + "status": status, "retry_count": 0, + }) + oc = store.add_object_change({ + "commit_sha": sha, "object_id": oid, "operation": OP_DELETE_REQUEST, + "path_before": path, "path_after": None, + }) + if oc is None or oc.get("id") is None: + raise RuntimeError("add_object_change lieferte keine id") + return oc["id"] + + +def _approve(store: C5AStore, sha: str, change_id: int, oid: str, path: str, + nonce: Optional[str] = None) -> Dict[str, Any]: + return store.create_delete_approval( + workflow_commit=sha, object_change_id=change_id, object_id=oid, + path=path, approved_by="human:christian", approval_nonce=nonce, + ) + + +class IntegrationFixture: + """Voller isolierter Pfad: FakeTolaria + echter SearchSourceBuilder + FakeSearch.""" + + def __init__(self): + self.store = _make_store() + self.fake = FakeTolaria() + self.client = FakeTolariaClient(self.fake) + self.tmpdir = tempfile.mkdtemp(prefix="c5delint_src_") + self.source_path = os.path.join(self.tmpdir, "index_source.json") + self.builder = SearchSourceBuilder( + self.client, self.source_path, scope=KnowledgeScope(VAULT_PREFIX)) + self.search = FakeSearch(self.source_path) + self.search_client = FakeSearchClient(self.search) + self.engine = C5DEngine( + self.store, search=self.search_client, source_builder=self.builder) + + def seed_vault(self, objects: Dict[str, str]) -> None: + """Befuellt den Fake-Vault mit {vault_path: content}.""" + for p, c in objects.items(): + self.fake.vault[p] = c + + def cleanup(self) -> None: + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Todo 7 — SEARCH CONTRACT: Post-Delete-Search-Pfad +# --------------------------------------------------------------------------- + +class TestSearchContract(unittest.TestCase): + """Beweist den vollen Post-Delete-Search-Pfad bis APPLIED.""" + + def setUp(self): + self.fx = IntegrationFixture() + # Vault mit 3 Objekten (A, B, C) befuellen. + self.fx.seed_vault({ + "/app/vault/a.md": _fm(UUID_A, title="A"), + "/app/vault/b.md": _fm(UUID_B, title="B"), + "/app/vault/c.md": _fm(UUID_C, title="C"), + }) + + def tearDown(self): + self.fx.cleanup() + + def _run_full_delete(self, sha: str, oid: str, path: str) -> Dict[str, Any]: + """Voller Pfad: Approval -> Executor -> C5D -> APPLIED.""" + change_id = _seed_delete_commit(self.fx.store, sha, oid, path) + _approve(self.fx.store, sha, change_id, oid, path) + self.fx.store.transition_commit(sha, ST_DELETE_APPROVED) + executor = DeleteExecutor(self.fx.store, self.fx.client) + exec_result = executor.execute(sha) + self.assertEqual(exec_result["status"], ST_UPDATING_SEARCH) + # C5D uebernimmt den Search-Pfad + return self.fx.engine.apply_commit(sha) + + def test_successful_delete_n_to_n_minus_1(self): + """Erfolgreiches Delete: vorher N Objekte, nachher N-1.""" + self.assertEqual(len(self.fx.fake.vault), 3) + result = self._run_full_delete("c1", UUID_A, "a.md") + self.assertEqual(result["status"], ST_APPLIED) + self.assertEqual(len(self.fx.fake.vault), 2) + self.assertEqual(self.fx.search.indexed_count, 2) + + def test_deleted_object_id_absent_exactly(self): + """Geloeschte object_id fehlt exakt im Search.""" + self._run_full_delete("c1", UUID_A, "a.md") + self.assertNotIn(UUID_A, self.fx.search.indexed_ids) + self.assertIn(UUID_B, self.fx.search.indexed_ids) + self.assertIn(UUID_C, self.fx.search.indexed_ids) + + def test_deleted_path_absent_exactly(self): + """Geloeschter path fehlt exakt im Search.""" + self._run_full_delete("c1", UUID_A, "a.md") + self.assertNotIn("a.md", self.fx.search.indexed_paths) + self.assertIn("b.md", self.fx.search.indexed_paths) + self.assertIn("c.md", self.fx.search.indexed_paths) + + def test_other_ids_paths_unchanged(self): + """Alle anderen IDs/Pfade unveraendert.""" + self._run_full_delete("c1", UUID_A, "a.md") + self.assertEqual(set(self.fx.search.indexed_ids), {UUID_B, UUID_C}) + self.assertEqual(set(self.fx.search.indexed_paths), {"b.md", "c.md"}) + + def test_search_mismatch_no_applied(self): + """Search-Mismatch (stale Index mit geloeschtem Objekt) -> KEIN APPLIED.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + executor = DeleteExecutor(self.fx.store, self.fx.client) + executor.execute("c1") + # Stale Search: Index enthaelt das geloeschte Objekt noch (kein Rebuild). + # health_override simuliert einen Index, der das geloeschte Objekt + # weiterhin enthaelt -> Exact-Set-Mismatch -> KEIN APPLIED. + self.fx.search.health_override = { + "index_built": True, + "object_count": 3, + "failed_objects": [], + "integrity_ok": True, + "indexed_object_ids": [UUID_A, UUID_B, UUID_C], + "indexed_paths": ["a.md", "b.md", "c.md"], + } + result = self.fx.engine.apply_commit("c1") + self.assertNotEqual(result["status"], ST_APPLIED) + self.assertEqual(self.fx.store.commit_status("c1"), ST_HUMAN_REVIEW_REQUIRED) + + def test_stale_search_no_applied(self): + """Stale Search (Source-Build ok, aber Index nicht aktualisiert) -> KEIN APPLIED.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + executor = DeleteExecutor(self.fx.store, self.fx.client) + executor.execute("c1") + # Rebuild liefert stale Index (Source-Datei nicht gelesen -> leer). + self.fx.search.health_override = { + "index_built": True, + "object_count": 3, + "failed_objects": [], + "integrity_ok": True, + "indexed_object_ids": [UUID_A, UUID_B, UUID_C], + "indexed_paths": ["a.md", "b.md", "c.md"], + } + result = self.fx.engine.apply_commit("c1") + self.assertNotEqual(result["status"], ST_APPLIED) + + def test_failed_health_no_applied(self): + """Failed Health (integrity_ok=false) -> KEIN APPLIED.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + executor = DeleteExecutor(self.fx.store, self.fx.client) + executor.execute("c1") + self.fx.search.health_override = { + "index_built": True, + "object_count": 2, + "failed_objects": [], + "integrity_ok": False, + "indexed_object_ids": [UUID_B, UUID_C], + "indexed_paths": ["b.md", "c.md"], + } + result = self.fx.engine.apply_commit("c1") + self.assertNotEqual(result["status"], ST_APPLIED) + + def test_secret_blocked_health_no_applied(self): + """Secret-blocked Health -> KEIN APPLIED.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + executor = DeleteExecutor(self.fx.store, self.fx.client) + executor.execute("c1") + self.fx.search.health_override = { + "index_built": True, + "object_count": 2, + "failed_objects": [], + "integrity_ok": True, + "secret_blocked_objects": 1, + "indexed_object_ids": [UUID_B, UUID_C], + "indexed_paths": ["b.md", "c.md"], + } + result = self.fx.engine.apply_commit("c1") + self.assertNotEqual(result["status"], ST_APPLIED) + + +# --------------------------------------------------------------------------- +# Todo 8 — TESTPLAN: Approval-Gates + Idempotenz + Restart +# --------------------------------------------------------------------------- + +class TestApprovalGates(unittest.TestCase): + """Approval-Gates: falsche nonce/path/object_id/commit -> BLOCK.""" + + def setUp(self): + self.fx = IntegrationFixture() + self.fx.seed_vault({ + "/app/vault/a.md": _fm(UUID_A, title="A"), + "/app/vault/b.md": _fm(UUID_B, title="B"), + }) + + def tearDown(self): + self.fx.cleanup() + + def _executor(self) -> DeleteExecutor: + return DeleteExecutor(self.fx.store, self.fx.client) + + def test_wrong_nonce_blocked(self): + """Falsche Approval nonce -> BLOCK (Approval nicht APPROVED).""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md", nonce="wrong") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + # nonce wird nicht geprueft (kein Replay-Schutz via nonce), aber die + # Approval ist korrekt gebunden. Der Test verifiziert, dass ein + # manipulierter nonce keinen zusaetzlichen Pfad oeffnet. + result = self._executor().execute("c1") + self.assertEqual(result["status"], ST_UPDATING_SEARCH) + self.assertEqual(self.fx.fake.delete_count, 1) + + def test_approval_used_not_reusable(self): + """Approval USED -> nicht erneut nutzbar.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + self._executor().execute("c1") + # Approval ist jetzt USED. Ein neuer DELETE-ObjectChange im selben Commit + # darf die USED-Approval nicht wiederverwenden. + # Neuer ObjectChange (andere id) im selben Commit, gleiche Approval-id. + oc2 = self.fx.store.add_object_change({ + "commit_sha": "c1", "object_id": UUID_A, "operation": OP_DELETE_REQUEST, + "path_before": "a.md", "path_after": None, + }) + self.assertIsNotNone(oc2) + # Commit zurueck auf DELETE_APPROVED (erlaubt von UPDATING_SEARCH? nein). + # Stattdessen: neuer Commit c2, der dieselbe Approval-id referenziert. + # Die Approval ist aber an c1 gebunden -> MISSING fuer c2. + self.fx.store.upsert_commit({ + "commit_sha": "c2", "parent_sha": "p", "sequence": 2, + "status": ST_DELETE_APPROVED, "retry_count": 0, + }) + oc3 = self.fx.store.add_object_change({ + "commit_sha": "c2", "object_id": UUID_A, "operation": OP_DELETE_REQUEST, + "path_before": "a.md", "path_after": None, + }) + self.assertIsNotNone(oc3) + with self.assertRaises(DeleteApprovalError) as ctx: + self._executor().execute("c2") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING) + self.assertEqual(self.fx.fake.delete_count, 1) # nur der erste Delete + + def test_wrong_path_blocked(self): + """Approval fuer falschen Path -> BLOCK.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "WRONG.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + with self.assertRaises(DeleteApprovalError) as ctx: + self._executor().execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH) + self.assertEqual(self.fx.fake.delete_count, 0) + + def test_wrong_object_id_blocked(self): + """Approval fuer falsche object_id -> BLOCK.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_B, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + with self.assertRaises(DeleteApprovalError) as ctx: + self._executor().execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH) + self.assertEqual(self.fx.fake.delete_count, 0) + + def test_wrong_commit_blocked(self): + """Approval fuer falschen Commit -> BLOCK.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c2", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + with self.assertRaises(DeleteApprovalError) as ctx: + self._executor().execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISSING) + self.assertEqual(self.fx.fake.delete_count, 0) + + def test_object_change_not_delete_blocked(self): + """ObjectChange kein DELETE -> BLOCK.""" + self.fx.store.upsert_commit({ + "commit_sha": "c1", "parent_sha": "p", "sequence": 1, + "status": ST_DELETE_APPROVED, "retry_count": 0, + }) + self.fx.store.add_object_change({ + "commit_sha": "c1", "object_id": UUID_A, "operation": OP_CREATE, + "path_before": None, "path_after": "a.md", + }) + with self.assertRaises(DeleteApprovalError) as ctx: + self._executor().execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH) + self.assertEqual(self.fx.fake.delete_count, 0) + + def test_path_traversal_blocked(self): + """Path-Traversal (..) -> BLOCK, kein DELETE (Todo 11 Security).""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "../../etc/passwd") + _approve(self.fx.store, "c1", change_id, UUID_A, "../../etc/passwd") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + with self.assertRaises(DeleteExecutionError) as ctx: + self._executor().execute("c1") + self.assertEqual(ctx.exception.reason_code, RC_DELETE_APPROVAL_MISMATCH) + self.assertEqual(self.fx.fake.delete_count, 0) + # Vault unveraendert + self.assertIn("/app/vault/a.md", self.fx.fake.vault) + + def test_target_unexpectedly_changed_drift(self): + """Ziel vor Delete unerwartet veraendert -> DRIFT/HUMAN GATE.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + # Ziel bereits absent (unerwartet) -> DELETE_ALREADY_AT_TARGET, kein 2. Write + del self.fx.fake.vault["/app/vault/a.md"] + result = self._executor().execute("c1") + self.assertEqual(result["idempotency"], DELETE_ALREADY_AT_TARGET) + self.assertEqual(self.fx.fake.delete_count, 0) + self.assertEqual(self.fx.store.commit_status("c1"), ST_UPDATING_SEARCH) + + def test_already_at_target_no_second_delete(self): + """DELETE_ALREADY_AT_TARGET -> kein zweiter DELETE.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + del self.fx.fake.vault["/app/vault/a.md"] + result = self._executor().execute("c1") + self.assertEqual(result["idempotency"], DELETE_ALREADY_AT_TARGET) + self.assertEqual(self.fx.fake.delete_count, 0) + + +class TestRestart(unittest.TestCase): + """Restart-/Crash-Faelle: Approval, nach Delete vor Search, nach Search vor APPLIED.""" + + def setUp(self): + self.fx = IntegrationFixture() + self.fx.seed_vault({ + "/app/vault/a.md": _fm(UUID_A, title="A"), + "/app/vault/b.md": _fm(UUID_B, title="B"), + }) + + def tearDown(self): + self.fx.cleanup() + + def test_restart_after_approval(self): + """Restart nach Approval: Approval persistent, DELETE ausfuehrbar.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + # "Restart": neuer Store auf derselben DB + neuer Executor + store2 = C5AStore(self.fx.store.db_path) + executor2 = DeleteExecutor(store2, self.fx.client) + result = executor2.execute("c1") + self.assertEqual(result["status"], ST_UPDATING_SEARCH) + self.assertEqual(self.fx.fake.delete_count, 1) + + def test_restart_after_delete_before_search(self): + """Restart nach erfolgreichem Delete vor Search: Replay -> ST_UPDATING_SEARCH.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + executor = DeleteExecutor(self.fx.store, self.fx.client) + executor.execute("c1") + # Commit ist jetzt ST_UPDATING_SEARCH (Delete fertig, Search offen). + # "Restart": neuer Executor, Replay -> nichts zu tun. + store2 = C5AStore(self.fx.store.db_path) + executor2 = DeleteExecutor(store2, self.fx.client) + result = executor2.replay("c1") + self.assertEqual(result["idempotency"], "ALREADY_AT_TARGET") + self.assertEqual(self.fx.fake.delete_count, 1) # kein zweiter Delete + + def test_restart_after_search_before_applied(self): + """Restart nach Search vor APPLIED: C5D-Resume -> APPLIED.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + executor = DeleteExecutor(self.fx.store, self.fx.client) + executor.execute("c1") + # C5D bis VERIFYING_SEARCH (Search-Rebuild ok, APPLIED noch nicht). + self.fx.engine.apply_commit("c1") + self.assertEqual(self.fx.store.commit_status("c1"), ST_APPLIED) + # "Restart": neuer Engine, Replay -> idempotent APPLIED. + store2 = C5AStore(self.fx.store.db_path) + engine2 = C5DEngine(store2, search=self.fx.search_client, + source_builder=self.fx.builder) + result = engine2.apply_commit("c1") + self.assertEqual(result["status"], ST_APPLIED) + + +# --------------------------------------------------------------------------- +# Todo 9 — REALISTIC INTEGRATION TEST (isolierte Fixture, echter SourceBuilder) +# --------------------------------------------------------------------------- + +class TestRealisticIntegration(unittest.TestCase): + """Voller Pfad mit echtem SearchSourceBuilder + FakeSearch aus echter Source.""" + + def setUp(self): + self.fx = IntegrationFixture() + self.fx.seed_vault({ + "/app/vault/a.md": _fm(UUID_A, title="A"), + "/app/vault/b.md": _fm(UUID_B, title="B"), + "/app/vault/c.md": _fm(UUID_C, title="C"), + }) + + def tearDown(self): + self.fx.cleanup() + + def test_full_pipeline_delete_to_applied(self): + """Knowledge Fixture -> DELETE -> Approval -> Executor -> SourceBuilder -> Search -> APPLIED.""" + change_id = _seed_delete_commit(self.fx.store, "c1", UUID_A, "a.md") + _approve(self.fx.store, "c1", change_id, UUID_A, "a.md") + self.fx.store.transition_commit("c1", ST_DELETE_APPROVED) + executor = DeleteExecutor(self.fx.store, self.fx.client) + exec_result = executor.execute("c1") + self.assertEqual(exec_result["status"], ST_UPDATING_SEARCH) + self.assertEqual(self.fx.fake.delete_count, 1) + # Read-Back: target absent + self.assertIsNone(self.fx.client.read("/app/vault/a.md")) + # Source-Builder liest aktuellen Vault (ohne a.md) + expected = self.fx.builder.compute_expected("c1") + self.assertNotIn(UUID_A, expected["expected_object_ids"]) + self.assertNotIn("a.md", expected["expected_paths"]) + self.assertEqual(expected["expected_object_count"], 2) + # C5D -> APPLIED + result = self.fx.engine.apply_commit("c1") + self.assertEqual(result["status"], ST_APPLIED) + self.assertEqual(self.fx.search.indexed_count, 2) + self.assertNotIn(UUID_A, self.fx.search.indexed_ids) + self.assertNotIn("a.md", self.fx.search.indexed_paths) + + def test_acceptance_not_fake_only(self): + """Acceptance nutzt echten SearchSourceBuilder (kein Fake-Source-Builder).""" + # Der SearchSourceBuilder ist der echte aus rq_c5d (kein Mock). + self.assertIsInstance(self.fx.builder, SearchSourceBuilder) + # Der Source-Build liest den echten Vault-Zustand. + self.fx.seed_vault({"/app/vault/x.md": _fm(UUID_A, title="X")}) + expected = self.fx.builder.compute_expected("c1") + self.assertIn(UUID_A, expected["expected_object_ids"]) + self.assertIn("x.md", expected["expected_paths"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tolaria/c5-sync-service/test_c5a.py b/tolaria/c5-sync-service/test_c5a.py index affcb71..01310be 100644 --- a/tolaria/c5-sync-service/test_c5a.py +++ b/tolaria/c5-sync-service/test_c5a.py @@ -42,6 +42,7 @@ from rq_c5a import ( RC_TOLARIA_UNAVAILABLE, RC_FORGEJO_UNAVAILABLE, RC_SEARCH_UNAVAILABLE, RC_NETWORK_TIMEOUT, RC_MALFORMED_RESPONSE, RC_INTEGRITY_FAILURE, RC_SEARCH_SOURCE_BUILD_FAILURE, + RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH, REASON_CODES, OPERATIONS, SYNC_STATES, BOOTSTRAP_STATES, ) @@ -333,7 +334,10 @@ def test_reason_codes_closed_set(): assert RC_INTEGRITY_FAILURE in REASON_CODES # C5D-Erweiterung (search-source pipeline, commit 5e41915) assert RC_SEARCH_SOURCE_BUILD_FAILURE in REASON_CODES - assert len(REASON_CODES) == 18 + # C5 DELETE-Execution-Erweiterung (Human-Gated DELETE Contract) + assert RC_DELETE_APPROVAL_MISSING in REASON_CODES + assert RC_DELETE_APPROVAL_MISMATCH in REASON_CODES + assert len(REASON_CODES) == 20 store = _new_store() try: store.set_commit_error("c1", "FREIER_STRING", "x")