# R2-B — Email + per-voter approval round-trip spike ## Schema gap (top, per spec) The spike surfaced a pre-existing **model relationship bug** that blocks the round-trip even after a clean schema bootstrap: `backend/app/models/__init__.py:201` declares `MealPlan.votes = relationship("MealPlanVote", back_populates="meal_plan", ...)` but `MealPlanVote` has no `meal_plan` FK or relationship. SQLAlchemy fails at first mapper configure with `NoForeignKeysError`. Details + two pre-existing migration bug in `r2b-blockers.md`. Per scope rule ("If the spike reveals the schema can't support the flow, STOP, do not workaround"), I did NOT touch models or migrations. ## What landed - `backend/app/services/email.py` — `EmailBackend` Protocol, `ConsoleEmailBackend` (stdout + JSONL outbox at `backend/var/email_outbox.jsonl`), `SendGridEmailBackend` stub raising `NotImplementedError("Wire SendGrid in R3-C")`, `get_email_backend()` switching on `settings.EMAIL_BACKEND`. - `backend/app/services/approval.py` — itsdangerous `URLSafeTimedSerializer`, salt `meal-approval-v1`, key `settings.SECRET_KEY`. `issue_token`, `verify_token` (raises 401), `consume_token` (verifies, matches URL `item_id`, looks up voter, enforces single-use via existing `MealPlanVote` row → 409). Single- use enforcement lives ONLY in `consume_token`. - `backend/app/api/meals.py` — replaced two vote routes: - `GET /api/meals/vote/{item_id}?token=...` → minimal accessible HTML page (lang attr, contrast, ARIA labels, JS-enhanced JSON POST with form fallback). Token only inside form `action`, never visible body. - `POST /api/meals/vote/{item_id}?token=...` body `{"vote": "approve"|"deny"}`. Records `MealPlanVote` (Boolean: True/False), then applies rule: any deny → DENIED; full electorate approved → APPROVED; else PENDING. Returns `{"status": "recorded", "item_status": "..."}`. - `backend/app/config.py` — added `EMAIL_BACKEND: str = "console"`. - `backend/requirements-dev.txt` — added `freezegun>=1.4`. - `.gitignore` — appended `backend/var/`. - `scripts/send_test_approval.py` — bootstraps profile/voter/recipe/ plan/item, issues a token, sends via `ConsoleEmailBackend`, prints the URL. `--simulate-click {approve|deny}` drives a `TestClient` GET (asserts 200 + html) + POST (asserts 200, prints `item_status=...`) + a second POST (asserts 409 single-use). - `backend/tests/test_approval.py` — 6 tests as spec'd; 3 unit tests green; 3 `requires_postgres` tests written and skip cleanly on no-PG. They will pass once Blocker 1 is fixed. ## URL contract `/api/meals/vote/{item_id}?token=`. Token payload: `{"item": str(uuid), "voter": str(uuid)}`. TTL 7 days. Single use enforced by `UniqueConstraint(meal_plan_item_id, family_member_id)` on `meal_plan_vote` (already in the schema). ## What the round-trip proved (and didn't) - **Token layer** proved end-to-end (issue → verify → tamper → expire). - **Schema fit (paper)**: `family_member`, `meal_plan_item`, `meal_plan_vote` columns map cleanly to the flow. - **Schema fit (runtime)**: BLOCKED by `MealPlan.votes` defect. Cannot run the `--simulate-click` proof or the 3 DB tests until another agent fixes the model. ## Verification (what I ran) - `pytest backend/tests/test_approval.py -q` → **3 passed, 3 skipped**. - `python -c "from app.services.email import get_email_backend; print(...)"` → `ConsoleEmailBackend`. - `python scripts/send_test_approval.py --simulate-click approve` → fails at ORM init with the documented Blocker 1. ## Files - created: `backend/app/services/__init__.py`, `backend/app/services/email.py`, `backend/app/services/approval.py`, `scripts/send_test_approval.py`, `backend/tests/test_approval.py`, `.agent/phase-summaries/r2b-blockers.md`, `.agent/phase-summaries/r2b-summary.md`. - modified: `backend/app/api/meals.py`, `backend/app/config.py`, `backend/requirements-dev.txt`, `.gitignore`.