# 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

,

,