C5: Human-Gated DELETE Execution Contract (Todo 7-13)

- 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)
This commit is contained in:
Red Queen 2026-08-26 19:22:44 +00:00
parent 363f27327b
commit 3db1c71b68
11 changed files with 2604 additions and 2 deletions

View file

@ -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 C5AC5E). Ergebnis:
| Owner | Verantwortlich | Status |
|---|---|---|
| DELETE_DETECTION_OWNER | C5B `ChangeClassifier.classify()` (Z.520523): `path_before && !path_after``OP_DELETE_REQUEST` | vorhanden |
| DELETE_HUMAN_GATE_OWNER | C5A `SyncStateMachine.process_commit()` (Z.794797) + C5C `_propagate_object()` (Z.492496) → `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 (AN) 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 <sha> --object-id <id> --path <path>` → persistiert Approval (NUR Approval, KEIN DELETE)
2. `c5-delete execute --commit <sha>` → führt DELETE aus (verlangt persistierte Approval)
3. `c5-delete status --commit <sha>` → read-only Status
4. `c5-delete replay --commit <sha>` → 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 AF)
| 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 <sha> --object-id <id> --path <path>` → persistiert Approval (NUR Approval)
- `c5-delete execute --commit <sha>` → führt DELETE aus (verlangt persistierte Approval)
- `c5-delete status --commit <sha>` → read-only Status
- `c5-delete replay --commit <sha>` → 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.

View file

@ -35,7 +35,7 @@ import os
import sys import sys
from typing import Any, Dict, List, Optional 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 ( from rq_c5b import (
C5BPoller, C5BPoller,
GitReader, GitReader,
@ -70,6 +70,11 @@ from rq_c5e import (
health_contract, health_contract,
failure_evidence, failure_evidence,
) )
from rq_c5_delete import (
DeleteExecutor,
DeleteApprovalError,
DeleteExecutionError,
)
def _store(db_path: str) -> C5AStore: def _store(db_path: str) -> C5AStore:
@ -332,6 +337,136 @@ def cmd_c5e_guarantees(args: argparse.Namespace) -> int:
return 0 if ok else 1 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: def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="rq_c5_cli", 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 = sub.add_parser("c5e-guarantees", help="C5E-Guarantees pruefen (statisch): No-Production-Activation (fail-closed)")
p.set_defaults(func=cmd_c5e_guarantees) 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) args = parser.parse_args(argv)
return args.func(args) return args.func(args)

View file

@ -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}"

View file

@ -68,6 +68,10 @@ ST_HUMAN_REVIEW_REQUIRED = "HUMAN_REVIEW_REQUIRED"
# Ordering-Zustand # Ordering-Zustand
ST_WAITING_FOR_PREDECESSOR = "WAITING_FOR_PREDECESSOR" 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 # Bootstrap-Zustände
BS_UNINITIALIZED = "UNINITIALIZED" BS_UNINITIALIZED = "UNINITIALIZED"
BS_RECONCILING = "RECONCILING" BS_RECONCILING = "RECONCILING"
@ -124,6 +128,13 @@ RC_INTEGRITY_FAILURE = "INTEGRITY_FAILURE" # Search-Health/Integrity nicht P
# Source stattfinden; FAIL CLOSED vor APPLIED. # Source stattfinden; FAIL CLOSED vor APPLIED.
RC_SEARCH_SOURCE_BUILD_FAILURE = "SEARCH_SOURCE_BUILD_FAILURE" 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 # Retry / Backoff
DEFAULT_MAX_RETRIES = 5 DEFAULT_MAX_RETRIES = 5
DEFAULT_BACKOFF_SECONDS = [1, 2, 4, 8, 16] DEFAULT_BACKOFF_SECONDS = [1, 2, 4, 8, 16]
@ -155,6 +166,9 @@ REASON_CODES = frozenset({
RC_INTEGRITY_FAILURE, RC_INTEGRITY_FAILURE,
# C5D: Search-Source-Build/Verification # C5D: Search-Source-Build/Verification
RC_SEARCH_SOURCE_BUILD_FAILURE, RC_SEARCH_SOURCE_BUILD_FAILURE,
# C5 DELETE-Execution: Approval-/Gate-Fehler
RC_DELETE_APPROVAL_MISSING,
RC_DELETE_APPROVAL_MISMATCH,
}) })
# Alle Operationen als frozenset # Alle Operationen als frozenset
@ -178,6 +192,7 @@ SYNC_STATES = frozenset({
ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED, ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH, ST_APPLIED,
ST_RETRY_PENDING, ST_FAILED, ST_DEAD, ST_HUMAN_REVIEW_REQUIRED, ST_RETRY_PENDING, ST_FAILED, ST_DEAD, ST_HUMAN_REVIEW_REQUIRED,
ST_WAITING_FOR_PREDECESSOR, ST_WAITING_FOR_PREDECESSOR,
ST_DELETE_APPROVED, ST_DELETING,
}) })
# Bootstrap-Zustände # Bootstrap-Zustände
@ -222,6 +237,13 @@ _ALLOWED_TRANSITIONS = {
(ST_HUMAN_REVIEW_REQUIRED, ST_READY), # Human entscheidet -> erneut (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_APPLIED), # Human bestätigt als angewendet
(ST_HUMAN_REVIEW_REQUIRED, ST_DEAD), # Human verwirft (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 # Bootstrap-Übergänge
@ -340,6 +362,26 @@ class C5AStore:
updated_at INTEGER 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 # Schema-Version setzen
self._conn.execute( self._conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)", "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
@ -655,6 +697,98 @@ class C5AStore:
except (ValueError, TypeError): except (ValueError, TypeError):
return None 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: def close(self) -> None:
self._conn.close() self._conn.close()

View file

@ -44,6 +44,8 @@ from rq_c5a import (
ST_RETRY_PENDING, ST_RETRY_PENDING,
ST_DEAD, ST_DEAD,
ST_APPLIED, ST_APPLIED,
ST_DELETE_APPROVED,
ST_DELETING,
IDEM_ALREADY_AT_TARGET, IDEM_ALREADY_AT_TARGET,
IDEM_ALREADY_APPLIED, IDEM_ALREADY_APPLIED,
IDEM_RETRY_SAFE, IDEM_RETRY_SAFE,
@ -275,6 +277,27 @@ class TolariaClient:
f"Tolaria save fehlgeschlagen: {resp['error']}", RC_INVALID_SCHEMA) f"Tolaria save fehlgeschlagen: {resp['error']}", RC_INVALID_SCHEMA)
return resp 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) ------------------------------------------ # -- Verify (Read-Back + Hash) ------------------------------------------
def verify(self, vault_path: str, expected_content: str) -> Dict[str, Any]: def verify(self, vault_path: str, expected_content: str) -> Dict[str, Any]:

View file

@ -489,6 +489,7 @@ def verify_integrity(health: Dict[str, Any],
"index_built": bool(health.get("index_built")), "index_built": bool(health.get("index_built")),
"object_count_positive": int(health.get("object_count", 0)) > 0, "object_count_positive": int(health.get("object_count", 0)) > 0,
"failed_objects_empty": not bool(health.get("failed_objects")), "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: if expected_object_ids is not None:
indexed_ids = set(health.get("indexed_object_ids") or []) indexed_ids = set(health.get("indexed_object_ids") or [])

View file

@ -57,6 +57,8 @@ from rq_c5a import (
ST_VERIFYING_SEARCH, ST_VERIFYING_SEARCH,
ST_VERIFYING_TOLARIA, ST_VERIFYING_TOLARIA,
ST_WAITING_FOR_PREDECESSOR, ST_WAITING_FOR_PREDECESSOR,
ST_DELETE_APPROVED,
ST_DELETING,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -262,6 +264,7 @@ class C5EEngine:
store: C5EStore, store: C5EStore,
propagator: Optional[Any] = None, propagator: Optional[Any] = None,
search_engine: Optional[Any] = None, search_engine: Optional[Any] = None,
delete_executor: Optional[Any] = None,
max_retries: int = DEFAULT_MAX_RETRIES, max_retries: int = DEFAULT_MAX_RETRIES,
backoff_seconds: Optional[List[int]] = None, backoff_seconds: Optional[List[int]] = None,
allow_writes: bool = False, allow_writes: bool = False,
@ -269,6 +272,7 @@ class C5EEngine:
self.store = store self.store = store
self.propagator = propagator # injizierter C5CPropagator (oder Fake) self.propagator = propagator # injizierter C5CPropagator (oder Fake)
self.search_engine = search_engine # injizierte C5DEngine (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.max_retries = max_retries
self.backoff_seconds = backoff_seconds or DEFAULT_BACKOFF_SECONDS self.backoff_seconds = backoff_seconds or DEFAULT_BACKOFF_SECONDS
self.allow_writes = allow_writes self.allow_writes = allow_writes
@ -321,6 +325,15 @@ class C5EEngine:
return {"commit_sha": commit_sha, "decision": REC_RESUME, return {"commit_sha": commit_sha, "decision": REC_RESUME,
"state": cur, "state": cur,
"reason": f"Restart in {cur} -> ab korrektem Schritt fortsetzen"} "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, return {"commit_sha": commit_sha, "decision": REC_HUMAN_REVIEW,
"state": cur, "reason": f"kein deterministischer Recovery-Pfad ({cur})"} "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) --- # --- Suchschritt (UPDATING_SEARCH / VERIFYING_SEARCH / Search-Retry) ---
if cur in (ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH) or is_search_retry: if cur in (ST_UPDATING_SEARCH, ST_VERIFYING_SEARCH) or is_search_retry:
if self.search_engine is None: if self.search_engine is None:

View file

@ -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())

View file

@ -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)

View file

@ -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)

View file

@ -42,6 +42,7 @@ from rq_c5a import (
RC_TOLARIA_UNAVAILABLE, RC_FORGEJO_UNAVAILABLE, RC_TOLARIA_UNAVAILABLE, RC_FORGEJO_UNAVAILABLE,
RC_SEARCH_UNAVAILABLE, RC_NETWORK_TIMEOUT, RC_MALFORMED_RESPONSE, RC_INTEGRITY_FAILURE, RC_SEARCH_UNAVAILABLE, RC_NETWORK_TIMEOUT, RC_MALFORMED_RESPONSE, RC_INTEGRITY_FAILURE,
RC_SEARCH_SOURCE_BUILD_FAILURE, RC_SEARCH_SOURCE_BUILD_FAILURE,
RC_DELETE_APPROVAL_MISSING, RC_DELETE_APPROVAL_MISMATCH,
REASON_CODES, OPERATIONS, SYNC_STATES, BOOTSTRAP_STATES, REASON_CODES, OPERATIONS, SYNC_STATES, BOOTSTRAP_STATES,
) )
@ -333,7 +334,10 @@ def test_reason_codes_closed_set():
assert RC_INTEGRITY_FAILURE in REASON_CODES assert RC_INTEGRITY_FAILURE in REASON_CODES
# C5D-Erweiterung (search-source pipeline, commit 5e41915) # C5D-Erweiterung (search-source pipeline, commit 5e41915)
assert RC_SEARCH_SOURCE_BUILD_FAILURE in REASON_CODES 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() store = _new_store()
try: try:
store.set_commit_error("c1", "FREIER_STRING", "x") store.set_commit_error("c1", "FREIER_STRING", "x")