diff --git a/docs/superpowers/plans/2026-05-25-beta-mode-and-paid-gap.md b/docs/superpowers/plans/2026-05-25-beta-mode-and-paid-gap.md new file mode 100644 index 0000000..328fe47 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-beta-mode-and-paid-gap.md @@ -0,0 +1,2096 @@ +# Beta Mode + Paid/Free Gap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the closed-beta launch package: a visible BETA chip in the app chrome, a 6-hour free-tier news cap, and email digests (daily for paid Mon–Sat, Sunday weekly for everyone), all gated by an opt-in flag. + +**Architecture:** Three coordinated changes layered onto existing FastAPI + APScheduler + MariaDB + OpenRouter + SMTP infrastructure. No new external services. New persisted state: two columns on `users` (`email_digest_opt_in`, `digest_tone`) and one new table (`email_sends`). One new job (`email_digest_job`), one new router (`email`), two new prompt builders. + +**Tech Stack:** FastAPI · SQLAlchemy 2.0 (async) · Alembic · APScheduler · aiosmtplib · itsdangerous · pytest + aiosqlite (tests) · Jinja2 templates · vanilla JS + HTMX. + +**Spec:** `docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md` + +--- + +## File Map + +**New files** +- `app/jobs/email_digest_job.py` — orchestrator (daily Mon–Sat for paid, weekly Sunday for all opt-in) +- `app/routers/email.py` — `GET /email/unsubscribe?token=…` +- `alembic/versions/0017_email_digest.py` — migration +- `tests/test_news_window.py` — news-cap regression +- `tests/test_email_digest_job.py` — job recipient selection, idempotency +- `tests/test_email_unsubscribe.py` — token roundtrip + endpoint +- `tests/test_email_render.py` — digest email renderer +- `tests/test_digest_prompts.py` — prompt builder unit tests +- `tests/test_settings_digest_api.py` — PATCH endpoint +- `tests/test_verify_subscribe.py` — sign-up checkbox + +**Modified files** +- `app/config.py` — add `BETA_MODE` flag +- `app/templates_env.py` — expose `BETA_MODE` to templates +- `app/templates/base.html` — render the chip +- `app/static/css/cassandra.css` — `.beta-chip` styling +- `app/services/access.py` — `FREE_NEWS_WINDOW_HOURS` constant +- `app/routers/api.py` — `news_list` clamps `since_hours` for non-paid; PATCH endpoint for digest prefs +- `app/templates/partials/news.html` — capped-footer marker +- `app/models.py` — `User.email_digest_opt_in`, `User.digest_tone`, `EmailSend` model +- `app/services/openrouter.py` — `build_daily_digest_prompt`, `build_weekly_digest_prompt`, bump `PROMPT_VERSION` +- `app/services/email_service.py` — `render_digest_email`, `send_digest` +- `app/scheduler_main.py` — register the digest job at 06:30 UTC +- `app/templates/settings.html` — Email digests section +- `app/templates/verify.html` — subscribe checkbox +- `app/routers/auth.py` — read `subscribe_to_digests` on verify POST +- `app/templates/pricing.html` — updated tier copy +- `app/cli.py` — `send-test-digest` command +- `app/main.py` — include new email router + +--- + +## Task 1: BETA chip + +**Files:** +- Modify: `app/config.py` +- Modify: `app/templates_env.py` +- Modify: `app/templates/base.html` (line ~140) +- Modify: `app/static/css/cassandra.css` + +- [ ] **Step 1: Add `BETA_MODE` to config** + +Open `app/config.py`. Find the `Settings` class. Add: + +```python + BETA_MODE: bool = True # Shows a "BETA" pill in the app header. Flip to False at GA. +``` + +Place it adjacent to other display/flag-style settings (alongside `CASSANDRA_TONE` or the LLM caps — whichever cluster fits). + +- [ ] **Step 2: Expose the flag to templates** + +Open `app/templates_env.py`. After the existing `templates.env.globals[...]` block (around line 75), add: + +```python +from app.config import get_settings as _get_settings # if not already imported +templates.env.globals["BETA_MODE"] = _get_settings().BETA_MODE +``` + +If `get_settings` is already imported in the file under another alias, reuse it. + +- [ ] **Step 3: Render the chip in the app header** + +Open `app/templates/base.html`. Find the brand link (line ~140): + +```html + {{ BRAND_NAME }} +``` + +Insert immediately after it: + +```html + {% if BETA_MODE %}BETA{% endif %} +``` + +- [ ] **Step 4: Style the chip** + +Open `app/static/css/cassandra.css`. Append at the bottom of the file: + +```css +/* BETA indicator pill in the app header — see app/templates/base.html. */ +.beta-chip { + display: inline-block; + margin-left: 8px; + padding: 2px 7px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + font-family: var(--font-mono); + color: var(--bg); + background: var(--accent); + border-radius: 2px; + vertical-align: middle; + user-select: none; +} +``` + +- [ ] **Step 5: Manual visual check** + +Run the app locally (`docker compose restart app` — the new volume mount picks up changes). Load any logged-in page and confirm the `BETA` pill renders next to the brand. Confirm the chip does NOT appear on `/pricing` (which uses `public_base.html`, not `base.html`). + +- [ ] **Step 6: Commit** + +```bash +git add app/config.py app/templates_env.py app/templates/base.html app/static/css/cassandra.css +git commit -m "beta: header chip flagged by BETA_MODE config (default on)" +``` + +--- + +## Task 2: Free-tier news window cap + +**Files:** +- Modify: `app/services/access.py` +- Modify: `app/routers/api.py` (lines 222-280, `news_list`) +- Modify: `app/templates/partials/news.html` +- Create: `tests/test_news_window.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_news_window.py`: + +```python +"""Free vs paid window clamp on /api/news.""" +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone + +import pytest + + +def _build_app(tmp_path): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app import db as db_mod + from app.auth import sign_session + from app.db import Base + from app.models import Headline, User + from app.routers import api as api_router + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/news.db") + factory = async_sessionmaker(engine, expire_on_commit=False) + db_mod._engine = engine + db_mod._session_factory = factory + + now = datetime.now(timezone.utc) + + async def _seed(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with factory() as s: + s.add(User(id=1, email="free@x", tier="free")) + s.add(User(id=2, email="paid@x", tier="paid")) + # Headlines: one 1h old, one 12h old, one 20h old. + for hours_old, title in ((1, "fresh"), (12, "mid"), (20, "old")): + s.add(Headline( + source="test", title=title, url=f"https://e/{title}", + category="general", + published_at=now - timedelta(hours=hours_old), + fetched_at=now, + tags=[], + )) + await s.commit() + + asyncio.run(_seed()) + + app = FastAPI() + app.include_router(api_router.router, prefix="/api") + client = TestClient(app) + return client, sign_session(1), sign_session(2) + + +@pytest.mark.skipif(False, reason="requires aiosqlite + httpx") +def test_free_user_clamped_to_6h(tmp_path): + client, free_sess, _ = _build_app(tmp_path) + r = client.get("/api/news?since_hours=24", + cookies={"cassandra_session": free_sess}) + assert r.status_code == 200 + titles = [h["title"] for h in r.json()] + assert "fresh" in titles + assert "mid" not in titles # 12h ago, beyond 6h + assert "old" not in titles + + +def test_paid_user_full_24h(tmp_path): + client, _, paid_sess = _build_app(tmp_path) + r = client.get("/api/news?since_hours=24", + cookies={"cassandra_session": paid_sess}) + assert r.status_code == 200 + titles = [h["title"] for h in r.json()] + assert {"fresh", "mid", "old"} <= set(titles) + + +def test_anonymous_clamped_to_6h(tmp_path): + client, _, _ = _build_app(tmp_path) + r = client.get("/api/news?since_hours=24") + assert r.status_code == 200 + titles = [h["title"] for h in r.json()] + assert "fresh" in titles + assert "mid" not in titles +``` + +- [ ] **Step 2: Run the failing test** + +```bash +pytest tests/test_news_window.py -v +``` + +Expected: FAIL — the clamp doesn't exist yet, so the free/anonymous calls return 12h and 20h headlines too. + +- [ ] **Step 3: Add the constant** + +Open `app/services/access.py`. Below the imports / above `_utcnow`, add: + +```python +# How many hours of news the free tier sees. Paid sees whatever the +# endpoint's `since_hours` param requests (up to its own max). +FREE_NEWS_WINDOW_HOURS = 6.0 +``` + +- [ ] **Step 4: Clamp the endpoint** + +Open `app/routers/api.py`. Find `news_list` (line 222). Update: + +```python +@router.get("/news") +async def news_list( + request: Request, + session: AsyncSession = Depends(get_session), + principal: CurrentUser | None = Depends(maybe_current_user), + category: str | None = Query(None), + since_hours: float = Query(24.0, ge=0.1, le=720.0), + limit: int = Query(50, ge=1, le=500), + tags: str | None = Query(None, description="comma-separated include list"), + exclude_tags: str | None = Query(None, description="comma-separated exclude list"), + as_: str | None = Query(default=None, alias="as"), +): + from app.services.news_tagging import TAG_LABELS, TAG_VOCABULARY + from app.services.access import FREE_NEWS_WINDOW_HOURS, is_paid_active + + effective_hours = since_hours + capped = not is_paid_active(principal) + if capped: + effective_hours = min(since_hours, FREE_NEWS_WINDOW_HOURS) + + cutoff = utcnow() - timedelta(hours=effective_hours) + # ... rest of the function unchanged through `filtered = ...` ... +``` + +Also add `maybe_current_user` to the imports at the top of `app/routers/api.py`: + +```python +from app.auth import maybe_current_user +``` + +(The existing `from app.auth import …` line may already import other names — extend it.) + +In the `as_ == "html"` branch, extend the template context with `capped` and `window_hours`: + +```python + return templates.TemplateResponse( + request, "partials/news.html", + {"headlines": items, + "tag_vocabulary": TAG_VOCABULARY, + "tag_labels": TAG_LABELS, + "active_include": sorted(include), + "active_exclude": sorted(exclude), + "capped": capped, + "window_hours": effective_hours}, + ) +``` + +The JSON branch is unchanged — the test asserts via the JSON shape. + +- [ ] **Step 5: Run the test, expect PASS** + +```bash +pytest tests/test_news_window.py -v +``` + +Expected: all three tests pass. + +- [ ] **Step 6: Add the partial-template footer** + +Open `app/templates/partials/news.html`. At the bottom of the file (after the last existing item rendering and any close tags), add: + +```html +{% if capped %} +
+ Free tier — showing the last {{ window_hours|int }} hours of news. + Upgrade + for the full 24-hour feed plus daily and weekly email digests. +
+{% endif %} +``` + +- [ ] **Step 7: Run the full suite to catch regressions** + +```bash +pytest tests/ -x -q +``` + +Expected: no regressions (the existing `test_news_*` tests don't depend on the clamp). + +- [ ] **Step 8: Commit** + +```bash +git add app/services/access.py app/routers/api.py app/templates/partials/news.html tests/test_news_window.py +git commit -m "news: clamp free + anonymous to last 6h; paid keeps 24h" +``` + +--- + +## Task 3: DB model + Alembic migration + +**Files:** +- Modify: `app/models.py` +- Create: `alembic/versions/0017_email_digest.py` + +- [ ] **Step 1: Add columns to `User`** + +Open `app/models.py`. Find the `User` class (search for `class User(Base)`). Add two columns alongside the existing tier/credit fields: + +```python + email_digest_opt_in: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default=text("1"), + ) + # NULL = use INTERMEDIATE at render time. Server-side mirror of the + # dashboard tone, decoupled because the dashboard pref is localStorage. + digest_tone: Mapped[str | None] = mapped_column(String(16)) +``` + +If `Boolean` or `text` aren't already imported, extend the SQLAlchemy import line at the top of the file: + +```python +from sqlalchemy import (..., Boolean, text, ...) +``` + +- [ ] **Step 2: Add the `EmailSend` model** + +In the same file, after the existing model definitions, add: + +```python +class EmailSend(Base): + """Audit row per digest email send. Used for idempotency (don't send + twice on the same UTC day) and for surfacing 'last delivery' on the + Settings page.""" + __tablename__ = "email_sends" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, + ) + kind: Mapped[str] = mapped_column(String(16), nullable=False) # "daily" | "weekly" + sent_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False, + ) + status: Mapped[str] = mapped_column(String(16), nullable=False) # "sent" | "skipped" | "error" + error: Mapped[str | None] = mapped_column(String(255)) + + __table_args__ = ( + Index("ix_email_sends_user_kind_sent", "user_id", "kind", "sent_at"), + ) +``` + +Add any missing imports (`BigInteger`, `ForeignKey`, `Index`) at the top. + +- [ ] **Step 3: Create the Alembic migration** + +Create `alembic/versions/0017_email_digest.py`: + +```python +"""email digests: User.email_digest_opt_in, User.digest_tone, email_sends table. + +Revision ID: 0017 +Revises: 0016 +Create Date: 2026-05-25 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0017" +down_revision: Union[str, None] = "0016" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "email_digest_opt_in", sa.Boolean(), nullable=False, + server_default=sa.text("1"), + ), + ) + op.add_column( + "users", + sa.Column("digest_tone", sa.String(length=16), nullable=True), + ) + + op.create_table( + "email_sends", + sa.Column("id", sa.BigInteger(), autoincrement=True, primary_key=True), + sa.Column( + "user_id", sa.Integer(), nullable=False, + ), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("sent_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("error", sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], ondelete="CASCADE", + ), + ) + op.create_index( + "ix_email_sends_user_kind_sent", + "email_sends", + ["user_id", "kind", "sent_at"], + ) + op.create_index( + op.f("ix_email_sends_user_id"), "email_sends", ["user_id"], + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_email_sends_user_id"), table_name="email_sends") + op.drop_index("ix_email_sends_user_kind_sent", table_name="email_sends") + op.drop_table("email_sends") + op.drop_column("users", "digest_tone") + op.drop_column("users", "email_digest_opt_in") +``` + +- [ ] **Step 4: Apply the migration to the dev DB** + +```bash +docker compose exec app alembic upgrade head +``` + +Expected output ends with: `Running upgrade 0016 -> 0017, email digests...` + +- [ ] **Step 5: Verify the columns exist** + +```bash +docker compose exec db mysql -ucassandra -p${MARIADB_PASSWORD} -e "DESCRIBE cassandra.users;" | grep -E "email_digest_opt_in|digest_tone" +docker compose exec db mysql -ucassandra -p${MARIADB_PASSWORD} -e "DESCRIBE cassandra.email_sends;" +``` + +Expected: both new columns on `users` and the full `email_sends` table appear. + +- [ ] **Step 6: Run the test suite — confirm no regressions from the model change** + +```bash +pytest tests/ -x -q +``` + +Expected: all existing tests still pass (no test depends on the old User shape). + +- [ ] **Step 7: Commit** + +```bash +git add app/models.py alembic/versions/0017_email_digest.py +git commit -m "db: add digest opt-in/tone on users, email_sends audit table" +``` + +--- + +## Task 4: Daily / Weekly digest prompts + +**Files:** +- Modify: `app/services/openrouter.py` +- Create: `tests/test_digest_prompts.py` + +- [ ] **Step 1: Inspect the existing prompt scaffolding** + +Read `app/services/openrouter.py` to confirm the shape of `build_system_prompt(tone, analysis)` and `build_user_prompt(...)`. The new functions will mirror that contract: return `(system_prompt, user_prompt)` strings ready for `call_llm`. + +- [ ] **Step 2: Write the failing test** + +Create `tests/test_digest_prompts.py`: + +```python +"""Unit tests for the daily / weekly digest prompt builders.""" +from __future__ import annotations + +from datetime import datetime, timezone + +from app.services.openrouter import ( + build_daily_digest_prompt, + build_weekly_digest_prompt, +) + + +def _ctx(): + return dict( + today=datetime(2026, 5, 25, 6, 30, tzinfo=timezone.utc), + quotes_by_group={"equities": [{"symbol": "SPX", "price": 7500.0, + "label": "S&P 500", "currency": "USD", + "source": "test", "note": "", + "as_of": None, "changes": {}}]}, + headlines_by_bucket={"general": [{"when": "2026-05-25T05:00:00+00:00", + "source": "FT", "title": "Brent slides"}]}, + reference_line="S&P 7,501 (ATH) · VIX 18.0 · US 10y 4.45%", + ) + + +def test_daily_prompt_tone_intermediate(): + sys_, usr = build_daily_digest_prompt(tone="INTERMEDIATE", **_ctx()) + assert "INTERMEDIATE" in sys_.upper() or "intermediate" in sys_.lower() + assert "Brent slides" in usr + assert "daily" in sys_.lower() + + +def test_daily_prompt_tone_novice_differs(): + sys_int, _ = build_daily_digest_prompt(tone="INTERMEDIATE", **_ctx()) + sys_nov, _ = build_daily_digest_prompt(tone="NOVICE", **_ctx()) + assert sys_int != sys_nov + + +def test_weekly_prompt_mentions_week(): + sys_, usr = build_weekly_digest_prompt(tone="INTERMEDIATE", **_ctx()) + assert "week" in sys_.lower() or "weekly" in sys_.lower() + assert "Brent slides" in usr + + +def test_prompts_return_strings(): + for fn in (build_daily_digest_prompt, build_weekly_digest_prompt): + sys_, usr = fn(tone="INTERMEDIATE", **_ctx()) + assert isinstance(sys_, str) and isinstance(usr, str) + assert len(sys_) > 50 and len(usr) > 50 +``` + +- [ ] **Step 3: Run the failing test** + +```bash +pytest tests/test_digest_prompts.py -v +``` + +Expected: FAIL — `ImportError: cannot import name 'build_daily_digest_prompt'`. + +- [ ] **Step 4: Implement the prompt builders** + +Open `app/services/openrouter.py`. After `build_user_prompt`, add: + +```python +def build_daily_digest_prompt( + *, + tone: str, + today, + quotes_by_group: dict, + headlines_by_bucket: dict, + reference_line: str, +) -> tuple[str, str]: + """System + user prompt for the once-a-day editorial digest. + + Different from the hourly log: the daily digest reflects on the past + 24h and looks forward to the upcoming session. Longer, less + 'live-blogging,' more contextual. Target ~600 words.""" + tone_clause = ( + "Use plain English. Define any jargon on first use." + if tone.upper() == "NOVICE" + else "Write for a reader who already speaks markets fluently." + ) + system = ( + "You write the daily editorial digest for Read the Markets. " + f"Audience tone: {tone.upper()}. {tone_clause} " + "Cover: (1) what mattered yesterday, (2) what to watch in today's " + "EU and US sessions, (3) one cross-asset thread connecting them. " + "No predictions of price level, no buy/sell language. Target ~600 " + "words. Output HTML using only

,

,