# Session 0013.3 — Transcript

> Parent: SESSION-0013.0-TRANSCRIPT-2026-05-28T05-26--<end>.md
>
> Date: 2026-05-28 (PST)
> Start: 2026-05-28T05-30 PST
> End:   2026-05-28T05-58 PST
> Goal: Inventory OHM's email + Gitea-webhook posture (roadmap items
>       #18 + #20, bundled) and draft a hardening runbook plus a
>       framework-side template-hygiene proposal. Read-only only —
>       no DNS edits, no SMTP-provider changes, no rfc-app code,
>       no secret rotations, no test emails.
>
> Outcome: **Two docs written and left on disk for the driver at
> `~/git/ohm-infra/EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` and
> `~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md`. Five §19.2
> candidates surfaced. Three high-impact deployment-side gestures
> and three high-impact framework-side changes prioritized for the
> operator. Two surprising findings: SMTP_HOST is missing from the
> live flotilla overlay (gap to disambiguate), and a stale Gitea
> webhook on `wiggleverse/meta` still points at the deprovisioned
> `http://rfc.wiggleverse.org`.**

---

## Pre-session state

- Parent: Session 0013.0 driver, dispatched M.3 with scope = inventory
  for items #18 + #20.
- `~/git/ohm-infra/` is a plain directory (not a git repo) — driver
  noted this; M.3 leaves docs on disk for the driver to handle
  publishing.
- `rfc-app` at v0.17.0 on disk; live OHM running v0.17.0 per
  `flotilla pin check ohm-rfc-app` (deploys.id=21).
- Hard constraints from dispatch: no operator-led gestures, no
  secret bytes in transcript, read-only inventory only.

---

## Turn-by-turn arc

### Arc 1 — DNS inventory + roadmap read

Ran `dig +short TXT wiggleverse.org`, `dig +short TXT
_dmarc.wiggleverse.org`, `dig +short MX wiggleverse.org`, and
probed common DKIM selectors (`default`, `mail`, `s1`, `s2`,
`selector1`, `k1`, `mandrill`, `smtp`, `google`, etc.).

Findings:

- **SPF:** `"v=spf1 include:_spf.google.com ~all"` (soft-fail).
- **DMARC:** `"v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org"`
  (monitoring-only, no enforcement, no `pct=`, no `sp=`).
- **DKIM:** present at `google._domainkey.wiggleverse.org` — a
  2048-bit RSA Workspace key. All other probed selectors empty.
- **MX:** `aspmx.l.google.com` family — Google Workspace mailbox.

Read roadmap items #18 (l.362-403) and #20 (l.458-547) from
`~/projects/wiggleverse/ohm-rfc/ROADMAP.md`. Both confirm they
should ship together because the same DNS edits serve both. #20
names BIMI as a follow-on after DMARC reaches `p=reject`. #18 names
"refresh webhook-shared-secret + replay-resistance + ingress
restriction" as framework-side work.

### Arc 2 — Flotilla overlay + secret inventory

```
.venv/bin/ohm-rfc-app-flotilla overlay show ohm-rfc-app
.venv/bin/ohm-rfc-app-flotilla secret list ohm-rfc-app
```

Overlay reveals:

- `SMTP_PORT=587`, `SMTP_STARTTLS=1`, `EMAIL_FROM=notifications@wiggleverse.org`,
  `EMAIL_FROM_NAME=Wiggleverse` — present.
- `SMTP_HOST` — **missing**. `SMTP_USER` — **missing**.
- Secret refs include `GITEA_WEBHOOK_SECRET` bound to
  `wiggleverse-ohm/ohm-rfc-app-gitea-webhook-secret@latest`.

Greps:

- `~/projects/wiggleverse/ohm-rfc-app-flotilla/scripts/register-ohm.sh:40`
  sets `SMTP_HOST=smtp-relay.gmail.com` — but the live overlay
  doesn't have it. Either the bootstrap script wasn't re-run after
  a reshape, or SMTP env lives out-of-band on the VM (e.g.,
  systemd-unit override). This is a finding for the runbook —
  operator must disambiguate via `gcloud ssh` + `systemctl show
  ohm-app.service`.

Confirmed provider = **Google Workspace SMTP relay** (the bootstrap
script + the `google._domainkey` DKIM + the MX records align).

### Arc 3 — Framework email-helper read

Read all of:

- `~/git/rfc-app/backend/app/email.py` — §15.4 notification mailer
  (`_deliver`, `_send_one`, `_send_bundle`, `make_unsubscribe_url`).
- `~/git/rfc-app/backend/app/email_otc.py` — v0.7.0 OTC helper.
- `~/git/rfc-app/backend/app/email_invite.py` — v0.17.0 admin-create
  invite helper.

Built header-presence matrix across all four kinds (OTC / invite /
watcher-single / watcher-bundle):

- `Date`, `Message-ID`, `Auto-Submitted`, `List-Unsubscribe`,
  `List-Unsubscribe-Post`, `multipart/alternative` — **all
  absent** on all four paths. Google's SMTP relay adds `Date` /
  `Message-ID` during ingestion, but the framework doesn't control
  the format.
- Plain-text bodies only. No HTML variant anywhere.
- `_deliver` (`email.py:308`) embeds unsubscribe URL in body footer
  but doesn't set the header.

Stdlib spot-check: `python3 -c "from email.message import
EmailMessage; …"` confirms `set_content` doesn't auto-populate
`Date` or `Message-ID`. Inspected `smtplib.SMTP.send_message`
source — also doesn't set them.

### Arc 4 — Gitea-webhook inventory

Found the handler at `~/git/rfc-app/backend/app/webhooks.py` (78
lines). HMAC-SHA-256 over the raw body, `hmac.compare_digest` for
constant-time compare. **But:** `webhooks.py:43` gates verification
behind `if config.webhook_secret:` — empty secret = no auth. Per
`config.py:75`, `webhook_secret` is `_optional`. Insecure default.

Pulled `gitea-bot-token` from GCP Secret Manager into a shell var
(`GITEA_TOKEN=$(gcloud secrets versions access latest …)`), used
it for read-only `curl` calls against the Gitea API, immediately
`unset` after each call. Never echoed the value to stdout or any
file.

Hook inventory across the wiggleverse org:

| Repo | Hook | Notes |
|---|---|---|
| `wiggleverse/ohm-meta` | `https://ohm.wiggleverse.org/api/webhooks/gitea` | active, 12 events — correct |
| `wiggleverse/meta` | `http://rfc.wiggleverse.org/api/webhooks/gitea` | **active, plain HTTP, deprovisioned domain** |
| `wiggleverse/rfc-0001-human` | (none) | the only published RFC has no hook |
| `ben.stull/ohm-rfc` | (bot 403) | can't inventory, operator self-checks |

The org-level hooks endpoint
(`/api/v1/orgs/wiggleverse/hooks`) returned `[]` — no org-wide
hooks, all are repo-level.

Gitea's API exposes hook `config` keys = `['url', 'content_type']`
— it does NOT return the configured `secret` field, so I can't
confirm from the API side that the secret matches what OHM has.
But OHM's cache is current and webhook receiver verifies, so the
secrets must align (otherwise everything would 401).

### Arc 5 — Bounce-handling + observability gap

Read `~/git/rfc-app/backend/app/api_notifications.py:471-502` —
`/api/webhooks/email-bounce` exists, gated on optional
`WEBHOOK_EMAIL_BOUNCE_SECRET` per SPEC.md §3667 Slice 8 settlement.
But **no provider is wired to POST here** — Google Workspace SMTP
relay doesn't natively webhook bounces (it bounces back to the
sender mailbox). So today: hard bounces silently retry every
`flush_pending` pass.

All four send helpers catch `Exception`, log via `log.exception`,
return `False`. No DB write of the failure, no admin notification.
The operator has zero visibility into send failures without
grepping VM logs.

### Arc 6 — Draft the two docs

Wrote `EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` (~700 lines) with
sections: current posture / hardened shape / operator-execution
sequence / framework-vs-deployment split / §19.2 candidates.

Wrote `RFC-APP-EMAIL-HYGIENE-PROPOSAL.md` (~280 lines) with the
framework-side minor proposal: `build_envelope` shared helper,
webhook handler tightening (mandatory secret), `outbound_emails`
audit table, bounce-correlation, suggested 5-slice shipping plan.

Cross-referenced both docs at each end. Both name files + line
numbers for every framework citation.

### Arc 7 — Transcript

This file. ohm-infra is not a git repo (driver's check confirmed
pre-session), so no commit; driver decides whether to add a remote
or just publish the transcripts via `publish-transcript.sh`.

---

## Cut state (end of subsession)

| Surface | State |
|---|---|
| `~/git/ohm-infra/EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` | written, ~21 KB |
| `~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md` | written, ~9 KB |
| `~/git/ohm-infra/SESSION-0013.3-TRANSCRIPT-…md` | this file |
| OHM live | unchanged (v0.17.0, deploys.id=21) |
| DNS / Gitea / SMTP provider | unchanged (read-only inventory only) |
| flotilla overlay / secrets | unchanged (read-only `show` + `list` only) |

---

## §19.2 candidates surfaced

1. **rfc-app: `GITEA_WEBHOOK_SECRET` should be required, not optional.**
   Today the framework silently accepts unsigned POSTs when the env
   var is empty. Recommend `_required` with documented
   `RFC_APP_INSECURE_WEBHOOKS=1` dev-bypass.

2. **rfc-app: outbound-email observability seam.** Add
   `outbound_emails` table + write on every send. Today failures
   log-and-forget; the operator has no audit trail.

3. **flotilla: `overlay show` doesn't surface "expected but missing"
   keys.** The SMTP_HOST gap is invisible without grepping
   register-ohm.sh + framework source side-by-side. A per-deployment
   "required keys" manifest + `flotilla overlay validate` verb would
   catch this class of regression.

4. **flotilla: lacks a webhook-inventory verb.** Listing / diffing
   the webhooks on the org's Gitea repos against the deployment's
   expected set is a recurring operator gesture. A `flotilla webhook
   list / check <deployment>` verb would formalize it. Generic
   enough for a future `flotilla-core` extraction.

5. **rfc-app: multiple From-identity references should derive from
   one source.** `EMAIL_FROM` is read in `email.py:82`,
   `email_otc.py` via cfg, `email_invite.py` via cfg, plus implied
   by DMARC alignment + Workspace allowed-senders. A spec section
   binding all surfaces to one source would prevent skew.

---

## What lands on the driver's plate

1. **Decide where to put the two docs.** `~/git/ohm-infra/` is not
   a git repo. Options: (a) leave as plain files, publish only via
   `publish-transcript.sh` (which today only handles transcripts);
   (b) git-init `~/git/ohm-infra/` and add a Gitea remote so
   ohm-infra becomes a versioned doc repo; (c) move the two docs
   into `~/projects/wiggleverse/ohm-rfc/` under a `docs/` subdir
   since they're OHM-deployment-specific. Driver decides.

2. **Schedule the disambiguation gesture.** The SMTP_HOST gap is
   the most urgent finding — if the framework is actually falling
   to stdout-fallback, OHM's emails (OTC, invites, notifications,
   beta-access) aren't leaving the VM. Operator runs `gcloud ssh
   ohm-app` + `systemctl show ohm-app.service | grep
   Environment` next session.

3. **Schedule the stale-hook deletion.** Five-second gesture; the
   hook on `wiggleverse/meta` pointing at deprovisioned
   `http://rfc.wiggleverse.org/api/webhooks/gitea` should go.

4. **Decide on the From-identity question.** `ohm@wiggleverse.org`
   (roadmap's suggestion) or keep `notifications@wiggleverse.org`?
   Pinning this is a prerequisite for the framework-side proposal's
   `Reply-To` decision.

5. **Slot the rfc-app email-hygiene minor.** Per the proposal, v0.18.0
   or v0.19.0; 5 slices; mostly backward-compatible. Operator
   decides whether to bundle with another minor (e.g., #19's
   CONTRIBUTING.md docs) or ship solo.

6. **Confirm `ben.stull/ohm-rfc`'s hooks.** Bot lacks admin so M.3
   couldn't inventory. Operator self-checks via Gitea web UI.

---

## What the driver needs to know (report-back summary)

**DNS posture (literal values):**
- SPF: `v=spf1 include:_spf.google.com ~all` (soft-fail).
- DMARC: `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org`
  (monitoring-only).
- DKIM: present at `google._domainkey.wiggleverse.org` (2048-bit
  Workspace key).

**SMTP provider:** Google Workspace SMTP relay
(`smtp-relay.gmail.com`) — inferred from bootstrap script + DKIM
+ MX. **But SMTP_HOST is missing from the live flotilla overlay**
— either the bootstrap was reshaped and these were dropped, or
they're set on the VM out-of-band. Operator must disambiguate.

**Top 3 deployment-side hardening gestures (priority order):**
1. Delete stale `wiggleverse/meta` hook (5 seconds, removes
   plain-HTTP-to-deprovisioned-domain liability).
2. Disambiguate SMTP_HOST gap (5 minutes, confirms email actually
   leaves the VM).
3. DMARC Phase A: add `pct=100; sp=quarantine; adkim=s; aspf=s` to
   the existing `p=none` record; wait 1 week; flip to `p=quarantine`.

**Top 3 framework-side changes (rfc-app minor, target v0.18.0):**
1. `build_envelope` shared helper adds `Date`, `Message-ID`,
   `Auto-Submitted`, `List-Unsubscribe`, `List-Unsubscribe-Post`,
   and `multipart/alternative` to all four email paths.
2. Make `GITEA_WEBHOOK_SECRET` required (not silently optional).
3. `outbound_emails` audit table + bounce-correlation hook.

**Webhook posture:** signature verification is correct
(`hmac.compare_digest`, HMAC-SHA-256 over raw body) **when the
secret is set**. On OHM the secret is bound. Two open issues:
(a) framework's silent-accept-when-unset insecure default; (b)
stale `wiggleverse/meta` hook on a deprovisioned domain.

**§19.2 candidates surfaced:** 5 (listed above).

**Doc paths:**
- `/Users/benstull/git/ohm-infra/EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md`
- `/Users/benstull/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md`
- `/Users/benstull/git/ohm-infra/SESSION-0013.3-TRANSCRIPT-2026-05-28T05-30--2026-05-28T05-58.md`
