# v0.3.1 Emergency Hardening Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans for inline TDD execution. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Close 4 outage-class gaps confirmed by GPT Pro 2026-04-09 — concurrent broadcast send race, email-cli subprocess timeout, schema version safety check, agent-info / AGENTS.md staleness — and ship as `v0.3.1` to GitHub + crates.io + Homebrew tap.

**Architecture:** Five independent tasks against the v0.3.0 codebase. Zero new runtime crates. Migration 0004 = `broadcast_locks` (locks the v0.4 plan into renumbering its content-snapshot migration to 0005). Each task ships with TDD discipline and lands as its own commit on `main` (trunk-based). After all 4 implementation tasks land, the release task bumps version, runs the mandatory paperfoot.com smoke test, tags `v0.3.1`, pushes, publishes to crates.io (first publish ever for this crate), and adds the formula to `199-biotechnologies/homebrew-tap`.

**Tech Stack:** Rust 2024, rusqlite 0.37 (bundled SQLite), clap 4.5 (derive), thiserror, serde_json. Existing AppError variants: `Transient` (exit 1), `Config` (exit 2), `BadInput` (exit 3), `RateLimited` (exit 4).

**Pre-release:** v0.3.0 stable (commit `f30fd42`), main branch, 177 tests passing, cargo fmt + clippy clean, paperfoot.com smoke test green.

**Spec:** `docs/specs/2026-04-09-v0.3.1-emergency-hardening.md` (committed at `3cb5512`).

---

## File structure

| Task | Modify | Create | Test touches |
|---|---|---|---|
| 1. Schema version check | `src/db/mod.rs:43-89` (run_migrations) | — | `tests/db_schema_check_test.rs` (new) |
| 2. email-cli timeout | `src/email_cli.rs` (6 call sites + new helper) | — | `tests/email_cli_timeout_test.rs` (new) |
| 3. Broadcast lock CAS | `src/db/mod.rs`, `src/db/migrations.rs`, `src/broadcast/pipeline.rs`, `src/cli.rs`, `src/commands/broadcast.rs` | — | `tests/broadcast_lock_test.rs` (new) |
| 4. agent-info + AGENTS.md sync | `src/commands/agent_info.rs`, `AGENTS.md` | — | `tests/agent_info_drift_test.rs` (new) |
| 5. Release | `Cargo.toml`, `README.md`, `src/commands/agent_info.rs` (status) | `docs/handoffs/2026-04-09-session-6-handoff.md`, `Formula/mailing-list-cli.rb` (in homebrew-tap repo) | smoke test (manual) |

**DO NOT do** any of the following — out of scope for v0.3.1 (defer to v0.4 or later):
- Surface lock state in `broadcast show` / `broadcast list` output
- Auto-generate `agent-info` from clap introspection (manual sync only)
- Add `db check` / `db repair` subcommands
- Add audit log table
- Refactor `src/db/mod.rs` (still 2,374 lines after this patch)
- Per-call timeout overrides (env-var-only for now)
- Concurrent-send guard for `webhook poll` or `broadcast preview`
- Any new runtime dep

---

## Task 1: Schema version safety check

**Why:** A v0.3.0 binary opening a `state.db` last touched by a v0.4.0 binary silently misbehaves — it tries SQL queries against tables with new columns it doesn't know about. Fail fast at startup with exit code 2 instead.

**Strategy:** After the existing migration loop in `Db::run_migrations`, run one extra query: `SELECT MAX(version) FROM schema_version` and compare against the last entry in our compile-time `MIGRATIONS` array. Migration version strings are sortable text (`NNNN_name` convention).

**Files:**
- Modify: `src/db/mod.rs:43-89` (the `run_migrations` function)
- Create: `tests/db_schema_check_test.rs`

- [ ] **Step 1.1: Write the failing test**

Create `tests/db_schema_check_test.rs`:

```rust
//! Tests that Db::open fails fast when the schema version on disk is
//! newer than what this binary knows about.

use mailing_list_cli::db::Db;
use mailing_list_cli::error::ExitCode;
use rusqlite::{Connection, params};
use tempfile::TempDir;

#[test]
fn db_open_succeeds_on_known_schema() {
    let tmp = TempDir::new().unwrap();
    let path = tmp.path().join("state.db");
    // First open: creates schema, applies all known migrations.
    {
        let _db = Db::open_at(&path).unwrap();
    }
    // Second open: schema_version table now has all known migrations,
    // and MAX(version) equals the last known. Should succeed.
    let _db = Db::open_at(&path).unwrap();
}

#[test]
fn db_open_fails_fast_on_unknown_future_migration() {
    let tmp = TempDir::new().unwrap();
    let path = tmp.path().join("state.db");
    // Bootstrap: open once to create the schema_version table.
    {
        let _db = Db::open_at(&path).unwrap();
    }
    // Inject a future migration version that this binary doesn't know about.
    {
        let conn = Connection::open(&path).unwrap();
        conn.execute(
            "INSERT INTO schema_version (version, applied_at) VALUES (?1, ?2)",
            params!["9999_imaginary_future", "2026-04-09T00:00:00Z"],
        )
        .unwrap();
    }
    // Re-open should now fail with Config error (exit 2).
    let result = Db::open_at(&path);
    let err = result.unwrap_err();
    assert_eq!(err.exit_code(), ExitCode::Config);
    assert_eq!(err.code(), "db_schema_too_new");
    assert!(
        err.message().contains("9999_imaginary_future"),
        "error message should mention the unknown version, got: {}",
        err.message()
    );
}
```

- [ ] **Step 1.2: Run test to verify it fails**

Run: `cargo test --test db_schema_check_test -- --test-threads=1 2>&1 | tail -20`
Expected: `db_open_succeeds_on_known_schema` PASSES (existing behavior); `db_open_fails_fast_on_unknown_future_migration` FAILS because `Db::open_at` doesn't yet check for future versions.

- [ ] **Step 1.3: Implement the schema version check**

Open `src/db/mod.rs` and modify `run_migrations` (currently lines 43-89). After the existing `for (version, sql) in migrations::MIGRATIONS` loop, BEFORE `Ok(())`, add:

```rust
        // Schema-too-new safety check: a binary downgrade (or running an old
        // binary against a DB last touched by a newer binary) used to silently
        // run queries against tables whose shape it didn't expect, producing
        // confusing column-mismatch errors. Fail fast at open time instead.
        //
        // Migration version strings follow the convention `NNNN_name` (4-digit
        // numeric prefix), so lexicographic ordering matches numerical ordering.
        let last_known: &str = migrations::MIGRATIONS
            .last()
            .map(|(v, _)| *v)
            .unwrap_or("");
        let max_in_db: Option<String> = self
            .conn
            .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
            .ok()
            .flatten();
        if let Some(max) = max_in_db {
            if max.as_str() > last_known {
                return Err(AppError::Config {
                    code: "db_schema_too_new".into(),
                    message: format!(
                        "database schema version `{max}` is newer than what this binary supports (max known: `{last_known}`)"
                    ),
                    suggestion: format!(
                        "Upgrade mailing-list-cli to a version that knows about migration `{max}`. Running this binary against a newer DB risks data corruption."
                    ),
                });
            }
        }
        Ok(())
```

Note: the existing function ends with `Ok(())` after the for-loop. Replace that line with the block above (which itself ends with `Ok(())`).

- [ ] **Step 1.4: Run all DB-related tests to verify no regression**

Run: `cargo test --test db_schema_check_test -- --test-threads=1 2>&1 | tail -10 && cargo test db -- --test-threads=1 2>&1 | tail -10`
Expected: both new tests PASS; existing DB tests still pass.

- [ ] **Step 1.5: Commit**

```bash
git add src/db/mod.rs tests/db_schema_check_test.rs
git commit -m "feat(db): fail fast on unknown future schema versions

A v0.3.0 binary opening a state.db last touched by a v0.4.0 binary
used to silently run SELECT queries against tables with columns it
didn't know about, producing confusing rusqlite errors. Now Db::open
returns AppError::Config { code: 'db_schema_too_new' } (exit code 2)
when MAX(schema_version) is newer than the last known migration.

Closes gap #3 from GPT Pro 2026-04-09 hardening review."
```

---

## Task 2: email-cli subprocess timeout

**Why:** Every `Command::new(&self.path).args(...).output()` call site in `src/email_cli.rs` is blocking with no timeout. A hung `email-cli` subprocess freezes `mailing-list-cli` indefinitely. This is the #2 outage-class gap.

**Strategy:** Add a private `run_with_timeout` helper using `Child::try_wait()` in a poll loop. Default 120s timeout, override via `MLC_EMAIL_CLI_TIMEOUT_SEC` env var. Replace all 6 `Command::output()` call sites with the helper. The existing retry-on-stderr-contains-"timeout" classifier already handles the resulting `email_cli_timeout` error code, so the retry path is unchanged.

**Files:**
- Modify: `src/email_cli.rs` (add helper, replace 6 call sites)
- Create: `tests/email_cli_timeout_test.rs`

- [ ] **Step 2.1: Locate the existing test stub for email-cli**

Run: `ls tests/support/ && head -30 tests/support/stub_email_cli.sh 2>&1`
Expected: `stub_email_cli.sh` exists from v0.3.0 work.

- [ ] **Step 2.2: Write a slow stub variant for the timeout test**

Append to `tests/support/stub_email_cli.sh`:

```bash
# Slow mode for timeout tests: STUB_EMAIL_CLI_SLEEP_SEC=N sleeps before responding.
if [[ -n "${STUB_EMAIL_CLI_SLEEP_SEC:-}" ]]; then
  sleep "$STUB_EMAIL_CLI_SLEEP_SEC"
fi
```

Insert this BEFORE the `case "$*" in` block at the top of the script. Run: `cat tests/support/stub_email_cli.sh | head -20` to verify ordering.

- [ ] **Step 2.3: Write the failing test**

Create `tests/email_cli_timeout_test.rs`:

```rust
//! Integration tests for the run_with_timeout helper in email_cli.rs.
//! Verifies that hung subprocesses are killed and reported as
//! email_cli_timeout transient errors instead of hanging forever.

use mailing_list_cli::email_cli::EmailCli;
use std::time::Instant;

fn stub_path() -> String {
    let manifest = env!("CARGO_MANIFEST_DIR");
    format!("{manifest}/tests/support/stub_email_cli.sh")
}

#[test]
fn subprocess_completes_under_timeout() {
    // Default timeout (120s) should let a fast stub call return normally.
    unsafe {
        std::env::remove_var("MLC_EMAIL_CLI_TIMEOUT_SEC");
        std::env::remove_var("STUB_EMAIL_CLI_SLEEP_SEC");
    }
    let cli = EmailCli::new(stub_path(), "test");
    let result = cli.agent_info();
    assert!(
        result.is_ok(),
        "fast stub should complete under default timeout, got: {:?}",
        result
    );
}

#[test]
fn subprocess_killed_on_timeout() {
    // Stub sleeps 5 seconds; we set timeout to 1 second; should kill at ~1s.
    unsafe {
        std::env::set_var("MLC_EMAIL_CLI_TIMEOUT_SEC", "1");
        std::env::set_var("STUB_EMAIL_CLI_SLEEP_SEC", "5");
    }
    let cli = EmailCli::new(stub_path(), "test");
    let start = Instant::now();
    let result = cli.agent_info();
    let elapsed = start.elapsed();
    unsafe {
        std::env::remove_var("MLC_EMAIL_CLI_TIMEOUT_SEC");
        std::env::remove_var("STUB_EMAIL_CLI_SLEEP_SEC");
    }
    assert!(
        result.is_err(),
        "slow stub should be killed by timeout, got: {:?}",
        result
    );
    let err = result.unwrap_err();
    assert_eq!(err.code(), "email_cli_timeout");
    assert!(
        elapsed.as_secs() < 4,
        "should have been killed at ~1s, took {:?}",
        elapsed
    );
}

#[test]
fn timeout_env_var_parses_correctly() {
    // Sanity check: setting a numeric env var doesn't crash.
    unsafe {
        std::env::set_var("MLC_EMAIL_CLI_TIMEOUT_SEC", "300");
        std::env::remove_var("STUB_EMAIL_CLI_SLEEP_SEC");
    }
    let cli = EmailCli::new(stub_path(), "test");
    let result = cli.agent_info();
    unsafe {
        std::env::remove_var("MLC_EMAIL_CLI_TIMEOUT_SEC");
    }
    assert!(result.is_ok(), "300s timeout should be parseable, got: {:?}", result);
}
```

- [ ] **Step 2.4: Run test to verify it fails**

Run: `cargo test --test email_cli_timeout_test -- --test-threads=1 2>&1 | tail -15`
Expected: `subprocess_killed_on_timeout` FAILS with the test taking 5+ seconds (current `.output()` blocks for the full sleep). The other two pass trivially.

- [ ] **Step 2.5: Add the run_with_timeout helper to email_cli.rs**

Open `src/email_cli.rs`. Below the existing `MIN_INTERVAL` / `BACKOFF_MS` constants (around line 12-20), add:

```rust
/// Default timeout for any single email-cli subprocess invocation. 120 seconds
/// is generous: a 100-recipient batch send typically takes 2-5 seconds.
/// Override via MLC_EMAIL_CLI_TIMEOUT_SEC env var.
const DEFAULT_EMAIL_CLI_TIMEOUT_SEC: u64 = 120;
const ENV_TIMEOUT: &str = "MLC_EMAIL_CLI_TIMEOUT_SEC";
const POLL_INTERVAL_MS: u64 = 50;

/// Spawn a Command and wait for it to complete with a timeout. On timeout,
/// kills the child process and returns AppError::Transient with
/// code = "email_cli_timeout". The existing retry path classifies stderr
/// containing "timeout" as retryable, so timed-out chunks naturally feed
/// back into the exponential backoff retry loop.
///
/// Implementation note: uses Child::try_wait() in a 50ms poll loop instead
/// of threads or the wait-timeout crate. Single-threaded, dep-free, child
/// handle stays in the main thread for cheap kill on timeout.
fn run_with_timeout(mut cmd: Command) -> Result<Output, AppError> {
    let timeout_sec = std::env::var(ENV_TIMEOUT)
        .ok()
        .and_then(|v| v.parse::<u64>().ok())
        .unwrap_or(DEFAULT_EMAIL_CLI_TIMEOUT_SEC);
    let timeout = StdDuration::from_secs(timeout_sec);
    let deadline = Instant::now() + timeout;

    let mut child = cmd
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| AppError::Config {
            code: "email_cli_spawn_failed".into(),
            message: format!("could not spawn email-cli: {e}"),
            suggestion: "Check that email-cli is on PATH (v0.6+ required)".into(),
        })?;

    loop {
        match child.try_wait() {
            Ok(Some(_status)) => {
                return child.wait_with_output().map_err(|e| AppError::Transient {
                    code: "email_cli_io".into(),
                    message: format!("could not read email-cli output: {e}"),
                    suggestion: "Retry the command".into(),
                });
            }
            Ok(None) => {
                if Instant::now() >= deadline {
                    let _ = child.kill();
                    let _ = child.wait();
                    return Err(AppError::Transient {
                        code: "email_cli_timeout".into(),
                        message: format!(
                            "email-cli subprocess timed out after {}s",
                            timeout_sec
                        ),
                        suggestion: format!(
                            "Increase MLC_EMAIL_CLI_TIMEOUT_SEC (currently {}s) or check email-cli health",
                            timeout_sec
                        ),
                    });
                }
                std::thread::sleep(StdDuration::from_millis(POLL_INTERVAL_MS));
            }
            Err(e) => {
                let _ = child.kill();
                return Err(AppError::Transient {
                    code: "email_cli_wait_failed".into(),
                    message: format!("could not poll email-cli subprocess: {e}"),
                    suggestion: "Retry the command".into(),
                });
            }
        }
    }
}
```

Verify imports: `Output` must be imported from `std::process`. Check for `use std::process::{Command, Stdio};` near the top — extend to `use std::process::{Command, Output, Stdio};`.

- [ ] **Step 2.6: Replace all 6 call sites with run_with_timeout**

Find every occurrence of `.output()` chained on a `Command::new(&self.path)` chain. There are 6 (lines approx 78, 117, 210, 254, 307, 367). For each, transform:

**Before:**
```rust
let output = Command::new(&self.path)
    .args([...])
    .stdout(Stdio::piped())
    .stderr(Stdio::piped())
    .output()
    .map_err(|e| AppError::Config { ... })?;
```

**After:**
```rust
let mut cmd = Command::new(&self.path);
cmd.args([...]);
let output = run_with_timeout(cmd)?;
```

Note: `run_with_timeout` does `.stdout(Stdio::piped()).stderr(Stdio::piped())` itself, so REMOVE those from the call sites. The original `.map_err` blocks are replaced by the helper's internal error mapping (it returns `AppError::Config { code: "email_cli_spawn_failed", ... }` which is equivalent).

Use grep to find them: `grep -n "\.output()" src/email_cli.rs` and walk through each.

For the `batch_send` retry loop (line ~367), the inner Command construction is inside the `loop { }` body — the same transformation applies but the loop structure is unchanged.

- [ ] **Step 2.7: Run timeout tests + retry tests + full email_cli tests**

Run: `cargo test --test email_cli_timeout_test --test email_cli_retry -- --test-threads=1 2>&1 | tail -20`
Expected: all pass. The retry test should still work because `run_with_timeout` returns the same `Output` shape on success and the retry classifier in `batch_send` is unchanged.

- [ ] **Step 2.8: Run full test suite to catch regressions**

Run: `cargo test -- --test-threads=1 2>&1 | grep "test result"`
Expected: 177 + 3 = 180 passing.

- [ ] **Step 2.9: Commit**

```bash
git add src/email_cli.rs tests/email_cli_timeout_test.rs tests/support/stub_email_cli.sh
git commit -m "feat(email_cli): kill subprocess on MLC_EMAIL_CLI_TIMEOUT_SEC

Every Command::output() call site in email_cli.rs was blocking with
no timeout. A hung email-cli subprocess (network freeze, Resend API
stall, upstream loop) used to freeze mailing-list-cli indefinitely.

Adds run_with_timeout helper using Child::try_wait() in a 50ms poll
loop. Default 120s, override via MLC_EMAIL_CLI_TIMEOUT_SEC env var.
On timeout, kills the child and returns email_cli_timeout transient
error. Existing retry classifier already handles 'timeout' in stderr,
so timed-out chunks feed back into exponential backoff.

All 6 call sites in email_cli.rs migrated. Zero new dependencies.
Closes gap #2 from GPT Pro 2026-04-09 hardening review."
```

---

## Task 3: Broadcast lock CAS + migration 0004 + --force-unlock

**Why:** `broadcast_set_status(id, "sending", ...)` is an unconditional UPDATE — two concurrent `broadcast send 1` invocations both flip draft→sending and double-send. This is the #1 outage-class gap.

**Strategy:** Migration 0004 adds `locked_by_pid` + `locked_at` columns. New `broadcast_try_acquire_send_lock(id, pid, stale_after, force_unlock)` method does atomic CAS via UPDATE...WHERE. Pipeline calls it before any work; AlreadyHeld returns Transient error. Lock cleared in the final status transition.

**Files:**
- Modify: `src/db/migrations.rs` (append migration 0004)
- Modify: `src/db/mod.rs` (add LockAcquireResult enum + broadcast_try_acquire_send_lock + clear lock in broadcast_set_status_with_lock)
- Modify: `src/broadcast/pipeline.rs` (call acquire at start, fail fast on AlreadyHeld)
- Modify: `src/cli.rs` (add --force-unlock flag to BroadcastSendArgs)
- Modify: `src/commands/broadcast.rs` (pass force_unlock through to pipeline)
- Create: `tests/broadcast_lock_test.rs`

- [ ] **Step 3.1: Write migration 0004**

Open `src/db/migrations.rs`. After the existing `0003_template_html_source` entry, append a new tuple in the `MIGRATIONS` array:

```rust
    (
        "0004_broadcast_locks",
        r#"
        ALTER TABLE broadcast ADD COLUMN locked_by_pid INTEGER;
        ALTER TABLE broadcast ADD COLUMN locked_at TEXT;
        "#,
    ),
```

Verify: `grep -n "0004_broadcast_locks" src/db/migrations.rs` should match.

- [ ] **Step 3.2: Write the failing test**

Create `tests/broadcast_lock_test.rs`:

```rust
//! Tests for the concurrent-broadcast-send guard. Migration 0004 adds
//! locked_by_pid + locked_at columns. broadcast_try_acquire_send_lock
//! does atomic CAS — only one PID can hold the lock at a time, with
//! stale-detection by elapsed time.

use chrono::Duration;
use mailing_list_cli::db::{Db, LockAcquireResult};
use tempfile::TempDir;

fn fresh_db_with_broadcast() -> (TempDir, Db, i64) {
    let tmp = TempDir::new().unwrap();
    let path = tmp.path().join("state.db");
    let mut db = Db::open_at(&path).unwrap();
    // Minimal seed: list, template, broadcast in draft.
    let list_id = db.list_create("test", None, "seg_test").unwrap();
    let tpl_id = db
        .template_create(
            "tpl",
            "Subject {{ first_name }}",
            "<p>Hi {{ first_name }} {{{ unsubscribe_link }}} {{{ physical_address_footer }}}</p>",
        )
        .unwrap();
    let bid = db
        .broadcast_create("test-broadcast", tpl_id, "list", list_id)
        .unwrap();
    (tmp, db, bid)
}

#[test]
fn lock_acquire_on_fresh_draft() {
    let (_tmp, mut db, bid) = fresh_db_with_broadcast();
    let result = db
        .broadcast_try_acquire_send_lock(bid, 1234, Duration::minutes(30), false)
        .unwrap();
    assert!(matches!(result, LockAcquireResult::Acquired));
}

#[test]
fn lock_blocks_concurrent_acquire() {
    let (_tmp, mut db, bid) = fresh_db_with_broadcast();
    // First PID acquires.
    let r1 = db
        .broadcast_try_acquire_send_lock(bid, 1234, Duration::minutes(30), false)
        .unwrap();
    assert!(matches!(r1, LockAcquireResult::Acquired));
    // Second PID (different) is blocked.
    let r2 = db
        .broadcast_try_acquire_send_lock(bid, 5678, Duration::minutes(30), false)
        .unwrap();
    match r2 {
        LockAcquireResult::AlreadyHeld { pid, .. } => assert_eq!(pid, 1234),
        other => panic!("expected AlreadyHeld, got {:?}", other),
    }
}

#[test]
fn lock_resume_by_same_pid() {
    let (_tmp, mut db, bid) = fresh_db_with_broadcast();
    // Acquire as PID 1234.
    db.broadcast_try_acquire_send_lock(bid, 1234, Duration::minutes(30), false)
        .unwrap();
    // Same PID tries again (resume case) — should re-acquire.
    let r = db
        .broadcast_try_acquire_send_lock(bid, 1234, Duration::minutes(30), false)
        .unwrap();
    assert!(matches!(r, LockAcquireResult::Acquired));
}

#[test]
fn lock_breaks_stale_after_threshold() {
    let (_tmp, mut db, bid) = fresh_db_with_broadcast();
    // Acquire as PID 1234.
    db.broadcast_try_acquire_send_lock(bid, 1234, Duration::minutes(30), false)
        .unwrap();
    // Backdate the lock to look 31 minutes old.
    let stale = (chrono::Utc::now() - Duration::minutes(31)).to_rfc3339();
    db.conn
        .execute(
            "UPDATE broadcast SET locked_at = ?1 WHERE id = ?2",
            rusqlite::params![stale, bid],
        )
        .unwrap();
    // Different PID tries — should break and take the lock.
    let r = db
        .broadcast_try_acquire_send_lock(bid, 5678, Duration::minutes(30), false)
        .unwrap();
    match r {
        LockAcquireResult::BrokeStale { previous_pid, .. } => assert_eq!(previous_pid, 1234),
        other => panic!("expected BrokeStale, got {:?}", other),
    }
}

#[test]
fn lock_force_unlock_overrides_held_lock() {
    let (_tmp, mut db, bid) = fresh_db_with_broadcast();
    db.broadcast_try_acquire_send_lock(bid, 1234, Duration::minutes(30), false)
        .unwrap();
    // Different PID with force_unlock = true.
    let r = db
        .broadcast_try_acquire_send_lock(bid, 5678, Duration::minutes(30), true)
        .unwrap();
    assert!(matches!(r, LockAcquireResult::Acquired | LockAcquireResult::BrokeStale { .. }));
}

#[test]
fn lock_cleared_on_send_complete() {
    let (_tmp, mut db, bid) = fresh_db_with_broadcast();
    db.broadcast_try_acquire_send_lock(bid, 1234, Duration::minutes(30), false)
        .unwrap();
    // Simulate completion: status flip to sent + clear lock.
    db.broadcast_set_status_and_clear_lock(bid, "sent", Some("2026-04-09T12:00:00Z"))
        .unwrap();
    // Lock columns should now be NULL.
    let (pid, locked_at): (Option<i64>, Option<String>) = db
        .conn
        .query_row(
            "SELECT locked_by_pid, locked_at FROM broadcast WHERE id = ?1",
            rusqlite::params![bid],
            |r| Ok((r.get(0)?, r.get(1)?)),
        )
        .unwrap();
    assert!(pid.is_none(), "locked_by_pid should be NULL after clear");
    assert!(locked_at.is_none(), "locked_at should be NULL after clear");
}
```

- [ ] **Step 3.3: Run test to verify it fails**

Run: `cargo test --test broadcast_lock_test -- --test-threads=1 2>&1 | tail -15`
Expected: compile error or "method not found" — `broadcast_try_acquire_send_lock`, `LockAcquireResult`, `broadcast_set_status_and_clear_lock` don't exist yet.

- [ ] **Step 3.4: Add LockAcquireResult enum + broadcast_try_acquire_send_lock + broadcast_set_status_and_clear_lock to db/mod.rs**

Open `src/db/mod.rs`. Near the top of the file (after the imports, before the `pub struct Db`), add:

```rust
/// Result of attempting to acquire a broadcast send lock.
#[derive(Debug, Clone)]
pub enum LockAcquireResult {
    /// Lock acquired; status flipped to 'sending' (or remained 'sending' for resume).
    Acquired,
    /// Another process holds the lock and is still alive (locked_at within stale threshold).
    AlreadyHeld { pid: i64, locked_at: String },
    /// Lock exists but is older than the stale threshold; we took it.
    BrokeStale { previous_pid: i64, locked_at: String },
}
```

Then inside `impl Db { ... }`, near the existing `broadcast_set_status` (around line 1122), add the new methods:

```rust
    /// Try to acquire the broadcast send lock atomically. Returns:
    /// - `Acquired` if the broadcast was draft/scheduled (or already locked
    ///   by us) and we got the lock
    /// - `AlreadyHeld { pid, locked_at }` if another live process holds it
    /// - `BrokeStale { previous_pid, locked_at }` if we took a lock older
    ///   than `stale_after`
    ///
    /// Set `force_unlock = true` to override AlreadyHeld (operator escape
    /// hatch — caller must confirm the previous process is truly dead).
    ///
    /// On Acquired or BrokeStale, the broadcast row is updated with our
    /// PID and the current timestamp, and status is set to 'sending'.
    pub fn broadcast_try_acquire_send_lock(
        &mut self,
        id: i64,
        pid: i64,
        stale_after: chrono::Duration,
        force_unlock: bool,
    ) -> Result<LockAcquireResult, AppError> {
        let tx = self.conn.transaction().map_err(|e| AppError::Transient {
            code: "broadcast_lock_tx_failed".into(),
            message: format!("could not begin lock transaction: {e}"),
            suggestion: "Retry the command".into(),
        })?;

        let row: (String, Option<i64>, Option<String>) = tx
            .query_row(
                "SELECT status, locked_by_pid, locked_at FROM broadcast WHERE id = ?1",
                rusqlite::params![id],
                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
            )
            .map_err(|e| AppError::BadInput {
                code: "broadcast_not_found".into(),
                message: format!("broadcast {id} not found: {e}"),
                suggestion: "Check the broadcast id with `mailing-list-cli broadcast ls`".into(),
            })?;
        let (status, current_pid, current_locked_at) = row;

        let now = chrono::Utc::now();
        let now_str = now.to_rfc3339();

        let is_resume_by_us = current_pid == Some(pid);
        let is_stale = current_locked_at
            .as_deref()
            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
            .map(|t| (now - t.with_timezone(&chrono::Utc)) > stale_after)
            .unwrap_or(false);
        let is_unlocked = current_pid.is_none();
        let status_allows = matches!(status.as_str(), "draft" | "scheduled");

        let predicate_ok = status_allows
            || is_resume_by_us
            || is_stale
            || is_unlocked
            || force_unlock;

        if !predicate_ok {
            // AlreadyHeld — return without taking the lock.
            let pid = current_pid.unwrap_or(0);
            let locked_at = current_locked_at.unwrap_or_default();
            // Drop the transaction (no UPDATE) — implicit on tx going out of scope.
            return Ok(LockAcquireResult::AlreadyHeld { pid, locked_at });
        }

        // Take the lock: status → 'sending', locked_by_pid → us, locked_at → now.
        tx.execute(
            "UPDATE broadcast SET status = 'sending', locked_by_pid = ?1, locked_at = ?2 WHERE id = ?3",
            rusqlite::params![pid, now_str, id],
        )
        .map_err(|e| AppError::Transient {
            code: "broadcast_lock_update_failed".into(),
            message: format!("could not write lock: {e}"),
            suggestion: "Retry the command".into(),
        })?;

        tx.commit().map_err(|e| AppError::Transient {
            code: "broadcast_lock_commit_failed".into(),
            message: format!("could not commit lock transaction: {e}"),
            suggestion: "Retry the command".into(),
        })?;

        // Decide which result variant.
        if is_stale && !is_resume_by_us && !is_unlocked {
            Ok(LockAcquireResult::BrokeStale {
                previous_pid: current_pid.unwrap_or(0),
                locked_at: current_locked_at.unwrap_or_default(),
            })
        } else {
            Ok(LockAcquireResult::Acquired)
        }
    }

    /// Final status transition that ALSO clears the lock columns. Use at
    /// the end of the broadcast send pipeline (sent OR failed). The lock
    /// must be cleared so the broadcast can be inspected without lock
    /// confusion if the operator runs `broadcast resume` or `broadcast send`
    /// again later.
    pub fn broadcast_set_status_and_clear_lock(
        &mut self,
        id: i64,
        status: &str,
        sent_at: Option<&str>,
    ) -> Result<(), AppError> {
        self.conn
            .execute(
                "UPDATE broadcast SET status = ?1, sent_at = COALESCE(?2, sent_at), locked_by_pid = NULL, locked_at = NULL WHERE id = ?3",
                rusqlite::params![status, sent_at, id],
            )
            .map_err(|e| AppError::Transient {
                code: "broadcast_status_update_failed".into(),
                message: format!("could not update broadcast status: {e}"),
                suggestion: "Retry the command".into(),
            })?;
        Ok(())
    }
```

- [ ] **Step 3.5: Run lock test to verify it passes**

Run: `cargo test --test broadcast_lock_test -- --test-threads=1 2>&1 | tail -20`
Expected: all 6 lock tests pass.

- [ ] **Step 3.6: Add --force-unlock flag to BroadcastSendArgs in cli.rs**

Open `src/cli.rs`. Find the existing `BroadcastSendArgs` struct (around line 462):

```rust
#[derive(Args, Debug)]
pub struct BroadcastSendArgs {
    pub id: i64,
}
```

Replace with:

```rust
#[derive(Args, Debug)]
pub struct BroadcastSendArgs {
    pub id: i64,
    /// Force-acquire the send lock even if another process appears to hold it.
    /// USE WITH CAUTION: only after confirming the other process is truly dead
    /// (e.g., `ps aux | grep mailing-list-cli`). Risk: double-send if the
    /// other process is still alive and rendering chunks.
    #[arg(long)]
    pub force_unlock: bool,
}
```

- [ ] **Step 3.7: Wire force_unlock through commands/broadcast.rs into the pipeline**

Open `src/commands/broadcast.rs`. Find the `send` (or whatever it's called) handler that takes `BroadcastSendArgs`. The signature change passes `args.force_unlock` into the call to the pipeline's `send_broadcast` function.

Run: `grep -n "send_broadcast\|BroadcastSendArgs\|args\.id" src/commands/broadcast.rs` to find the handler.

Modify the call site to pass `args.force_unlock` as a new parameter. The pipeline function signature will be updated in the next step.

- [ ] **Step 3.8: Update src/broadcast/pipeline.rs::send_broadcast to acquire the lock**

Open `src/broadcast/pipeline.rs`. Find the `send_broadcast` function (the entrypoint called from the command handler). Add `force_unlock: bool` as a new parameter at the end of its signature. At the very TOP of the function body, before any other work:

```rust
    use crate::db::LockAcquireResult;
    let pid = std::process::id() as i64;
    let stale_after = chrono::Duration::minutes(30);
    match db.broadcast_try_acquire_send_lock(broadcast_id, pid, stale_after, force_unlock)? {
        LockAcquireResult::Acquired => {}
        LockAcquireResult::BrokeStale { previous_pid, locked_at } => {
            eprintln!(
                "warning: breaking stale lock from pid {previous_pid} (locked_at {locked_at}, > 30 min ago)"
            );
        }
        LockAcquireResult::AlreadyHeld { pid: holder, locked_at } => {
            return Err(AppError::Transient {
                code: "broadcast_lock_held".into(),
                message: format!(
                    "broadcast {broadcast_id} is already being sent by process {holder}, started {locked_at}"
                ),
                suggestion: "Wait for the other process to finish, OR if you're sure it died, run `broadcast send {broadcast_id} --force-unlock`".into(),
            });
        }
    }
```

Then find every existing `db.broadcast_set_status(id, "sent", ...)` and `db.broadcast_set_status(id, "failed", ...)` call site in the function. Replace with `db.broadcast_set_status_and_clear_lock(id, "sent", sent_at)` and `db.broadcast_set_status_and_clear_lock(id, "failed", None)` respectively.

Note: `broadcast_set_status` may still be called for the initial `draft → sending` transition elsewhere. The lock acquire above already handles that flip — REMOVE any redundant `broadcast_set_status(id, "sending", ...)` call that previously happened before the chunk loop. Search with: `grep -n 'broadcast_set_status' src/broadcast/pipeline.rs`.

- [ ] **Step 3.9: Update the command handler to call the new pipeline signature**

Open `src/commands/broadcast.rs`. Find the call site of `send_broadcast(...)`. Add `args.force_unlock` as the final argument.

If there are TWO entry points (one for `send`, one for `resume` alias), update both.

- [ ] **Step 3.10: Run full test suite to verify no regression**

Run: `cargo build 2>&1 | tail -20`
Expected: clean build.

Run: `cargo test -- --test-threads=1 2>&1 | grep "test result"`
Expected: 177 + 3 (timeout tests from Task 2) + 6 (lock tests) + 2 (schema tests from Task 1) = 188 passing. Some existing pipeline_tests.rs cases may have shifted.

If existing tests fail because they directly call `broadcast_set_status(id, "sent", ...)`, leave them as-is — `broadcast_set_status` is still a valid method, just no longer the only way to mark sent.

- [ ] **Step 3.11: Run cargo fmt + clippy**

Run: `cargo fmt --check 2>&1 | tail -5 && cargo clippy --all-targets -- -D warnings 2>&1 | tail -10`
Expected: clean. If fmt fails, run `cargo fmt`. If clippy warns about unused imports or `force_unlock`, address.

- [ ] **Step 3.12: Commit**

```bash
git add src/db/migrations.rs src/db/mod.rs src/broadcast/pipeline.rs src/cli.rs src/commands/broadcast.rs tests/broadcast_lock_test.rs
git commit -m "feat(broadcast): atomic lock CAS prevents concurrent send race

Two simultaneous \`mailing-list-cli broadcast send 1\` invocations
used to both flip draft→sending and double-send every recipient.
Sender domain reputation tanks within hours.

Migration 0004 adds broadcast.locked_by_pid + locked_at columns.
broadcast_try_acquire_send_lock does atomic CAS via UPDATE inside
a BEGIN IMMEDIATE transaction. Predicate satisfied if status is
draft/scheduled, OR same-PID resume, OR locked_at > 30 min stale,
OR force_unlock.

Pipeline calls acquire at start, fails fast with code
'broadcast_lock_held' (exit 1 — operator can retry after the other
process finishes). Lock cleared in the final status transition via
broadcast_set_status_and_clear_lock (sent OR failed).

New CLI flag: \`broadcast send <id> --force-unlock\` for the operator
escape hatch when a previous process truly died.

Closes gap #1 from GPT Pro 2026-04-09 hardening review."
```

---

## Task 4: agent-info + AGENTS.md content sync + drift test

**Why:** `agent-info` is missing v0.3.0 commands (`contact erase`, `broadcast resume`) and falsely claims a `--json` global flag. AGENTS.md still says "Version: v0.2.0". Both files are part of the binary's agent-facing contract — drift here is hostile to LLM agents driving blind.

**Strategy:** Manually rewrite both files to v0.3.1 reality. Add a regression test that asserts the agent-info manifest contains a known-current set of command keys.

**Files:**
- Modify: `src/commands/agent_info.rs`
- Modify: `AGENTS.md`
- Create: `tests/agent_info_drift_test.rs`

- [ ] **Step 4.1: Write the failing test**

Create `tests/agent_info_drift_test.rs`:

```rust
//! Sanity check that agent-info exposes the v0.3.1 surface. NOT a
//! schema-derived test (we don't introspect clap) — it's a known-good
//! list of CLI commands that MUST appear in the manifest. When you add
//! a new subcommand or rename one, update this list AND the agent-info
//! source. When you forget to update agent-info, this test fails.

use assert_cmd::Command;
use serde_json::Value;

#[test]
fn agent_info_includes_v0_3_1_surface() {
    let output = Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .arg("agent-info")
        .output()
        .unwrap();
    assert!(output.status.success(), "agent-info exited non-zero");
    let manifest: Value = serde_json::from_slice(&output.stdout).unwrap();

    let commands = manifest
        .get("commands")
        .and_then(|c| c.as_object())
        .expect("agent-info must have a `commands` object");

    let must_have_prefixes = [
        "contact erase",
        "broadcast resume",
        "broadcast send",
        "template render",
    ];
    for needle in must_have_prefixes {
        assert!(
            commands.keys().any(|k| k.starts_with(needle)),
            "agent-info commands list missing entry starting with `{needle}` — manifest has drifted from the live CLI surface"
        );
    }

    // The false `--json` global flag must NOT be present.
    let json_flag = manifest
        .get("flags")
        .and_then(|f| f.get("--json"));
    assert!(
        json_flag.is_none(),
        "agent-info still claims a global --json flag exists; it does not (output is auto-detected via IsTerminal)"
    );

    // The version field must match Cargo's compile-time version.
    let version = manifest
        .get("version")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    assert_eq!(
        version,
        env!("CARGO_PKG_VERSION"),
        "agent-info version field is hardcoded; it should reflect CARGO_PKG_VERSION"
    );
}

#[test]
fn agent_info_is_valid_json() {
    let output = Command::cargo_bin("mailing-list-cli")
        .unwrap()
        .arg("agent-info")
        .output()
        .unwrap();
    let _manifest: Value = serde_json::from_slice(&output.stdout)
        .expect("agent-info output must be valid JSON");
}
```

- [ ] **Step 4.2: Run test to verify it fails**

Run: `cargo test --test agent_info_drift_test -- --test-threads=1 2>&1 | tail -15`
Expected: `agent_info_includes_v0_3_1_surface` FAILS — `contact erase` and `broadcast resume` are missing from the current manifest, and the false `--json` flag IS present.

- [ ] **Step 4.3: Rewrite src/commands/agent_info.rs to v0.3.1 surface**

Open `src/commands/agent_info.rs`. Replace the entire `commands` dictionary entries to match the live CLI surface. Add the missing entries:

In the `commands` object inside the `json!` macro, add these lines (preserving alphabetical/grouping order with neighbors):

```rust
            "contact erase <email> --confirm": "Permanently erase a contact (GDPR Article 17). Inserts gdpr_erasure suppression tombstone, deletes contact row, FK cascades child tables",
            "broadcast resume <id> [--force-unlock]": "Resume an interrupted broadcast send (alias of `broadcast send`). Skips already-sent recipients via the broadcast_recipient table",
            "template render <name> [--with-data <file>] [--with-placeholders] [--raw]": "Compile template → JSON {subject,html,text}. --raw skips automatic injection of unsubscribe-link / physical-address-footer stubs (for advanced template authors who provide their own)",
```

And REPLACE the existing `broadcast send` line:
```rust
            "broadcast send <id> [--force-unlock]": "Run the full send pipeline. Acquires an atomic broadcast lock; --force-unlock overrides a held lock (use only when previous process is truly dead)",
```

REMOVE the false `flags` block:
```rust
        "flags": {
            "--json": "Force JSON output (auto-enabled when stdout is not a TTY)"
        },
```

REPLACE with:
```rust
        "global_flags": "none — output mode is auto-detected via IsTerminal (TTY → human, pipe/redirect → JSON envelope)",
```

Update the `status` field. Replace the v0.3.0 string with:
```rust
        "status": "v0.3.1 — emergency hardening: atomic broadcast lock CAS (no double-send race), email-cli subprocess timeout (MLC_EMAIL_CLI_TIMEOUT_SEC, default 120s), schema version safety check (fail-fast on newer DB), agent-info + AGENTS.md sync. Built on v0.3.0 production-grade 10k foundations."
```

Update `version` to read from CARGO_PKG_VERSION (it already does, leave as-is): `"version": env!("CARGO_PKG_VERSION"),`.

Add `env_vars` block above `status`:
```rust
        "env_vars": {
            "MLC_EMAIL_CLI_TIMEOUT_SEC": "Timeout in seconds for email-cli subprocess calls (default: 120)",
            "MLC_UNSUBSCRIBE_SECRET": "HMAC secret for one-click unsubscribe link signatures (required for `broadcast send`)"
        },
```

Update `health` description if not already specific:
```rust
            "health": "Run a system health check (config, email-cli on PATH, sender_domain_verified, db reachable)",
```

- [ ] **Step 4.4: Run drift test to verify it passes**

Run: `cargo test --test agent_info_drift_test -- --test-threads=1 2>&1 | tail -15`
Expected: both tests PASS.

- [ ] **Step 4.5: Rewrite AGENTS.md to v0.3.1 reality**

Open `AGENTS.md`. Replace lines 9-12 (the "Current state" block):

**Before:**
```markdown
## Current state

- **Version**: v0.2.0 (agent-native rearchitecture, breaking release)
- **Research**: see [/research](./research) for the five dossiers that informed the original design
- **Phase plan**: [docs/plans/2026-04-08-phase-7-v0.2-rearchitecture.md](./docs/plans/2026-04-08-phase-7-v0.2-rearchitecture.md)
```

**After:**
```markdown
## Current state

- **Version**: v0.3.1 (emergency hardening on top of v0.3.0 production-grade 10k foundations)
- **Research**: see [/research](./research) for the five dossiers that informed the original design
- **Recent plans**:
  - [v0.2 rearchitecture](./docs/plans/2026-04-08-phase-7-v0.2-rearchitecture.md) (shipped as v0.2.0)
  - [v0.3 production-grade 10k](./docs/plans/2026-04-09-phase-8-v0.3-production-grade-10k.md) (shipped as v0.3.0)
  - [v0.3.1 emergency hardening](./docs/plans/2026-04-09-phase-9-v0.3.1-emergency-hardening.md) (shipped as v0.3.1)

## Production hardening (v0.3.x)

What "production-grade" means in this codebase:

- **Send pipeline reliability**: 429/5xx retry with exponential backoff [500ms, 1s, 2s, 4s], up to 4 retries; per-chunk DB transactions; preloaded suppression HashSet for O(1) lookups; 30-day complaint/bounce rate guard in preflight; resumable sends with atomic broadcast lock CAS (no double-send race even on concurrent invocation).
- **Subprocess safety**: every `email-cli` call has a 120-second default timeout (`MLC_EMAIL_CLI_TIMEOUT_SEC` env var to override); hung subprocesses are killed via SIGKILL and surfaced as transient errors that feed the existing retry path.
- **Schema safety**: `Db::open` fails fast (exit code 2) when the on-disk schema version is newer than what this binary supports — no more silent column-mismatch errors after a binary downgrade.
- **GDPR compliance**: `contact erase --confirm` writes a `gdpr_erasure` suppression tombstone before deleting the contact row (atomic transaction; the email is never momentarily absent from both).
- **Operator escape hatches**: `broadcast send <id> --force-unlock` overrides a held lock when the previous process is confirmed dead.
```

- [ ] **Step 4.6: Run all tests to verify no regression**

Run: `cargo test -- --test-threads=1 2>&1 | grep "test result"`
Expected: 177 + 3 + 6 + 2 + 2 = 190 passing.

- [ ] **Step 4.7: Run cargo fmt + clippy**

Run: `cargo fmt --check 2>&1 | tail -5 && cargo clippy --all-targets -- -D warnings 2>&1 | tail -10`
Expected: clean.

- [ ] **Step 4.8: Commit**

```bash
git add src/commands/agent_info.rs AGENTS.md tests/agent_info_drift_test.rs
git commit -m "docs(agent-info): sync manifest + AGENTS.md to v0.3.1 surface

agent-info was missing contact erase, broadcast resume, the new
--force-unlock flag, and the --raw template render flag (all
shipped in v0.3.0 or earlier in this session). It also falsely
claimed a global --json flag exists; output is auto-detected via
IsTerminal — there is no --json flag. AGENTS.md still said v0.2.0.

Manual rewrite of both files. Adds tests/agent_info_drift_test.rs
which asserts the manifest contains a known-current set of command
key prefixes and that the version field matches CARGO_PKG_VERSION.
Drift caught by \`cargo test\` going forward.

Auto-generation of agent-info from clap introspection is deferred
to v0.4 (refactor candidate, out of scope for this patch).

Closes gap #4 from GPT Pro 2026-04-09 hardening review."
```

---

## Release Task: Bump to v0.3.1, smoke-test, push, publish, homebrew

**Why:** All 4 hardening fixes have landed on `main`; tests green; no regressions. Now: bump version, rebuild release binary, run the mandatory paperfoot.com smoke test (the v0.3.0 13-step flow plus 1 new concurrent-send step), tag, push, publish to crates.io (first publish), add formula to homebrew tap, write session-6 handoff.

**Files:**
- Modify: `Cargo.toml`
- Modify: `README.md`
- Modify: `src/commands/agent_info.rs` (status string only)
- Create: `docs/handoffs/2026-04-09-session-6-handoff.md`
- Create (in homebrew-tap repo): `Formula/mailing-list-cli.rb`

- [ ] **Step R.1: Bump version**

Edit `Cargo.toml`:
```toml
version = "0.3.1"
```

Edit `README.md` status badge (search for `0.3.0`):
```markdown
[![Status: v0.3.1 email-cli v0.6](https://img.shields.io/badge/Status-v0.3.1_email--cli_v0.6-orange?style=for-the-badge)](#status)
```

`src/commands/agent_info.rs` status string is already v0.3.1 from Task 4.

- [ ] **Step R.2: Full test + lint + fmt + build**

```bash
cargo fmt --check 2>&1 | tail -5
cargo clippy --all-targets -- -D warnings 2>&1 | tail -5
cargo test -- --test-threads=1 2>&1 | grep "test result"
cargo build --release 2>&1 | tail -3
./target/release/mailing-list-cli --version
```
Expected: fmt clean, clippy clean, ≥ 190 tests passing, release binary reports `mailing-list-cli 0.3.1`.

- [ ] **Step R.3: Mandatory paperfoot.com smoke test**

Run the v0.3.0 13-step flow against a fresh `/tmp/mlc-smoke-v0.3.1/` directory (preserved). Add 1 new step:

| Step | What |
|---|---|
| 1-13 | Existing v0.3.0 flow: health, list create, contact add, template create+lint+preview, broadcast create, broadcast preview real send, broadcast send batch, webhook poll, broadcast show, report show, hard-fail path, broadcast resume, contact erase |
| **14 NEW** | Concurrent send guard: in two terminals, run `broadcast send <fresh-id>` simultaneously. Verify exactly one succeeds, the other exits 1 with `broadcast_lock_held` error code |

If step 14 is hard to script (timing), simulate by manually setting `locked_by_pid = 99999, locked_at = $(date)` on a draft broadcast in SQLite, then running `broadcast send <id>` and verifying exit 1.

Expected: all 14 steps green. If ANY step fails, do NOT tag. Investigate, fix, re-run.

- [ ] **Step R.4: Commit version bump**

```bash
git add Cargo.toml Cargo.lock README.md
git commit -m "chore: bump to v0.3.1 — emergency hardening release"
git log --oneline -10
```

- [ ] **Step R.5: Tag v0.3.1**

```bash
git tag v0.3.1 HEAD -m "v0.3.1 — emergency hardening (lock CAS, subprocess timeout, schema check, agent-info sync)"
```

- [ ] **Step R.6: Push main + tag**

```bash
git push origin main
git push origin v0.3.1
```

- [ ] **Step R.7: Publish to crates.io (first publish for this crate)**

```bash
cargo publish --dry-run 2>&1 | tail -20
```
Expected: dry-run succeeds, lists files to be packaged. Address any warnings about missing fields.

If clean:
```bash
cargo publish 2>&1 | tail -20
```
Expected: package uploaded to crates.io. Visit `https://crates.io/crates/mailing-list-cli` to verify.

If `cargo publish` fails with "not logged in", run `cargo login <token>` first using the user's crates.io token.

- [ ] **Step R.8: Compute SHA256 of the release tarball**

After tag is pushed and crates.io publish is done:

```bash
TAG=v0.3.1
TARBALL_URL="https://github.com/paperfoot/mailing-list-cli/archive/refs/tags/${TAG}.tar.gz"
SHA256=$(curl -sL "$TARBALL_URL" | shasum -a 256 | cut -d' ' -f1)
echo "tarball: $TARBALL_URL"
echo "sha256:  $SHA256"
```

Save the SHA256 — you'll paste it into the homebrew formula in the next step.

- [ ] **Step R.9: Add Formula/mailing-list-cli.rb to 199-biotechnologies/homebrew-tap**

Clone the tap to a temp dir, write the formula, commit, push:

```bash
TAP_DIR=$(mktemp -d)
git clone https://github.com/199-biotechnologies/homebrew-tap.git "$TAP_DIR"
cat > "$TAP_DIR/Formula/mailing-list-cli.rb" <<EOF
class MailingListCli < Formula
  desc "Newsletter and mailing list management from the terminal. Built for AI agents on top of email-cli"
  homepage "https://github.com/paperfoot/mailing-list-cli"
  url "https://github.com/paperfoot/mailing-list-cli/archive/refs/tags/v0.3.1.tar.gz"
  sha256 "<PASTE_SHA256_FROM_STEP_R.8>"
  license "MIT"
  head "https://github.com/paperfoot/mailing-list-cli.git", branch: "main"

  depends_on "rust" => :build

  def install
    system "cargo", "install", *std_cargo_args
  end

  test do
    assert_match "mailing-list-cli", shell_output("#{bin}/mailing-list-cli --version")
    assert_match "agent-info", shell_output("#{bin}/mailing-list-cli --help")
  end
end
EOF

# Hand-edit the SHA256 placeholder before commit:
# (replace <PASTE_SHA256_FROM_STEP_R.8> with the actual value)

cd "$TAP_DIR"
git add Formula/mailing-list-cli.rb
git commit -m "feat: add mailing-list-cli v0.3.1 formula

Newsletter and mailing list management CLI for AI agents.
First release to homebrew tap. Built on top of email-cli (sister
binary), shares the same agent-cli-framework patterns.

Install: brew tap 199-biotechnologies/tap && brew install mailing-list-cli"
git push origin main
cd -
rm -rf "$TAP_DIR"
```

Verify the formula loads:
```bash
brew tap 199-biotechnologies/tap 2>&1
brew info 199-biotechnologies/tap/mailing-list-cli 2>&1 | head -20
```

- [ ] **Step R.10: Verify CI on the tag**

```bash
gh run list --branch v0.3.1 --limit 5 2>&1
```
Expected: green. If it flakes, investigate immediately.

- [ ] **Step R.11: Write session-6 handoff**

Create `docs/handoffs/2026-04-09-session-6-handoff.md` summarizing:
- v0.3.1 shipped: 4 fixes (lock CAS, subprocess timeout, schema check, agent-info sync), 13 new tests (177 → 190)
- crates.io: first publish ever for `mailing-list-cli`
- Homebrew: formula added to `199-biotechnologies/homebrew-tap`
- Migration 0004 used for broadcast_locks; v0.4 plan must renumber its content-snapshot migration to 0005
- v0.4 plan (operator superpowers) is still NOT YET WRITTEN — that's the next session's job
- GPT Pro hardening review is still pending its full report; this v0.3.1 only addressed the 4 confirmed gaps the user pre-identified

- [ ] **Step R.12: Commit handoff + final push**

```bash
git add docs/handoffs/2026-04-09-session-6-handoff.md
git commit -m "docs: session 6 handoff — v0.3.1 emergency hardening shipped"
git push origin main
```

---

## Self-review

**1. Spec coverage:** Each of the 4 fixes in the spec has a task. Schema check = Task 1. Subprocess timeout = Task 2. Lock CAS = Task 3. agent-info sync = Task 4. Release = Task 5 (includes crates + homebrew). ✓

**2. Placeholder scan:** No "TODO", "TBD", "implement later", "similar to task N", "fill in details". Step R.9 has a `<PASTE_SHA256_FROM_STEP_R.8>` placeholder which is intentional and explicitly documented as "hand-edit before commit" — that's a runtime fill-in, not a plan placeholder. ✓

**3. Type consistency:** `LockAcquireResult` has 3 variants used identically in tests and pipeline (Acquired, AlreadyHeld { pid, locked_at }, BrokeStale { previous_pid, locked_at }). `broadcast_try_acquire_send_lock` signature is `(&mut self, id: i64, pid: i64, stale_after: chrono::Duration, force_unlock: bool) -> Result<LockAcquireResult, AppError>` in both definition (Task 3 Step 4) and call sites (test + pipeline). `run_with_timeout` takes `Command`, returns `Result<Output, AppError>` consistently. AppError variants `Transient { code, message, suggestion }`, `Config { code, message, suggestion }`, `BadInput { code, message, suggestion }` match the existing enum. ✓

**4. Out-of-scope guardrails:** No new runtime deps. No daemon. No db check / db repair. No audit log. No `broadcast show` lock surfacing. No clap introspection. No webhook listener. ✓

**5. Mutability:** Task 3's `broadcast_try_acquire_send_lock` and `broadcast_set_status_and_clear_lock` both take `&mut self` because they need a transaction (begin_immediate). Task 1's schema check uses `&self.conn.query_row(...)` (read-only, no mut needed). Task 2's `run_with_timeout` is a free function, no self. ✓

**6. Migration numbering lockdown:** This plan REQUIRES the v0.4 plan to renumber its content-snapshot migration from `0004_content_snapshots_and_revenue` to `0005_content_snapshots_and_revenue`. The session-5 handoff used "0004" generically. The session-6 handoff written in Step R.11 will explicitly call this out. ✓

**7. Effort sanity:** 5 tasks × ~10 steps each = 50 steps. With TDD overhead and the release pipeline, ~1.5 days inline. ✓

---

## Execution handoff

User has explicitly approved inline execution without a post-plan-write checkpoint. Proceed directly to executing-plans, then the release pipeline (smoke test, tag, push, crates.io publish, homebrew tap update, session-6 handoff).
