# v0.3.1 Emergency Hardening — Design

**Date:** 2026-04-09
**Status:** APPROVED — proceed to implementation plan
**Author:** Boris Djordjevic + Claude Opus
**Approval mode:** verbal approval given before spec write; user opted out of post-write review pause

---

## Goal

Ship a fast patch release (`v0.3.1`) that closes four outage-class gaps confirmed by GPT Pro analysis on 2026-04-09, before the v0.4 plan is even written. Each gap can land independently as its own commit on `main` (trunk-based, no feature branch). The release is bounded: no new runtime crates, no new architectural rewrites, no scope creep into v0.4 territory.

## Why now

The four gaps share one property: **none of them are in the v0.4 scope**, and **all four can produce silent or loud production outages** at 10k+ recipient volume. They are foundational hardening that v0.4's "Operator superpowers" theme deliberately deferred to later. With a real binary already shipping to production-grade lists, deferring hardening to v0.5+ is strictly worse than a fast 1.5-day patch.

## The four gaps

| # | Gap | Outage class | Fix scope |
|---|---|---|---|
| 1 | Concurrent `broadcast send` lacks locking — two simultaneous invocations both flip `draft → sending` and double-send | **Critical** — recipients get duplicate emails, sender domain reputation tanks within hours | Migration 0004 + DB CAS method + pipeline guard + `--force-unlock` flag |
| 2 | `email-cli` subprocess calls have no timeout — a hung subprocess freezes the entire binary indefinitely | **High** — broadcast hangs forever, requires manual `kill`, no recovery path | One shared helper in `email_cli.rs`, 6 call sites, env-var configurable default |
| 3 | Older binaries don't fail fast on newer DB schemas — silent column-mismatch errors instead of "wrong binary version" | **High** — confusing error messages, undebuggable crashes after binary downgrade | ~25 lines in `Db::open()` against the existing `schema_version` table |
| 4 | `agent-info` and `AGENTS.md` are stale against the live CLI surface (missing `contact erase`, `broadcast resume`, `template render --raw`, false `--json` flag) | **Medium** — agents driving the binary blind get bad capability manifests, attempt non-existent flags | Manual rewrite of both files + minimal regression test |

---

## Fix 1 — Broadcast lock CAS

### Problem statement

`src/db/mod.rs:1122` `broadcast_set_status(id, "sending", ...)` is an unconditional `UPDATE broadcast SET status = ?1 WHERE id = ?3`. There is no predicate that checks "is anyone else already sending this?". Two concurrent `mailing-list-cli broadcast send 1` invocations both flip `draft → sending`, both load the same recipient set, both call `email-cli batch send` with overlapping payloads. Result: each recipient gets the email twice (or N times for N parallel invocations).

The v0.3.0 resume feature accidentally protects the **happy path** for sequential invocations (already-sent recipients are skipped on the second run), but resume is a **post-hoc** filter, not a **pre-flight** lock — it does nothing for two truly concurrent runs that both load the recipient set before either has marked any rows.

### Architecture

**Migration 0004** adds two columns to the existing `broadcast` table:

```sql
ALTER TABLE broadcast ADD COLUMN locked_by_pid INTEGER;
ALTER TABLE broadcast ADD COLUMN locked_at TEXT;
```

Both nullable. Existing broadcast rows from v0.3.0 and earlier get NULL for both, which means "no current lock" — backward compatible.

**New DB method** in `src/db/mod.rs`:

```rust
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 },
}

pub fn broadcast_try_acquire_send_lock(
    &mut self,
    id: i64,
    pid: i64,
    stale_after: chrono::Duration,
    force_unlock: bool,
) -> Result<LockAcquireResult, AppError>
```

The implementation runs as a single `BEGIN IMMEDIATE` transaction:

1. `SELECT status, locked_by_pid, locked_at FROM broadcast WHERE id = ?` → if no row, BadInput error.
2. Compute `is_stale = locked_at IS NOT NULL AND now - locked_at > stale_after`.
3. Compute `is_resume_by_us = locked_by_pid = our_pid`.
4. Compute predicate satisfied: `status IN ('draft', 'scheduled')` OR `is_resume_by_us` OR `is_stale` OR `force_unlock`.
5. If predicate satisfied: `UPDATE broadcast SET status='sending', locked_by_pid=?, locked_at=? WHERE id=?` then return Acquired (or BrokeStale if `is_stale && !is_resume_by_us`).
6. If predicate not satisfied: return AlreadyHeld with the current pid + locked_at for the error message.

**Pipeline integration** in `src/broadcast/pipeline.rs::send_broadcast`:

Before any work (before suppression load, before chunk loop), call `broadcast_try_acquire_send_lock`. On `AlreadyHeld`, return:

```
AppError::Transient {
    code: "broadcast_lock_held",
    message: "broadcast {id} is already being sent by process {pid}, started {locked_at} ({elapsed} ago)",
    suggestion: "Wait for the other process to finish, OR if you're sure it died, run `broadcast send {id} --force-unlock`",
}
```

Exit code 1 (Transient) so retries are valid after the first process finishes.

On `BrokeStale`, log a warning to stderr but proceed: `"breaking stale lock from pid {previous_pid} (locked_at {locked_at}, > {stale_after} ago)"`.

On successful pipeline completion (final `broadcast_set_status(id, "sent", ...)`), the lock columns are CLEARED in the same UPDATE: `UPDATE broadcast SET status='sent', locked_by_pid=NULL, locked_at=NULL, sent_at=? WHERE id=?`.

On pipeline failure (final `broadcast_set_status(id, "failed", ...)`), the lock columns are ALSO cleared so the broadcast can be inspected without lock confusion.

### CLI surface

New flag on `broadcast send` and its `broadcast resume` alias:

```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,
}
```

### Stale threshold

**30 minutes.** Justification: a 100k-recipient broadcast with 100-recipient chunks at ~2 seconds per chunk (including retries) = 33 minutes worst case. A 10k-recipient broadcast = ~3 minutes. 30 minutes covers everything up to ~50k recipients. For larger sends, the operator can use `--force-unlock`. We can revisit the threshold or make it configurable in v0.4 if it bites; for v0.3.1 a hardcoded const is fine.

### Tests

| Test | What it asserts |
|---|---|
| `lock_acquire_on_fresh_draft` | new broadcast in draft → Acquired, status flipped to sending, columns set |
| `lock_blocks_concurrent_acquire` | first acquire returns Acquired; second acquire from a different PID returns AlreadyHeld |
| `lock_breaks_stale` | a lock with `locked_at = now - 31 min` can be acquired by a new PID, returns BrokeStale |
| `lock_resume_by_same_pid` | second acquire from the SAME PID succeeds (resume case) |
| `pipeline_fails_fast_on_held_lock` | end-to-end: `send_broadcast` returns `AppError::Transient { code: "broadcast_lock_held" }` when another process holds the lock |
| `lock_cleared_on_send_complete` | after successful send, `locked_by_pid IS NULL AND locked_at IS NULL` |

---

## Fix 2 — email-cli subprocess timeout

### Problem statement

Every `email-cli` invocation in `src/email_cli.rs` is `Command::new(&self.path).args(...).output()` — a blocking call with no timeout. If `email-cli` hangs (network freeze, Resend API stall, infinite loop in upstream code), `mailing-list-cli` hangs forever. The user has to `Ctrl-C` and the broadcast is left in `sending` status.

Six call sites currently lack timeouts. Lines (current, may shift slightly during impl): 78, 117, 210, 254, 307, 367.

### Architecture

**Pure stdlib, zero new dependencies.** Add a private helper to `src/email_cli.rs`:

```rust
const DEFAULT_EMAIL_CLI_TIMEOUT_SEC: u64 = 120;
const ENV_TIMEOUT: &str = "MLC_EMAIL_CLI_TIMEOUT_SEC";
const POLL_INTERVAL_MS: u64 = 50;

/// Spawn `cmd` as a subprocess and wait for it to complete with a timeout.
/// On timeout, sends SIGKILL via `Child::kill()` and returns
/// `AppError::Transient { code: "email_cli_timeout", ... }`.
///
/// Timeout source priority:
///   1. `MLC_EMAIL_CLI_TIMEOUT_SEC` env var (parsed as u64 seconds)
///   2. `DEFAULT_EMAIL_CLI_TIMEOUT_SEC` (120s)
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 = Duration::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)) => {
                // Child exited; collect buffered stdout/stderr.
                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(Duration::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(),
                });
            }
        }
    }
}
```

**All 6 existing call sites** (`Command::new(&self.path).args(...).output()?`) replace `.output()` with `run_with_timeout(cmd)`. The build pattern becomes:

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

The retry loop in `batch_send` already classifies stderr containing `"timeout"` as retryable, and the new `email_cli_timeout` error message contains the word "timeout", so timed-out chunks naturally feed back into the existing exponential backoff retry path. **No retry-loop changes required.**

### Why try_wait poll loop, not threads or wait-timeout crate

- **Threads + mpsc::recv_timeout:** the cleanest stdlib version requires either moving the `Child` into a thread (then we can't kill it on timeout) or holding a `Mutex<Child>` shared between main and watchdog thread (lock contention + complexity).
- **wait-timeout crate:** ~1 small dep, but breaks the 14-crate budget for one feature. Not worth it.
- **try_wait poll loop:** dep-free, single-threaded, child handle stays in main thread, kill is a one-liner. The 50ms poll interval costs ~20 syscalls/sec during a wait — negligible vs the 100-recipient batch send latency (~2-5 seconds typical).

### Tests

| Test | What it asserts |
|---|---|
| `subprocess_completes_under_timeout` | a fast stub returns Output normally |
| `subprocess_killed_on_timeout` | a stub that `sleep 5` with `MLC_EMAIL_CLI_TIMEOUT_SEC=1` returns `email_cli_timeout` error in ~1s |
| `timeout_env_var_overrides_default` | with no env var, default 120s applies; with `MLC_EMAIL_CLI_TIMEOUT_SEC=300`, 300s applies |

The existing `tests/email_cli_retry.rs` continues to pass unchanged because the retry loop already handles `"timeout"` in stderr.

---

## Fix 3 — Schema version safety check

### Problem statement

`src/db/mod.rs::run_migrations` (line 43) iterates `migrations::MIGRATIONS` and applies each entry not yet recorded in the `schema_version` table. There is **no check for the inverse** — entries in `schema_version` that this binary doesn't know about. A v0.3.0 binary opening a `state.db` last touched by v0.4.0 (which has migration 0005) reads the table, sees `0005_content_snapshots`, ignores it (because it's not in this binary's MIGRATIONS list), and proceeds to run queries against tables that have new columns it doesn't expect.

### Architecture

After the migration loop completes successfully, run one extra query:

```rust
// After the for loop in run_migrations, before returning Ok(()):
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."
            ),
        });
    }
}
```

This works because **migration version strings are sortable text** with the convention `NNNN_name`. Lexicographic ordering equals numerical ordering (`0001_xxx < 0002_xxx < 0010_xxx` only fails at 4-digit boundaries — we have 3 digits of headroom).

Exit code 2 (Config) — the operator needs to fix their setup (upgrade the binary).

### Why MAX(version) and not the table-row count

Counting rows breaks if migrations get re-numbered or backfilled. Sorting by version is stable as long as the convention holds. We document the convention in `migrations.rs`.

### Tests

| Test | What it asserts |
|---|---|
| `db_open_succeeds_on_known_schema` | opening a v0.3.0-shaped DB with the v0.3.1 binary works (lock columns get added by 0004) |
| `db_open_fails_fast_on_unknown_future_migration` | manually inserting `('0099_future', '...')` into `schema_version` then `Db::open()` returns `AppError::Config { code: "db_schema_too_new" }` |

---

## Fix 4 — agent-info + AGENTS.md content sync

### Problem statement

`src/commands/agent_info.rs` is a hand-written `serde_json::json!()` macro literal. v0.3.0 added `contact erase`, `broadcast resume`, `template render --raw`, the `sender_domain_verified` health check — none of these appear in the current manifest. The `flags` section claims a `--json` global flag exists when it does NOT (output is auto-detected via `IsTerminal`, no flag).

`AGENTS.md` says `**Version**: v0.2.0` and references the v0.2 rearchitecture phase plan. We're at v0.3.0 (about to be v0.3.1).

Both files are part of the binary's **public agent-facing contract**. Drift here is hostile to LLM agents driving the binary blind from a fresh `agent-info` call.

### Architecture

**Two passes, no automation.**

**Pass 1 — manual rewrite** of both files to reflect v0.3.1 reality:

`src/commands/agent_info.rs` updates:
- Add: `contact erase <email> --confirm`, `broadcast resume <id> [--force-unlock]`, `broadcast send <id> [--force-unlock]`, `template render <name> --raw`
- Update: `health` description to mention `sender_domain_verified` check
- Remove: the false `flags.--json` entry (replace with: `"global_flags": "none — output auto-detected via IsTerminal"`)
- Update: the `status` string from the v0.3.0 paragraph to a v0.3.1 paragraph mentioning the lock + timeout + schema check
- Add: `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 links" }`

`AGENTS.md` updates:
- `**Version**: v0.3.1` (not v0.2.0)
- Remove: the dead reference to `docs/plans/2026-04-08-phase-7-v0.2-rearchitecture.md` (it's the v0.2 phase plan, no longer current)
- Add: a one-paragraph "v0.3 hardening" section listing what production-grade now means (retries, transactions, suppression HashSet, complaint guards, GDPR erase, resume, lock, timeout, schema check)
- Add: pointer to `docs/plans/2026-04-09-phase-8-v0.3-production-grade-10k.md` and the upcoming v0.3.1 + v0.4 plans

**Pass 2 — drift regression test.** Add `tests/agent_info_drift_test.rs`:

```rust
//! Sanity check that agent-info exposes the v0.3.1 surface. This is 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 = [
        "contact erase",
        "broadcast resume",
        "broadcast send",
        "template render",
    ];
    for needle in must_have {
        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"
        );
    }

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

    // Check version field matches Cargo 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 be env!(\"CARGO_PKG_VERSION\")"
    );
}
```

This is a **partial** drift check, not exhaustive. Auto-generation from clap introspection is a v0.4+ refactor (see open question in v0.4 plan). For v0.3.1, this catches the four most likely drift cases.

### Tests

| Test | What it asserts |
|---|---|
| `agent_info_includes_v0_3_1_surface` | the 4 must-have command prefixes are present, the false `--json` flag is absent, version matches `CARGO_PKG_VERSION` |
| `agent_info_is_valid_json` | output parses as `serde_json::Value` (catches typos in the json! macro) |

---

## Migration impact

| Migration | Adds | When |
|---|---|---|
| 0001_initial | initial schema | already shipped |
| 0002_event_idempotency_and_kv | event dedup + kv table | already shipped |
| 0003_template_html_source | no-op for v0.2-shape DBs | already shipped |
| **0004_broadcast_locks** | `broadcast.locked_by_pid INTEGER NULL`, `broadcast.locked_at TEXT NULL` | **v0.3.1 (this spec)** |
| 0005_content_snapshots_and_revenue | broadcast snapshot columns + revenue table | v0.4 (next plan) |

The v0.4 plan must rename its migration from 0004 to 0005. The session-5 handoff already used "0004" generically; we're locking it down to mean "broadcast_locks".

## Doctrine alignment

- **Zero new runtime crates.** 14-crate budget intact. ✓
- **Single-tenant, local-first.** No network surface added. ✓
- **No interactive prompts.** `--force-unlock` is a flag, not a prompt. ✓
- **Semantic exit codes.** Lock-held = exit 1 (Transient), schema-too-new = exit 2 (Config), force-unlock-without-arg = exit 3 (BadInput). ✓
- **Self-describing via agent-info.** All new flags + env vars get manifest entries. ✓
- **Layer-2 positioning.** No changes to the email-cli boundary doctrine. ✓
- **TDD.** Every fix has tests written first. ✓

## Out of scope (do NOT do in v0.3.1)

These tempted-but-deferred items belong in v0.4 or later:

- **Per-broadcast lock with operator visibility in `broadcast show`.** v0.3.1 stores the lock columns; surfacing them in `broadcast show` is a v0.4 polish item.
- **Auto-generation of agent-info from clap introspection.** v0.4 refactor candidate.
- **PRAGMA integrity_check at startup.** Belongs in a `db check` subcommand, v0.5 territory.
- **Full audit log table.** v0.5 territory (GDPR Article 30).
- **Concurrent send guard for `broadcast preview`.** Preview is single-recipient by design and doesn't move broadcast status. Out of scope.
- **Concurrent send guard for `webhook poll`.** Different command, different pattern, separate fix if needed.
- **Per-call timeout overrides** (vs the global env var). v0.4 if anyone asks.
- **Force-unlock requires `--confirm`.** No — `--force-unlock` is itself the consent gesture. Adding `--confirm` on top would be ceremony for ceremony's sake.

## Effort + tests

| # | Fix | Files touched | Tests added |
|---|---|---|---|
| 1 | broadcast lock CAS + migration 0004 + `--force-unlock` flag | `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) | 6 |
| 2 | email-cli subprocess timeout helper | `src/email_cli.rs`, `tests/email_cli_timeout_test.rs` (new) | 3 |
| 3 | schema version safety check | `src/db/mod.rs`, `tests/db_schema_check_test.rs` (new) | 2 |
| 4 | agent-info + AGENTS.md sync + drift test | `src/commands/agent_info.rs`, `AGENTS.md`, `tests/agent_info_drift_test.rs` (new) | 2 |
| Release | bump Cargo.toml → 0.3.1, README badge, agent-info status string, smoke test, tag | `Cargo.toml`, `README.md`, `src/commands/agent_info.rs` | smoke (manual) |
| **Total** | | **9 source files modified, 4 test files created** | **+13 tests (177 → 190)** |

Effort: ~1.5 days inline TDD execution.

## Release task

Per project doctrine, every tag goes through a real-Resend smoke test. v0.3.1 keeps the existing 13-step v0.3.0 paperfoot.com flow and adds 2 new steps:

| Step | New for v0.3.1 |
|---|---|
| 14 | Concurrent send: in two shells, run `broadcast send 1` simultaneously. Verify exactly one succeeds, the other exits 1 with `broadcast_lock_held`. |
| 15 | Schema version check: copy `state.db` to `/tmp/test-future.db`, manually `INSERT INTO schema_version VALUES ('9999_future', '2026-04-09T00:00:00Z')`, then `mailing-list-cli --db-path /tmp/test-future.db health` and verify exit 2 + `db_schema_too_new`. *(Actually `--db-path` doesn't exist yet — the test uses MLC_DB_PATH env var if available, or hand-edit + revert. If neither works, the test reduces to the unit test only.)* |

The timeout fix is hard to smoke-test against real Resend (would need to inject a network stall), so it's covered by the integration tests + the existing retry path's smoke coverage.

## Estimated test count progression

| Version | Tests | Δ |
|---|---|---|
| v0.3.0 (current main) | 177 | — |
| v0.3.1 (this spec) | **190** | +13 |
| v0.4.0 (next plan, ~17 items) | ~225 | +35 |

## What this spec deliberately does NOT promise

- It does not promise `broadcast show` will display lock state. (v0.4 polish.)
- It does not promise PID-based liveness check (`kill -0`). (Time-based stale only.)
- It does not promise lock columns in `broadcast list` output. (v0.4 polish.)
- It does not promise an operator UI for `db schema-check` as a subcommand. (Internal-only check at startup.)
- It does not promise the `MLC_DB_PATH` env var if it doesn't already exist. (Use whatever path mechanism is current; if none, smoke step 15 reduces to the unit test.)

---

## Self-review

**1. Placeholder scan:** No TBD/TODO/incomplete sections. Step 15 of the smoke test has a parenthetical caveat about `--db-path`, which is documented as a known reduction not a placeholder. ✓

**2. Internal consistency:** Migration 0004 = locks (this spec), 0005 = v0.4 content snapshots. The v0.4 handoff used "0004" generically and is now superseded by this spec's renumbering. The session-5 handoff will need a one-line update when v0.3.1 ships, captured in the v0.3.1 release commit. ✓

**3. Scope check:** 4 fixes + 1 release task. Single implementation plan, single release. ✓

**4. Ambiguity check:** "Stale = 30 min" is hardcoded constant — explicit. "Default timeout = 120s" is hardcoded constant — explicit. Lock acquire predicate is documented as a 4-clause OR — explicit. ✓

**5. Type consistency:** `LockAcquireResult` is a single enum used by `broadcast_try_acquire_send_lock` and consumed by `pipeline.rs`. `Duration::from_secs(timeout_sec)` matches the existing `StdDuration` import in `email_cli.rs`. AppError variants match the existing `{code, message, suggestion}` shape. ✓

---

## Approval status

- [x] User explicitly approved all 4 default fixes via AskUserQuestion on 2026-04-09 ("Yes, all 4 — proceed")
- [x] User opted out of post-spec-write review pause (chose option 1, not option 2)
- [x] Spec self-review complete

**Next step:** Invoke `superpowers:writing-plans` to produce the TDD implementation plan.
