# v0.3.2 Emergency Hardening (Round 2) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans for inline TDD execution.

**Goal:** Close the 3 remaining outage-class items GPT Pro identified on top of v0.3.1, plus document one doctrinal limitation in the v0.3.0 complaint/bounce guards. Ship as `v0.3.2` to GitHub + crates.io + Homebrew.

**Architecture:** 4 fixes against the v0.3.1 codebase. Zero new runtime crates. Migration 0005 = `broadcast_send_attempt`. Same trunk-based pattern as v0.3.1.

**Spec:** `docs/specs/2026-04-09-v0.3.2-emergency-hardening-round-2.md` (committed at `dcfb486`).

**Pre-release:** v0.3.1 stable, main, 189 tests passing.

---

## Task 1: F13.1 — Unsubscribe secret hard-fail (smallest, do first)

**Files:** `src/broadcast/pipeline.rs` (2 sites)

- [ ] Replace both `unwrap_or_else(|_| "mlc-unsubscribe-dev".to_string())` blocks with `.map_err(...)?` returning `AppError::Config { code: "missing_unsubscribe_secret", ... }`
- [ ] Add unit test in `src/broadcast/pipeline.rs` `#[cfg(test)] mod tests` (or in db tests if pipeline has no test mod): set up minimal broadcast, ensure `MLC_UNSUBSCRIBE_SECRET` is unset, call send_broadcast, assert error code
- [ ] Verify existing tests still pass (they all set the env var via smoke setup)
- [ ] Commit

---

## Task 2: F9.1 (revised) — Multi-profile health check + dead-field doc

**Files:** `src/email_cli.rs`, `src/commands/health.rs`, `src/commands/agent_info.rs`, `tests/support/stub_email_cli.sh`

- [ ] Update doc comment on `EmailCli::profile` field to clarify it's used only by `profile_test`
- [ ] Add new `EmailCli::profile_list()` method that calls `email-cli profile list` and returns the parsed profile names
- [ ] Add new health check in `src/commands/health.rs::run` named `email_cli_single_profile`:
  - Calls `cli.profile_list()`
  - 0 profiles → `state: fail`
  - 1 profile → `state: ok`
  - >1 profiles → `state: ok` with message "warning: N profiles configured in email-cli; mailing-list-cli has no per-command profile selection. Verify the active profile is correct."
- [ ] Extend `tests/support/stub_email_cli.sh` to handle `profile list` based on `STUB_EMAIL_CLI_PROFILE_COUNT` env var (returns N profiles)
- [ ] Add unit test for the multi-profile case via the existing health command tests
- [ ] Update `agent-info` to include a `known_limitations` array with the F9.1 + F3.2 entries
- [ ] Update `agent-info` `health` description to mention the new check
- [ ] Commit

---

## Task 3: F2.1 — broadcast_send_attempt write-ahead table (the big one)

**Files:** `src/db/migrations.rs`, `src/db/mod.rs`, `src/broadcast/pipeline.rs`

### Step 3.1: Migration 0005

Add to `src/db/migrations.rs` after `0004_broadcast_locks`:

```rust
(
    "0005_broadcast_send_attempt",
    r#"
    CREATE TABLE broadcast_send_attempt (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        broadcast_id INTEGER NOT NULL REFERENCES broadcast(id) ON DELETE CASCADE,
        chunk_index INTEGER NOT NULL,
        request_sha256 TEXT NOT NULL,
        batch_file_path TEXT NOT NULL,
        state TEXT NOT NULL CHECK(state IN ('prepared', 'esp_acked', 'applied', 'failed')),
        esp_response_json TEXT,
        created_at TEXT NOT NULL,
        updated_at TEXT NOT NULL,
        UNIQUE (broadcast_id, chunk_index, request_sha256)
    );
    CREATE INDEX idx_send_attempt_broadcast ON broadcast_send_attempt(broadcast_id);
    CREATE INDEX idx_send_attempt_state ON broadcast_send_attempt(broadcast_id, state);
    "#,
),
```

### Step 3.2: DB methods

Add to `src/db/mod.rs`:

- `pub fn broadcast_send_attempt_insert(&self, broadcast_id, chunk_index, request_sha256, batch_file_path) -> Result<i64, AppError>`
  - Inserts a row with state='prepared', returns the row id
  - On UNIQUE conflict, return the existing row's id (idempotent retry)
- `pub fn broadcast_send_attempt_mark_esp_acked(&self, attempt_id, response_json) -> Result<(), AppError>`
- `pub fn broadcast_send_attempt_mark_applied(&self, attempt_id) -> Result<(), AppError>`
- `pub fn broadcast_send_attempt_mark_failed(&self, attempt_id) -> Result<(), AppError>`
- `pub fn broadcast_send_attempts_in_state(&self, broadcast_id, state: &str) -> Result<Vec<SendAttempt>, AppError>` (returns rows for reconcile/indeterminate detection)

Plus a struct:
```rust
pub struct SendAttempt {
    pub id: i64,
    pub broadcast_id: i64,
    pub chunk_index: i64,
    pub request_sha256: String,
    pub batch_file_path: String,
    pub state: String,
    pub esp_response_json: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}
```

### Step 3.3: SHA-256 helper

Add to `src/broadcast/pipeline.rs`:
```rust
fn sha256_hex(bytes: &[u8]) -> String {
    use sha2::{Digest, Sha256};
    let mut h = Sha256::new();
    h.update(bytes);
    format!("{:x}", h.finalize())
}
```

`sha2` is already in Cargo.toml. No new dep.

### Step 3.4: Pipeline integration

In `src/broadcast/pipeline.rs::send_broadcast`:

**At the start of the chunk loop**, BEFORE any chunk processing:
```rust
// v0.3.2 F2.1: reconcile any esp_acked attempts from a previous run.
{
    let acked = db.broadcast_send_attempts_in_state(id, "esp_acked")?;
    for attempt in acked {
        eprintln!("reconciling chunk {} from previous run (esp_acked → applied)", attempt.chunk_index);
        // Parse the stored response and apply it locally
        let response: serde_json::Value = serde_json::from_str(
            attempt.esp_response_json.as_deref().unwrap_or("{}")
        ).map_err(|e| AppError::Transient {
            code: "send_attempt_response_parse".into(),
            message: format!("could not parse stored esp_response_json for attempt {}: {e}", attempt.id),
            suggestion: "Inspect broadcast_send_attempt manually".into(),
        })?;
        // Extract the items array (same shape as batch_send response)
        let items = response.get("data").and_then(|d| d.get("data")).and_then(|d| d.as_array())
            .or_else(|| response.get("data").and_then(|d| d.as_array()));
        // ...apply via existing recipient UPDATE logic...
        // Then mark applied
        db.broadcast_send_attempt_mark_applied(attempt.id)?;
    }

    // v0.3.2 F2.1: refuse to proceed if any prepared attempts exist (indeterminate state).
    let prepared = db.broadcast_send_attempts_in_state(id, "prepared")?;
    if !prepared.is_empty() {
        let chunk_indices: Vec<String> = prepared.iter().map(|a| a.chunk_index.to_string()).collect();
        return Err(AppError::Transient {
            code: "broadcast_attempt_indeterminate".into(),
            message: format!(
                "broadcast {id} has {} chunk(s) in indeterminate state from a previous run (chunks: {}). The previous process crashed between starting an email-cli call and recording the response — we cannot tell if Resend received the chunk or not.",
                prepared.len(),
                chunk_indices.join(", ")
            ),
            suggestion: format!(
                "Inspect Resend dashboard or `email-cli email list` for broadcast tag `broadcast_id={id}` to determine if the chunk(s) shipped. Then either: (a) manually mark the attempt 'applied' and re-run, or (b) manually mark the attempt 'failed' and re-run to retry the chunk. SQL: UPDATE broadcast_send_attempt SET state='applied' WHERE id IN (...)"
            ),
        });
    }
}
```

**For each chunk in the existing chunk loop**, around the `cli.batch_send(...)` call:
```rust
// Compute hash of batch file for the attempt row
let batch_bytes = std::fs::read(&batch_path).map_err(|e| AppError::Transient {
    code: "batch_file_read".into(),
    message: format!("could not read batch file: {e}"),
    suggestion: "Retry the command".into(),
})?;
let request_sha256 = sha256_hex(&batch_bytes);
let attempt_id = db.broadcast_send_attempt_insert(
    id,
    chunk_idx as i64,
    &request_sha256,
    batch_path.to_str().unwrap_or(""),
)?;

match cli.batch_send(&batch_path, &recipients_in_order) {
    Ok(results) => {
        // Mark esp_acked with the raw response stored.
        let raw_response = serde_json::to_string(&serde_json::json!({
            "items": results.iter().map(|(email, id)| serde_json::json!({"to": email, "id": id})).collect::<Vec<_>>()
        })).unwrap_or_else(|_| "{}".to_string());
        db.broadcast_send_attempt_mark_esp_acked(attempt_id, &raw_response)?;

        // Existing recipient UPDATE transaction
        let tx = db.conn.transaction()...;
        for (email, resend_id) in &results {
            // ... existing UPDATE ...
        }
        tx.commit()?;

        // Mark attempt applied
        db.broadcast_send_attempt_mark_applied(attempt_id)?;
    }
    Err(e) => {
        let _ = db.broadcast_send_attempt_mark_failed(attempt_id);
        // ... existing error logging ...
    }
}
```

### Step 3.5: Tests

Add to `src/db/mod.rs` test mod:
- `attempt_table_created_by_migration_0005`
- `attempt_insert_returns_existing_id_on_duplicate`
- `attempt_state_transitions_prepared_to_acked_to_applied`
- `attempts_in_state_filters_correctly`

Plus integration via the smoke test (manual).

- [ ] Run all tests
- [ ] cargo fmt + clippy
- [ ] Commit

---

## Task 4: F3.2 doctrinal note (no code, doc only)

**Files:** `src/db/mod.rs` (docstring on `historical_send_rates`), `AGENTS.md`, `src/commands/agent_info.rs` (already updated in Task 2 with `known_limitations`)

- [ ] Add a comment block on `historical_send_rates` explaining the limitation
- [ ] Add a one-line caveat to AGENTS.md "Production hardening" section
- [ ] Commit

---

## Release Task: bump → smoke → tag → push → crates → homebrew → handoff

- [ ] Bump Cargo.toml 0.3.1 → 0.3.2
- [ ] Update README.md badge
- [ ] Update agent-info status string
- [ ] cargo fmt + clippy + test (expect ~198 tests)
- [ ] cargo build --release; verify version
- [ ] Smoke test on paperfoot.com:
  - Steps 1-15 (existing v0.3.1 flow)
  - Step 16: F13.1 — unset MLC_UNSUBSCRIBE_SECRET, send → exit 2
  - Step 17: F2.1 reconcile — inject esp_acked attempt, send/resume → reconciled, sent
  - Step 18: F2.1 indeterminate — inject prepared attempt, send → exit 1 broadcast_attempt_indeterminate
  - Step 19: F9.1 — stub returns 2 profiles, health → ok with warning
- [ ] Commit version bump
- [ ] Tag v0.3.2
- [ ] Push main + tag
- [ ] cargo publish (second publish ever for this crate)
- [ ] Compute SHA256 of v0.3.2 tarball
- [ ] Update Formula/mailing-list-cli.rb in homebrew-tap (url + sha256), commit, push
- [ ] Verify CI on tag
- [ ] Write session-7 handoff
- [ ] Commit + push handoff
