d5125190ae
- Rewrite every legacy `Session L` / `Session M.1` etc. reference inside
transcript bodies to the numeric form (`Session 0012.0` / `Session 0013.1`),
via a mechanical Python sed pass scoped to canonical-filename transcripts
only (502 replacements across 39 files; 21 files in this published repo +
18 sibling files in `~/git/ohm-infra/`). The §23 rename was originally
filename-only — session 0018.0 reverses that and rewrites the bodies too,
so cross-references inside a transcript match the filename you'd browse
to. The mapping table (`A → 0001` … `N → 0014`) lives in
`SESSION-PROTOCOL.md` §1 (also amended this session) for anyone hitting
an old external link.
- Amend README.md:
- New "Where the record begins" section noting OHM started with two
vibe-coded prototypes (no transcripts; the discipline that produced this
record didn't exist yet). The transcripts here begin at the
spec-driven-development phase — not at the literal beginning of OHM.
- Update the existing "Sessions 0001 through 0013 originally shipped
under a letter-form naming" paragraph to reflect that body refs are
now also in numeric form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
352 lines
16 KiB
Markdown
352 lines
16 KiB
Markdown
# Session 0014.1 — Transcript
|
|
|
|
> Parent: SESSION-0014.0-TRANSCRIPT-2026-05-28T07-01--<end>.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**: `main` at `1456c8b` (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**: `main` at `8cce148`, `.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 need
|
|
`PYTHONPATH=.` (the prompt's example `pytest backend/tests/` was
|
|
one cwd-level off; backend tests live under `backend/tests/`
|
|
and import `from app import ...` so `cd backend && PYTHONPATH=. pytest`
|
|
is 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
|
|
fixture `tmp_env` in `test_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_domain` defaults to the @-domain of `from_address` (so
|
|
Message-IDs align with the sending domain by default); falls
|
|
back to `"localhost"` if from_address has no `@` (defensive).
|
|
- `Auto-Submitted: auto-generated` set 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-Click` only emitted
|
|
when an `unsubscribe_url` is 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 synthetic `all` category instead of any single
|
|
per-category flag. The bundle/digest spans multiple categories,
|
|
so a per-category opt-out wouldn't honor user intent. Added
|
|
`all` → `email_opt_out_all = 1` to the `_CATEGORY_COLUMN` map
|
|
in `api_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; `all` via POST;
|
|
`all` via 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
|
|
|
|
1. `VERSION` 0.17.0 → 0.18.0.
|
|
2. `frontend/package.json#version` 0.17.0 → 0.18.0.
|
|
3. 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.
|
|
4. Release commit `0562d53`.
|
|
5. Final test run: 295 passed in 59.11s. Green.
|
|
6. Merged feature branch to main with `--no-ff` →
|
|
merge commit `31913b1`.
|
|
7. Tagged `v0.18.0` on `31913b1`.
|
|
8. Pushed `main`, `v0.18.0` tag, and feature branch to BOTH
|
|
`origin` (canonical) and `benstull` (mirror). All four pushes
|
|
reported successful by the remote.
|
|
9. Bumped `/Users/benstull/projects/wiggleverse/ohm-rfc/.rfc-app-version`
|
|
0.17.0 → 0.18.0. Committed as `c527aca`, pushed to ohm-rfc
|
|
`origin/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
|
|
|
|
1. **Per-RFC invite path consolidation.** Per the proposal's open
|
|
question #4: confirmed `invites.py` (v0.16.0 per-RFC invite)
|
|
uses `email_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
|
|
on `email_invite.py` and 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.
|
|
2. **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_plain` to `build_envelope`. The helper
|
|
supports `body_html`, so adding HTML variants is a follow-up
|
|
minor with zero infrastructure work; just the template authoring.
|
|
§19.2 candidate.
|
|
3. **Sender Policy Framework + DKIM rotation rhythm.** The
|
|
`mail-tester.com` SHOULD 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.
|
|
4. **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.
|
|
5. **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` / `benstull` reality. 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 `c527aca` on `main`,
|
|
pushed.** Ready for `flotilla deploy ohm-rfc-app` once the
|
|
operator-led SMTP fix lands.
|
|
- **OHM-specific upgrade impact: zero overlay change.**
|
|
`GITEA_WEBHOOK_SECRET` has been bound on OHM since v0.14.0;
|
|
the new mandatory-at-startup check is a no-op for OHM
|
|
specifically. The new `EMAIL_UNSUBSCRIBE_MAILTO` is optional
|
|
(falls back to `EMAIL_FROM`), so OHM picks up the default
|
|
behavior cleanly without operator action. The
|
|
`outbound_emails` migration 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 is `origin` + `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-0014.1-TRANSCRIPT-2026-05-28T07-08--2026-05-28T07-40.md`.
|
|
Driver N publishes this via `publish-transcript.sh` alongside
|
|
the parent session transcript.
|