From ed9e0a444ad5565ded8eab5a2c1dce60adbde454 Mon Sep 17 00:00:00 2001 From: Amy Hermes Date: Mon, 18 May 2026 08:26:31 -0400 Subject: [PATCH] feat: add emergency-stop bridge rehearsal --- HANDOFF.md | 14 ++-- README.md | 2 + ROADMAP.md | 50 +++++++++++--- botfucker/bridge_ledger.py | 21 +++++- botfucker/bridge_rehearsal.py | 122 +++++++++++++++++++++++++++++++++ docs/bridge-rehearsal.md | 103 ++++++++++++++++++++++++++++ tests/test_bridge_rehearsal.py | 100 +++++++++++++++++++++++++++ 7 files changed, 394 insertions(+), 18 deletions(-) create mode 100644 botfucker/bridge_rehearsal.py create mode 100644 docs/bridge-rehearsal.md create mode 100644 tests/test_bridge_rehearsal.py diff --git a/HANDOFF.md b/HANDOFF.md index d4b5e94..bcf5c52 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,10 +4,11 @@ - GitHub: `https://github.com/Jdelg718/BotFucker` - Default branch: `main` -- Latest merged milestone: Phase 13 Reviewed Action Bridge Promotion Plan (`docs: plan reviewed action bridge promotion`, main at 4619bf5) -- Current working branch: `phase-14-durable-bridge-ledger` -- Current PR target: Phase 14 Durable Bridge Ledger Scaffold — code/docs/tests only, no OAuth/live provider mutation -- Current local demo target: demonstrate deterministic local review, optional mocked LLM classifier fallback/validation, approved-action export, dry-run n8n bridge contract, fail-closed YOLO policy checks, real n8n import validation results, reviewed bridge-promotion gate, and durable bridge ledger dedupe scaffold +- Latest merged milestone: Phase 14 Durable Bridge Ledger Scaffold (`feat: add durable bridge ledger scaffold`, main at 8904d67) +- Current working branch: `phase-15-emergency-stop-bridge-rehearsal` +- Current PR target: Phase 15 Emergency-Stop Bridge Rehearsal — code/docs/tests only, dry-run-only, no OAuth/live provider mutation +- Selected next provider/action target: Microsoft Outlook warning draft in sandbox/manual reviewed bridge contract; no send-reply mutation yet +- Current local demo target: demonstrate deterministic local review, optional mocked LLM classifier fallback/validation, approved-action export, dry-run n8n bridge contract, fail-closed YOLO policy checks, real n8n import validation results, the Phase 13 Reviewed Action Bridge Promotion Plan, durable bridge ledger dedupe scaffold, and emergency-stop bridge rehearsal - Current promo artifact: `promo/botfucker-animated-explainer/renders/botfucker-animated-explainer_narrated-final.mp4` ## What BotFucker Is @@ -26,7 +27,7 @@ Important files: ```text DESIGN.md # v2 architecture and principles -ROADMAP.md # phased product roadmap, current through Phase 12 n8n import validation +ROADMAP.md # phased product roadmap, current through Phase 15 emergency-stop bridge rehearsal docs/n8n-import-validation.md # Phase 12 real n8n import/dry-run procedure and results docs/webhook-contract.md # normalized n8n/webhook JSON contract docs/n8n-workflow.json # importable n8n import starter workflow @@ -36,6 +37,7 @@ docs/n8n-approved-action-bridge.md # approved-action bridge operator guide docs/provider-auth-plan.md # provider auth/action boundary plan docs/reviewed-action-bridge-promotion-plan.md # Phase 13 reviewed live-bridge gate; no OAuth/live mutation docs/bridge-ledger-scaffold.md # Phase 14 durable processed-audit ledger scaffold; no OAuth/live mutation +docs/bridge-rehearsal.md # Phase 15 emergency-stop/dry-run bridge rehearsal; no provider execution README.md # user-facing setup and project overview outreach_filter.py # compatibility CLI wrapper botfucker/models.py # normalized email/classification/review models @@ -44,6 +46,7 @@ botfucker/history.py # SQLite sender history + strike state botfucker/review_queue.py # review item/audit models and sample data helpers botfucker/review_store.py # durable SQLite review queue and audit store botfucker/bridge_ledger.py # durable bridge processed-audit ledger scaffold +botfucker/bridge_rehearsal.py # dry-run bridge rehearsal proving emergency stop and duplicate skip botfucker/review_cli.py # durable local review CLI botfucker/webhook_contract.py # n8n/webhook payload sanitizer/import adapter botfucker/local_ui.py # local browser review UI server @@ -67,6 +70,7 @@ These are non-negotiable: - Local UI and review CLI actions affect SQLite review state only. - Provider-side actions are future bridge work, not local UI behavior. - The Phase 14 durable bridge ledger records `audit_id` state only (`bridge_ledger_state_only`) before provider mutation; it is not a provider action engine. +- The Phase 15 bridge rehearsal is dry-run-only; emergency stop exits before ledger claim, and provider execution remains `not_performed`. ## Current Test Commands diff --git a/README.md b/README.md index df23141..ede648b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ The current core is split into reusable modules under `botfucker/`: - `history.py` tracks sender history, warning counts, and strike levels in SQLite. - `review_store.py` persists local review queue items and audit events in SQLite. - `bridge_ledger.py` provides a durable bridge ledger scaffold keyed by approved-action `audit_id` values. +- `bridge_rehearsal.py` proves emergency-stop and dry-run bridge behavior without provider execution. - `webhook_contract.py` normalizes bounded n8n/webhook email JSON into local review items. - `review_cli.py` provides a provider-safe local review workflow around seeded/imported items. - `responses.py` contains human-reviewable warning templates. @@ -39,6 +40,7 @@ See [DESIGN.md](DESIGN.md) for the proposed architecture and roadmap. - Exports approved local audit events as an idempotent JSON bundle for an n8n/provider bridge. - Provides an inactive n8n approved-action bridge starter that validates/dedupes actions in dry-run mode. - Provides a Phase 14 durable bridge ledger scaffold for recording processed `audit_id` state before provider mutation. +- Provides a Phase 15 dry-run bridge rehearsal that proves emergency stop and duplicate-skip behavior before provider mutation. - Keeps provider credentials and live mailbox side effects outside the local UI and review queue. ## Safety First diff --git a/ROADMAP.md b/ROADMAP.md index a8dc5ca..ed21d47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -341,6 +341,29 @@ Acceptance criteria: - Ledger stores IDs/status only, not message body/header/credential material. - No OAuth, no provider credentials, no live provider mutation nodes, and no checked-in n8n activation changes are added. +### Phase 15 — Emergency-Stop Bridge Rehearsal ✅ + +Status: implemented on `phase-15-emergency-stop-bridge-rehearsal`. + +Goal: prove the emergency-stop and dry-run bridge path against the durable ledger before any live provider mutation exists. + +Delivered: + +- `botfucker.bridge_rehearsal.rehearse_approved_actions()` dry-run-only bridge rehearsal +- emergency-stop default that exits before claiming the durable ledger +- dry-run path that claims the ledger and marks `dry_run_logged` without provider execution +- duplicate replay handling that returns `duplicate_skipped` +- docs in `docs/bridge-rehearsal.md` +- tests proving emergency stop, dry-run logging, duplicate skip, live-mode rejection, and unsafe-action rejection + +Acceptance criteria: + +- Emergency stop creates no ledger row and no provider execution. +- Dry-run remains mandatory; `dry_run=False` fails closed. +- Duplicate approved-action exports are skipped by durable `audit_id`. +- Provider execution remains `not_performed`. +- No OAuth, provider credentials, provider API calls, or live mutation nodes are added. + ## Local Kodex/Codex Demo Plan Kent is pulling this locally onto Kodex/Codex next. The demo should show what exists now, not pretend Phase 8 is already done. Revolutionary concept, apparently. @@ -387,27 +410,32 @@ Use fake or sanitized JSON only. Real mailbox payloads stay out of the repo. ## Near-Term Recommendation -After Phase 14 is reviewed and merged, the next PR should be **Phase 15: emergency-stop proof or sandbox-only bridge rehearsal**, not broad OAuth implementation. +After Phase 15 is reviewed and merged, the next PR should be **Phase 16: Microsoft Outlook warning-draft sandbox contract**, not broad OAuth implementation. + +Kent selected the first provider/action target: + +- Provider: Microsoft Outlook +- First action: create/save a warning draft only +- Scope: sandbox/manual reviewed bridge contract first; no send-reply mutation yet Recommended scope: - keep one provider/action pair only (`approve_warning`) -- use the durable bridge ledger before any provider mutation +- document the exact sandbox mailbox/provider target Kent wants to use - keep credentials in n8n only - keep dry-run as the default path -- prove emergency stop exits before provider mutation -- require provider-specific sandbox/manual tests +- map the Phase 15 rehearsal outcomes onto an inactive n8n/operator checklist - require Rex/Gus security/ops review before any live mutation node is connected -OAuth can still wait. Phase 14 gives the bridge a seatbelt: durable dedupe before action. Next is proving the brakes, not flooring it into a live inbox. +OAuth can still wait. Phase 15 proves the brakes in code. Next is picking the sandbox road — not handing the robot live mailbox keys because apparently we enjoy learning by fire. -### Restart checklist after Phase 14 +### Restart checklist after Phase 15 -1. Re-check Phase 14 branch CI and mergeability. -2. Squash-merge Phase 14 into `main` if still green. -3. Pull updated `main` and create a Phase 15 branch. -4. Keep exactly one sandbox provider/action pair, likely `approve_warning` only if Kent explicitly wants reply-send tested. -5. Prove emergency-stop and dry-run behavior against the durable ledger before wiring any provider mutation. +1. Re-check Phase 15 PR CI and mergeability. +2. Squash-merge Phase 15 into `main` if still green. +3. Use Microsoft Outlook as the selected sandbox provider target. +4. Limit the first provider/action pair to warning draft creation/save only; do not send replies. +5. Keep provider credentials inside n8n/operator infrastructure only; do not put secrets in BotFucker. ## Team Utilization diff --git a/botfucker/bridge_ledger.py b/botfucker/bridge_ledger.py index d79816c..3c2d270 100644 --- a/botfucker/bridge_ledger.py +++ b/botfucker/bridge_ledger.py @@ -24,7 +24,8 @@ PROCESSED = "processed" FAILED = "failed" ROLLED_BACK = "rolled_back" -VALID_STATUSES = {PENDING, PROCESSED, FAILED, ROLLED_BACK} +DRY_RUN_LOGGED = "dry_run_logged" +VALID_STATUSES = {PENDING, PROCESSED, FAILED, ROLLED_BACK, DRY_RUN_LOGGED} class BridgeLedgerError(ValueError): @@ -211,6 +212,21 @@ def mark_rolled_back( processed_by_workflow=processed_by_workflow, ) + def mark_dry_run_logged( + self, + audit_id: str, + *, + processed_by_workflow: str | None = None, + ) -> BridgeLedgerRecord: + """Record a dry-run rehearsal without provider execution.""" + + return self._update_status( + audit_id, + DRY_RUN_LOGGED, + provider_result_id="", + processed_by_workflow=processed_by_workflow, + ) + def _update_status( self, audit_id: str, @@ -302,9 +318,10 @@ def _validate_status_transition(current: str, target: str) -> None: if current == target: return allowed = { - PENDING: {PROCESSED, FAILED, ROLLED_BACK}, + PENDING: {PROCESSED, FAILED, ROLLED_BACK, DRY_RUN_LOGGED}, FAILED: {ROLLED_BACK}, PROCESSED: {ROLLED_BACK}, + DRY_RUN_LOGGED: {ROLLED_BACK}, ROLLED_BACK: set(), } if target not in allowed[current]: diff --git a/botfucker/bridge_rehearsal.py b/botfucker/bridge_rehearsal.py new file mode 100644 index 0000000..1e1bdaf --- /dev/null +++ b/botfucker/bridge_rehearsal.py @@ -0,0 +1,122 @@ +"""Dry-run bridge rehearsal with emergency-stop proof. + +This module is deliberately provider-free. It does not call Gmail, Microsoft, +IMAP, SMTP, n8n, HTTP APIs, or any provider mutation surface. It proves the +operator-side gates that must happen before a future reviewed live bridge: + +1. emergency stop is checked first; +2. dry-run remains mandatory; +3. the durable bridge ledger is claimed before any would-execute result; +4. duplicate audit IDs are skipped rather than replayed. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from botfucker.bridge_ledger import ( + BridgeLedgerError, + DurableBridgeLedger, + REQUIRED_PROVIDER_EXECUTION, +) + +BRIDGE_REHEARSAL_WORKFLOW = "botfucker-bridge-rehearsal-v1" + + +@dataclass(frozen=True) +class BridgeRehearsalOutcome: + """One action's dry-run bridge rehearsal result.""" + + audit_id: str + approved_action: str + provider: str + status: str + dry_run: bool + ledger_acquired: bool + provider_execution: str = REQUIRED_PROVIDER_EXECUTION + would_execute: bool = False + reason: str = "" + + +def rehearse_approved_actions( + bundle: dict[str, Any], + ledger: DurableBridgeLedger, + *, + emergency_stop: bool = True, + dry_run: bool = True, + processed_by_workflow: str = BRIDGE_REHEARSAL_WORKFLOW, +) -> list[BridgeRehearsalOutcome]: + """Rehearse approved actions through the safety gates without side effects. + + Phase 15 intentionally supports dry-run only. Passing ``dry_run=False`` is an + error, not a secret live mode. When ``emergency_stop`` is enabled, actions + are blocked before the ledger is claimed so operators can prove the stop + switch exits before any provider mutation slot is acquired. + """ + + if not dry_run: + raise BridgeLedgerError("Bridge rehearsal is dry-run only; live provider execution is out of scope") + + actions = bundle.get("actions") + if not isinstance(actions, list): + raise BridgeLedgerError("Approved-actions bundle must include an actions list") + + outcomes: list[BridgeRehearsalOutcome] = [] + for action in actions: + if not isinstance(action, dict): + raise BridgeLedgerError("Approved action entries must be objects") + + if emergency_stop: + outcomes.append( + BridgeRehearsalOutcome( + audit_id=str(action.get("audit_id") or ""), + approved_action=str(action.get("approved_action") or ""), + provider=str(action.get("provider") or ""), + status="blocked_by_emergency_stop", + dry_run=True, + ledger_acquired=False, + would_execute=False, + reason="Emergency stop enabled before ledger claim or provider mutation", + ) + ) + continue + + claim = ledger.claim_action( + bundle, + action, + processed_by_workflow=processed_by_workflow, + dry_run=True, + ) + if claim.acquired: + record = ledger.mark_dry_run_logged( + claim.record.audit_id, + processed_by_workflow=processed_by_workflow, + ) + outcomes.append( + BridgeRehearsalOutcome( + audit_id=record.audit_id, + approved_action=record.approved_action, + provider=record.provider, + status=record.status, + dry_run=True, + ledger_acquired=True, + would_execute=True, + reason="Dry-run rehearsal logged; provider execution not performed", + ) + ) + else: + outcomes.append( + BridgeRehearsalOutcome( + audit_id=claim.record.audit_id, + approved_action=claim.record.approved_action, + provider=claim.record.provider, + status="duplicate_skipped", + dry_run=True, + ledger_acquired=False, + would_execute=False, + reason="Durable ledger already contains this audit_id", + ) + ) + + return outcomes diff --git a/docs/bridge-rehearsal.md b/docs/bridge-rehearsal.md new file mode 100644 index 0000000..4531b18 --- /dev/null +++ b/docs/bridge-rehearsal.md @@ -0,0 +1,103 @@ +# Phase 15 — Emergency-Stop Bridge Rehearsal + +Phase 15 proves the brakes before anyone touches a live mailbox. + +This is a dry-run-only bridge rehearsal that uses the durable Phase 14 ledger and verifies the emergency stop exits before any provider mutation slot is claimed. + +## What exists + +- `botfucker.bridge_rehearsal.rehearse_approved_actions()` +- `botfucker.bridge_ledger.DurableBridgeLedger.mark_dry_run_logged()` +- `tests/test_bridge_rehearsal.py` + +## Safety boundary + +This phase performs: + +- no OAuth +- no provider credentials +- no Gmail/Microsoft/IMAP/SMTP calls +- no n8n calls +- no HTTP calls +- no live provider mutation nodes +- no message send/move/delete/archive/label behavior + +Provider execution remains exactly: + +```text +not_performed +``` + +## Rehearsal lifecycle + +### Emergency stop on + +Default behavior is stopped. + +```python +outcomes = rehearse_approved_actions(bundle, ledger, emergency_stop=True) +``` + +Expected result: + +- status: `blocked_by_emergency_stop` +- `ledger_acquired: false` +- `would_execute: false` +- no ledger row is created +- provider execution is `not_performed` + +This proves the stop switch exits before claiming a provider mutation slot. + +### Emergency stop off, dry-run on + +```python +outcomes = rehearse_approved_actions(bundle, ledger, emergency_stop=False) +``` + +Expected result for the first valid action: + +- validates the approved-action bundle +- claims the durable `audit_id` +- marks the row `dry_run_logged` +- returns `would_execute: true` +- provider execution remains `not_performed` + +### Duplicate replay + +Running the same bundle again returns: + +- status: `duplicate_skipped` +- `ledger_acquired: false` +- `would_execute: false` + +The durable ledger prevents replay by `audit_id`. + +## Dry-run only + +`dry_run=False` raises `BridgeLedgerError`. There is no hidden live mode in Phase 15. + +## Accepted input + +The rehearsal inherits Phase 14 ledger validation: + +- bundle schema must be `botfucker.approved_actions.v1` +- bundle/action safety scope must be `provider_action_export_only` +- bundle/action provider execution must be `not_performed` +- action must be present in the bundle's `actions` list +- `audit_id`, provider, message ID, and thread ID must be present +- approved action is limited to `approve_warning` + +Anything else fails closed. + +## Future live bridge requirement + +Before live provider mutation exists, Rex/Gus must review a separate provider-specific bridge artifact with: + +- n8n-owned credentials only +- emergency stop tested against that exact workflow +- durable ledger claim before provider mutation +- provider sandbox/manual evidence +- rollback/remediation procedure +- structured logs with no credentials or raw message bodies + +Until then, this is the brake test. No fireworks, no live inbox surgery. diff --git a/tests/test_bridge_rehearsal.py b/tests/test_bridge_rehearsal.py new file mode 100644 index 0000000..a01d7fb --- /dev/null +++ b/tests/test_bridge_rehearsal.py @@ -0,0 +1,100 @@ +import tempfile +import unittest +from pathlib import Path + +from botfucker.bridge_ledger import BridgeLedgerError, DRY_RUN_LOGGED, DurableBridgeLedger +from botfucker.bridge_rehearsal import rehearse_approved_actions + + +def sample_action(**overrides): + action = { + "audit_id": "audit-0001", + "action_id": "bf-action-audit-0001", + "item_id": "webhook:gmail:gmail-msg-123", + "message_id": "gmail-msg-123", + "thread_id": "gmail-thread-7", + "provider": "gmail", + "approved_action": "approve_warning", + "approved_by": "human", + "approved_at": "2026-05-18T12:00:00Z", + "draft_reply": "Human-reviewed warning text", + "safety_scope": "provider_action_export_only", + "provider_execution": "not_performed", + } + action.update(overrides) + return action + + +def sample_bundle(action=None): + action = action or sample_action() + return { + "schema": "botfucker.approved_actions.v1", + "safety_scope": "provider_action_export_only", + "provider_execution": "not_performed", + "cursor": {"since_audit_id": None, "last_audit_id": action["audit_id"]}, + "actions": [action], + } + + +class BridgeRehearsalTests(unittest.TestCase): + def test_emergency_stop_blocks_before_ledger_claim(self): + with tempfile.TemporaryDirectory() as tmpdir: + with DurableBridgeLedger(Path(tmpdir) / "ledger.sqlite3") as ledger: + action = sample_action() + outcomes = rehearse_approved_actions(sample_bundle(action), ledger, emergency_stop=True) + + self.assertEqual(1, len(outcomes)) + self.assertEqual("blocked_by_emergency_stop", outcomes[0].status) + self.assertFalse(outcomes[0].ledger_acquired) + self.assertFalse(outcomes[0].would_execute) + self.assertEqual("not_performed", outcomes[0].provider_execution) + self.assertEqual([], ledger.list_records()) + + def test_dry_run_rehearsal_claims_ledger_and_logs_without_provider_execution(self): + with tempfile.TemporaryDirectory() as tmpdir: + with DurableBridgeLedger(Path(tmpdir) / "ledger.sqlite3") as ledger: + action = sample_action() + outcomes = rehearse_approved_actions(sample_bundle(action), ledger, emergency_stop=False) + + self.assertEqual(1, len(outcomes)) + self.assertEqual(DRY_RUN_LOGGED, outcomes[0].status) + self.assertTrue(outcomes[0].ledger_acquired) + self.assertTrue(outcomes[0].would_execute) + self.assertEqual("not_performed", outcomes[0].provider_execution) + + record = ledger.get("audit-0001") + self.assertEqual(DRY_RUN_LOGGED, record.status) + self.assertTrue(record.dry_run) + self.assertEqual("", record.provider_result_id) + + def test_duplicate_rehearsal_is_skipped_by_durable_ledger(self): + with tempfile.TemporaryDirectory() as tmpdir: + with DurableBridgeLedger(Path(tmpdir) / "ledger.sqlite3") as ledger: + action = sample_action() + bundle = sample_bundle(action) + + first = rehearse_approved_actions(bundle, ledger, emergency_stop=False) + second = rehearse_approved_actions(bundle, ledger, emergency_stop=False) + + self.assertEqual(DRY_RUN_LOGGED, first[0].status) + self.assertEqual("duplicate_skipped", second[0].status) + self.assertFalse(second[0].ledger_acquired) + self.assertFalse(second[0].would_execute) + self.assertEqual(1, len(ledger.list_records())) + + def test_live_mode_is_not_a_hidden_option(self): + with tempfile.TemporaryDirectory() as tmpdir: + with DurableBridgeLedger(Path(tmpdir) / "ledger.sqlite3") as ledger: + with self.assertRaises(BridgeLedgerError): + rehearse_approved_actions(sample_bundle(), ledger, dry_run=False, emergency_stop=False) + + def test_unsafe_actions_still_fail_closed_when_stop_is_off(self): + with tempfile.TemporaryDirectory() as tmpdir: + with DurableBridgeLedger(Path(tmpdir) / "ledger.sqlite3") as ledger: + unsafe_action = sample_action(approved_action="delete_message") + with self.assertRaises(BridgeLedgerError): + rehearse_approved_actions(sample_bundle(unsafe_action), ledger, emergency_stop=False) + + +if __name__ == "__main__": + unittest.main()