---
phase: 1.5
plan: 3
subsystem: governance-runtime-bugfix
tags: [hook, audit, fail-closed, observability, orphan-log, BUG-04]
dependency_graph:
  requires: [01.5-02]
  provides: [BUG-04]
  affects: [hooks/dashclaw_pretool.py, .env.example, CHANGELOG.md, hooks/tests/, scripts/]
metrics:
  duration: ~1hr (orchestrator inline, one BUG-04 diagnosis session + one execution resume)
  completed: 2026-04-22
  tasks: 4 (T1-T4 all done)
  files: 5 (1 modified hook + installed copy, 1 new test, 2 docs edits, 1 new validation runner)
---

# Phase 1.5 Plan 3: Hook Fail-Open Bugfix — Summary

## Context

Plan 01.5-03 was the third and final plan in Phase 1.5. It was authored during a 2026-04-22 Phase 2 context-gathering session after a diagnosis revealed that Wes's Claude Code hook had been silently routed to a local Docker demo container for ~30 minutes due to a stale `DASHCLAW_BASE_URL=http://localhost:3000` env var. That specific misroute produced a noisy `"Demo Production Guard"` block (demo fixtures), but the exact same stale env var pointing at an unreachable URL would have caused every governed tool call to exit 0 silently — *unaudited* — for the full session.

Structurally identical to BUG-02: a governance decision made without an audit trail. BUG-02 fixed the block path; BUG-04 fixes the outage path.

## Requirements Closed

- **BUG-04**: `hooks/dashclaw_pretool.py:557-560` (pre-fix line numbers) silently exited 0 on `/api/guard` unreachable. Now routes through `handle_guard_unavailable(context, tool_use_id)` which:
  - Writes an orphan JSONL record to `~/.dashclaw/orphan-actions.jsonl` with the full enriched context — unconditional, regardless of policy or mode.
  - Defaults to **fail-closed** (exit 2) in enforce mode. New env var `DASHCLAW_GUARD_UNAVAILABLE_POLICY=block|warn|allow` lets advanced users opt out explicitly.
  - Proceeds loudly in observe mode (exit 0 with `[observe]` stderr banner + orphan record).

## Commits Produced

| SHA | Description | Task |
|-----|-------------|------|
| `36b62765` | `plan(01.5-03): author plan for BUG-04 (hook fail-open on guard unavailable)` | plan authoring |
| `853952ef` | `fix(hook): BUG-04 — handle guard unavailable with orphan log + fail-closed default (01.5-03)` | T1 + T2 + T3 |
| *(this commit)* | `docs(01.5-03): close plan with T4 validation evidence + summary` | T4 + closure |

## What Changed

### `hooks/dashclaw_pretool.py` (modified, also synced to `.claude/hooks/dashclaw_pretool.py`)

- New top-level constant `GUARD_UNAVAILABLE_POLICY` reads `DASHCLAW_GUARD_UNAVAILABLE_POLICY` (default `block`).
- New handler `handle_guard_unavailable(context, tool_use_id)` replaces the silent `sys.exit(0)` on unreachable guard. Writes orphan log unconditionally, then dispatches on `HOOK_MODE` and `GUARD_UNAVAILABLE_POLICY`.
- `main()` call site now delegates to the handler instead of printing `"[DashClaw] Guard unavailable, proceeding"` and exiting 0.

Canonical and installed hook copies byte-for-byte match (`.claude/hooks/` is gitignored; synced via `cp`).

### `hooks/tests/test_pretool_guard_unavailable.py` (new, ~200 lines, 5 tests)

Regression coverage for all five BUG-04 scenarios:

1. Enforce + block (default) → exit 2, orphan record written.
2. Enforce + warn → exit 0 with ⚠ stderr, orphan record written.
3. Enforce + allow → exit 0, orphan record written.
4. Observe mode → exit 0 with `[observe]` banner, orphan record written.
5. Orphan-log write failure (simulated via `~/.dashclaw` planted as a file) → policy still honored, error appended to `dashclaw_hook_errors.log`.

All 5 pass in 3.207s with no real network (`DASHCLAW_BASE_URL=http://127.0.0.1:1`). Each test scopes `HOME`/`USERPROFILE` and `TEMP` to fresh `tempfile.TemporaryDirectory()` instances — no pollution of real `~/.dashclaw/`.

### `.env.example` (modified)

`DASHCLAW_GUARD_UNAVAILABLE_POLICY=block` added to the Claude Code Hook section with a block-comment explaining the three policy values and the orphan-log backfill behavior.

### `CHANGELOG.md` (modified)

New `## [Unreleased]` section at the top with a `### Fixed` entry for BUG-04. No platform version bump in this plan — that happens separately at release time.

### `scripts/bug04-validate.py` (new, T4 validation runner)

Subprocess replay of the four guard-unreachable scenarios. Invoked during T4 validation (see `01.5-BUG04-VALIDATION.md`) and re-runnable at any time to confirm the hook's outage behavior without needing a Claude Code restart.

## T4 Resolution (Deviation from Plan)

The plan's T4 originally asked Wes to export `DASHCLAW_BASE_URL=http://127.0.0.1:1`, restart Claude Code, trigger a Bash command, capture the block, then repeat three more times for `warn`, `allow`, and observe mode — four full Claude Code restarts for four scenarios.

Wes pushed back on the restart-churn workflow. I replaced the manual dance with `scripts/bug04-validate.py`, which spawns `dashclaw_pretool.py` as a subprocess with a Claude-Code-shaped stdin payload and captures exit code + stderr + orphan record per scenario. This exercises the exact same code path the plan asked for, since the hook is a pure stdin-in/exit-code-out process that doesn't know who invoked it. Outcome is identical to the plan's T4 intent; the only thing skipped is the "confirm real Vercel instance works" recovery step, which is covered by Wes's normal daily use going forward.

Full evidence: `.planning/phases/01.5-governance-bugfix/01.5-BUG04-VALIDATION.md`.

## Guardrail Evidence

- `python -c "import ast; ast.parse(open('hooks/dashclaw_pretool.py').read())"` → OK (canonical)
- `python -c "import ast; ast.parse(open('.claude/hooks/dashclaw_pretool.py').read())"` → OK (installed)
- `diff hooks/dashclaw_pretool.py .claude/hooks/dashclaw_pretool.py` → empty (in sync)
- `python -m unittest hooks.tests.test_pretool_guard_unavailable -v` → 5/5 in 3.207s
- `grep -q 'log("\[DashClaw\] Guard unavailable, proceeding")' hooks/dashclaw_pretool.py` → not present (silent fail-open removed)
- `grep -q "DASHCLAW_GUARD_UNAVAILABLE_POLICY" .env.example` → present
- `grep -q "BUG-04" CHANGELOG.md` → present
- `scripts/bug04-validate.py` → 4/4 scenarios pass with expected exit codes and orphan records

## Deferred — Noticed Out of Scope

1. **Backfill-on-recovery** — when the hook successfully reaches `/api/guard` on a later invocation, it could drain `~/.dashclaw/orphan-actions.jsonl` by POSTing each record to a new `/api/actions/backfill` endpoint. Out of scope for this plan (explicitly noted in 01.5-03-PLAN.md §notes). Future Phase 2+ work.
2. **`dashclaw_posttool.py` / `dashclaw_stop.py` same-class audit** — these hooks may have similar silent-failure patterns. Backlog review item, not this plan.
3. **`todo-003-guard-unavailable-fail-open.md`** — this plan formalizes the fix. The todo can now move to `.planning/todos/resolved/` or be deleted.
4. **An in-UI surface for orphan records** — the `/decisions` page doesn't currently show orphan entries as pending governance reconciliation. Worth a separate plan if/when backfill ships.

## Known Stubs

None. Every piece of code shipped is real logic with real tests.

## Self-Check: PASSED

- All commits on main (local — pushing as part of this closure)
- All 4 tasks complete (T1-T3 in `853952ef`, T4 in this commit)
- 5/5 unit tests pass, 4/4 validation scenarios pass
- All guardrails green
- Canonical hook + installed copy match
- Silent fail-open removed, fail-closed default locked in

---

*Plan 01.5-03 complete: 2026-04-22*
*Execution mode: orchestrator inline (resumed after env-var stall; T4 automated via subprocess replay per Wes's feedback)*
*Closes Phase 1.5 (Governance Runtime Bugfix). Phase 2 (Claude Code Beachhead) is unblocked.*
