diff --git a/SESSION-M.3-TRANSCRIPT-2026-05-28T05-30--2026-05-28T05-58.md b/SESSION-M.3-TRANSCRIPT-2026-05-28T05-30--2026-05-28T05-58.md new file mode 100644 index 0000000..fda57e2 --- /dev/null +++ b/SESSION-M.3-TRANSCRIPT-2026-05-28T05-30--2026-05-28T05-58.md @@ -0,0 +1,311 @@ +# Session M.3 — Transcript + +> Parent: SESSION-M-TRANSCRIPT-2026-05-28T05-26--.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 M 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-M.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 ` 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-M.3-TRANSCRIPT-2026-05-28T05-30--2026-05-28T05-58.md`