Restructures the flat repo into one folder per session, with a top-level README.md (the "about sessions" page, also rendered at /docs/sessions/about by rfc-app v0.19.0+) and a sessions.json title manifest keyed by 4-digit NNNN. Filenames inside each folder retain the full SESSION-NNNN.M-TRANSCRIPT-… form (no trim of the SESSION- prefix). Legacy letter-form transcripts, if any are still at the root, are not moved — the folder convention applies to the numeric form only. Session 0017.0; see SESSION-PROTOCOL.md §1 amendment for the binding shape.
16 KiB
Session N.1 — Transcript
Parent: SESSION-N-TRANSCRIPT-2026-05-28T07-01--.md Date: 2026-05-28 (PST) Start: 2026-05-28T07-08 PST End: 2026-05-28T07-40 PST Goal: Ship rfc-app v0.18.0 — email + webhook hygiene proposal (Wave 7 candidate #3; OHM roadmap items #18 + #20). Outcome: All 5 slices shipped + merged + tagged + pushed to both remotes; ohm-rfc pin bumped to 0.18.0; 295 tests passing (was 252 pre-release).
Pre-session state
- rfc-app:
mainat1456c8b(v0.17.0), working tree clean, no uncommitted changes. Remotes:origin=https://git.wiggleverse.org/ben.stull/rfc-app.git(canonical),benstull=git@git.benstull.org:benstull/rfc-app.git(mirror). Test baseline (run before any work):PYTHONPATH=. .venv/bin/pytest -q→ 252 passed in 53.29s. - ohm-rfc:
mainat8cce148,.rfc-app-version = 0.17.0, remote =ssh://git@git.wiggleverse.org:2222/ben/ohm-rfc.git(canonical only — no mirror configured, matches the prompt's expectation). - Backend dev env: Python venv at
/Users/benstull/git/rfc-app/backend/.venv. Tests needPYTHONPATH=.(the prompt's examplepytest backend/tests/was one cwd-level off; backend tests live underbackend/tests/and importfrom app import ...socd backend && PYTHONPATH=. pytestis the working invocation).
Finding 1 — remote name mismatch with the prompt
The prompt said push to canonical and mirror. Actual remote
names in this repo are origin (canonical) and benstull
(mirror). Proceeded with the actual names; final pushes hit both
remotes successfully. Surfacing for the driver's transcript — no
action needed beyond the rename if the prompt's convention is
intended.
Turn-by-turn arc
Arc 1 — Required reading + repo survey
Read in order:
RFC-APP-EMAIL-HYGIENE-PROPOSAL.md— the binding spec, three surfaces (template hygiene, webhook tightening, observability), 5-slice plan, four open questions.SESSION-PROTOCOL.md— §5 (subagent transcripts) is the binding section for this subsession.~/git/rfc-app/CLAUDE.md— VERSION + frontend/package.json are the canonical version pins; SemVer pre-1.0 minor can break.- Looked at
email_otc.py,email_invite.py,email.py,webhooks.py,config.py,invites.py,api_notifications.py,db.py, the migrations directory (next slot was 020), test fixturetmp_envintest_propose_vertical.py, and a sample v0.17.0 CHANGELOG entry for shape.
Open question #4 settled: invites.py (per-RFC invite,
v0.16.0) does NOT have its own send path — it calls into
email_invite.py's send_invite_email helper. So Slice 2's
single migration of email_invite.py covers both admin-create
and per-RFC invite paths automatically.
Open question #3 settled: the dev-fallback path DOES write to
outbound_emails with status='deferred' per the proposal's
recommendation. Tests rely on this (no SMTP_HOST in CI/tests).
Open questions #1, #2 — operator-territory, not framework. Left for the operator's next move.
Arc 2 — Slice 1: build_envelope helper + unit tests
Created backend/app/email_envelope.py with a single
build_envelope(...) function. Key decisions:
msgid_domaindefaults to the @-domain offrom_address(so Message-IDs align with the sending domain by default); falls back to"localhost"if from_address has no@(defensive).Auto-Submitted: auto-generatedset by default (is_transactional=True) — every kind of mail rfc-app sends is transactional in the RFC 3834 sense.List-Unsubscribe-Post: List-Unsubscribe=One-Clickonly emitted when anunsubscribe_urlis present (mailto-only callers don't get it).body_html=None→ single-part text/plain; html-present → multipart/alternative with text/plain FIRST per RFC 2046.
15 unit tests in test_email_envelope.py covering: always-present
headers; Auto-Submitted toggle; the three List-Unsubscribe shapes
(none / mailto-only / full one-click); plain vs multipart bodies;
Message-ID domain default + override + fallback.
After Slice 1: 267 passed (252 + 15). Committed as 92059f3.
Arc 3 — Slice 2: migrate send paths + POST unsubscribe
Migrated email_otc.py, email_invite.py, email._deliver,
email._send_bundle, and (out of scope per proposal but a
natural sibling) digest.py to call build_envelope.
Per-kind unsubscribe matrix:
- OTC: no
List-Unsubscribe(per proposal tradeoff discussion — the recipient explicitly requested the code). - Invite (admin or per-RFC):
List-Unsubscribe: <mailto:…>only. No URL form because the invitee isn't a user yet, so no per-user opt-out row exists. - Notification (
_send_one): full one-click — mailto + signed URL +List-Unsubscribe-Post. - Bundle (
_send_bundle) + Digest: same as notification, BUT uses a new syntheticallcategory instead of any single per-category flag. The bundle/digest spans multiple categories, so a per-category opt-out wouldn't honor user intent. Addedall→email_opt_out_all = 1to the_CATEGORY_COLUMNmap inapi_notifications.py.
EmailConfig gained unsubscribe_mailto (env:
EMAIL_UNSUBSCRIBE_MAILTO, default = EMAIL_FROM). Tested via
test_invite_envelope_respects_email_unsubscribe_mailto_override.
Added POST /api/email/unsubscribe as the RFC 8058 receiver. Its
body is hint-only — the token signature carries the authority.
Returns {ok, category}.
The _SENT test buffer now also carries envelope["message"]
(the constructed EmailMessage) so new tests can assert on
headers directly while preserving the legacy to/from/
subject/body keys for backward compatibility.
Added 10 integration tests:
test_otc_vertical.py: 2 (no List-Unsubscribe; headers present).test_admin_create_user_invite_vertical.py: 3 (headers; mailto- only; respects override).test_notifications_vertical.py: 5 (full one-click on notification; POST endpoint flips per-category;allvia POST;allvia GET; POST refuses invalid token).
Friction in Arc 3: my first notification-header test failed
with "watcher notification did not fire" — I'd forgotten to call
provision_user_row(user_id=1, login="ben", role="owner") and to
pass email="alice@test" to sign_in_as. Once both were added
the test passed. No code change needed — just test setup.
After Slice 2: 277 passed (267 + 10). Committed as e9fdc47.
Arc 4 — Slice 3: webhook tightening
config.py: read RFC_APP_INSECURE_WEBHOOKS first; if 1, call
_optional("GITEA_WEBHOOK_SECRET") (empty allowed); otherwise
call _required("GITEA_WEBHOOK_SECRET") (raises if empty). This
is the startup-loud-failure shape the proposal targets.
webhooks.py: defense-in-depth at request time — if
config.webhook_secret is empty AND the bypass isn't set, 500
with "Webhook receiver misconfigured." If the bypass IS set, log
a loud per-request warning. Unknown-repo branch gains an INFO
log line ("hook may be on a fork or stale").
tmp_env fixture in test_propose_vertical.py: was setting
GITEA_WEBHOOK_SECRET="". Updated to bind a non-empty fake
secret so all pre-v0.18.0 tests boot under the new mandatory-
secret regime. This was the right call — the alternative (set
RFC_APP_INSECURE_WEBHOOKS=1 in tests) would have left the
signature-verification path untested by default.
New test_webhooks_vertical.py (7 tests):
- 3 startup-time
load_config()tests (empty + no bypass raises; empty + bypass loads; bound secret loads). - 3 request-time signature tests (valid sig accepted; invalid sig 401; missing sig 401).
- 1 unknown-repo logging test using
caplog.
After Slice 3: 284 passed (277 + 7). Committed as d3daa97.
Arc 5 — Slice 4: outbound_emails table + admin endpoint
Migration 020_outbound_emails.sql with the table per the
proposal's spec (status enum includes 'bounced' for Slice 5).
email.record_outbound(...) is a sibling of _deliver. It
swallows RuntimeError (so pure-helper unit tests that don't
call db.init() don't crash) and any other exception (so an
audit write never breaks a send).
_deliver writes one row per send: status='deferred' when
SMTP_HOST is unset; status='sent' on SMTP success; status='failed'
with error=ExceptionClassName: message on exception. Threaded
kind and notification_id kwargs through _send_one →
_deliver so the row carries the FK back to notifications.
email_otc.py and email_invite.py were trickier — they don't
go through _deliver, they have their own SMTP send. Added
record_outbound calls at each return point (disabled / dev-
fallback / success / exception).
digest.py: gained kind='digest' on its _deliver call.
GET /api/admin/outbound-emails admin endpoint with three filters
(kind, status, to_address — case-insensitive). Returns
{items, has_more} matching the rest of the admin surface.
7 new tests in test_outbound_emails_vertical.py covering write
paths and admin endpoint.
After Slice 4: 291 passed (284 + 7). Committed as 281a844.
Arc 6 — Slice 5: bounce correlation
Extended BounceBody with optional message_id (max_length=1000).
When supplied, the handler looks up the matching outbound_emails
row and stamps status='bounced' + appends "bounce (<kind>)"
to error. Response shape gained correlated_id (nullable).
Intentional response-shape change: the response body now
includes correlated_id. One pre-existing test asserted on the
exact shape {ok: True, matched: False} —
test_bounce_webhook_refuses_unsigned_when_secret_configured in
test_e2e_smoke.py. Updated the assertion to include
correlated_id: None. Documented in the CHANGELOG entry.
4 new tests in test_outbound_emails_vertical.py:
- bounce with matching message_id stamps the row + flips global opt-out.
- bounce with unknown message_id logs but 200s (provider replay case).
- bounce without message_id falls back to legacy v1 behavior.
- bounced rows show up in admin endpoint with
?status=bounced.
After Slice 5: 295 passed (291 + 4 new + 1 updated). Committed as
4666c4a.
Arc 7 — Release gesture
VERSION0.17.0 → 0.18.0.frontend/package.json#version0.17.0 → 0.18.0.- CHANGELOG entry inserted above v0.17.0. Five-slice summary + Added / Changed / Tests / Migration / Upgrade-steps blocks. Upgrade-steps block per SPEC §20.4 covers: MUST GITEA_WEBHOOK_SECRET non-empty; MAY RFC_APP_INSECURE_WEBHOOKS=1 for local dev; MAY EMAIL_UNSUBSCRIBE_MAILTO override; MUST apply migration 020 (auto); MUST rebuild + restart; SHOULD run mail-tester.com probe.
- Release commit
0562d53. - Final test run: 295 passed in 59.11s. Green.
- Merged feature branch to main with
--no-ff→ merge commit31913b1. - Tagged
v0.18.0on31913b1. - Pushed
main,v0.18.0tag, and feature branch to BOTHorigin(canonical) andbenstull(mirror). All four pushes reported successful by the remote. - Bumped
/Users/benstull/projects/wiggleverse/ohm-rfc/.rfc-app-version0.17.0 → 0.18.0. Committed asc527aca, pushed to ohm-rfcorigin/main.
Arc 8 — Transcript (this file)
Wrote the transcript before returning to driver N, per Session-M lesson + §5 of the protocol.
Cut state
| Surface | State |
|---|---|
rfc-app main |
31913b1 (merge commit), tagged v0.18.0 at 31913b1. Pushed to origin + benstull. |
| rfc-app feature branch | feature/v0.18.0-email-webhook-hygiene at 0562d53. Pushed to both remotes. |
rfc-app VERSION + frontend/package.json |
0.18.0. |
ohm-rfc main |
c527aca, .rfc-app-version = 0.18.0. Pushed to origin only (no mirror; matches the project convention). |
| Tests | 295 passed, 0 failed (was 252 pre-release). Run time ~59s. |
| OHM live deploy | UNCHANGED — driver N drives the deploy gesture after the operator-led SMTP fix lands. |
| Slice | Commit | Status |
|---|---|---|
| 1: build_envelope helper + unit tests | 92059f3 |
✅ shipped |
| 2: migrate send paths + POST unsubscribe | e9fdc47 |
✅ shipped |
| 3: webhook tightening | d3daa97 |
✅ shipped |
| 4: outbound_emails + admin endpoint | 281a844 |
✅ shipped |
| 5: bounce correlation | 4666c4a |
✅ shipped |
| Release: VERSION + CHANGELOG + frontend/package.json | 0562d53 |
✅ shipped |
| Merge to main | 31913b1 |
✅ shipped |
Tag v0.18.0 |
at 31913b1 |
✅ pushed both remotes |
| ohm-rfc pin bump | c527aca |
✅ pushed |
§19.2 candidates surfaced
- Per-RFC invite path consolidation. Per the proposal's open
question #4: confirmed
invites.py(v0.16.0 per-RFC invite) usesemail_invite.py's helper, NOT its own send path. So Slice 2 covered it automatically. But the per-RFC invite and admin-create invite share the same template + From identity today; if a future minor needs them to diverge (e.g., per-RFC carries the RFC slug in its Reply-To), the split goes onemail_invite.pyand the per-RFC caller passes its own override kwargs. Track as a §19.2 candidate but only worth a session when an actual deployment asks for the divergence. - HTML alternative body templates. The proposal called out a
minimal HTML variant for OTC and a button-style HTML variant
for invites. v0.18.0 did NOT ship these — every send path
still passes only
body_plaintobuild_envelope. The helper supportsbody_html, so adding HTML variants is a follow-up minor with zero infrastructure work; just the template authoring. §19.2 candidate. - Sender Policy Framework + DKIM rotation rhythm. The
mail-tester.comSHOULD step in the upgrade-steps block surfaces this — once OHM runs the probe with real SMTP, any DKIM or SPF score gap drops onto the operator's plate, NOT the framework's. But if the framework wants to surface suspected DNS misalignment proactively (e.g., parse the bounce body for SPF-fail signals and log a hint), that's a future minor. §19.2 candidate. - Admin UI for
outbound_emails. Proposal explicitly deferred this; operator queries the endpoint with curl + jq for now. When a need arises (e.g., the admin user-management page wants to show "last email sent" inline per user), the table is already populated and indexed for it. - Remote-name mismatch with the prompt (already named in the
Finding above). The prompt's "canonical / mirror" convention
doesn't match this repo's
origin/benstullreality. Worth either (a) renaming the remotes to match, or (b) updating future driver/subagent prompts to say "the local origin / mirror config, whatever that is." Procedural §19.2 candidate.
What driver N needs to know
- All 5 slices shipped clean. v0.18.0 = full proposal scope. Tag + main + feature branch are all pushed to both remotes.
- 295 tests passing, 0 failed. The one test that needed an
intentional update (
test_e2e_smoke.test_bounce_webhook_refuses_unsigned_when_secret_configured) was updated in the Slice 5 commit and documented in the CHANGELOG entry under "Changed → api_notifications.py." - ohm-rfc pin bumped to 0.18.0 at
c527acaonmain, pushed. Ready forflotilla deploy ohm-rfc-apponce the operator-led SMTP fix lands. - OHM-specific upgrade impact: zero overlay change.
GITEA_WEBHOOK_SECREThas been bound on OHM since v0.14.0; the new mandatory-at-startup check is a no-op for OHM specifically. The newEMAIL_UNSUBSCRIBE_MAILTOis optional (falls back toEMAIL_FROM), so OHM picks up the default behavior cleanly without operator action. Theoutbound_emailsmigration auto-applies on first startup after the new image rolls. - Mail-tester probe is a SHOULD step. Operator gesture, not driver gesture. Worth surfacing on the operator's plate after the deploy lands.
- Remote names note: the prompt said
canonical+mirror; actual config isorigin+benstull. Pushed to both, no failures. Driver N may want to either rename the remotes or update the prompt template. - Subsession transcript: this file, at
~/git/ohm-infra/SESSION-N.1-TRANSCRIPT-2026-05-28T07-08--2026-05-28T07-40.md. Driver N publishes this viapublish-transcript.shalongside the parent session transcript.