read.markets/docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md
Giorgio Gilestro eabf8b6a7f docs: spec for beta indicator + paid/free gap (digests + news cap)
Design doc for three coordinated closed-beta changes: a BETA chip in
the app header, a 6h news-window cap on the free tier, and email
digests (daily for paid Mon-Sat, Sunday weekly for everyone). Draft;
awaits implementation plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:06:15 +02:00

338 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Beta Mode + Wider Paid/Free Gap — Design Spec
**Date:** 2026-05-25
**Status:** Draft — awaiting approval
**Scope:** Three coordinated changes that ship together because they all
target the closed-beta launch: (1) a visible BETA indicator in the app
chrome, (2) a free-tier cap on the news feed window, and (3) email
digests — daily for paid, Sunday-weekly for everyone.
## 1. Goals
- Set expectations for closed-beta testers that the product is still
evolving, without making the app look unfinished.
- Create a tangible reason to upgrade beyond portfolio import + sync.
Today the free tier carries nearly the entire editorial layer; after
this change, paid gets meaningfully more.
- Use existing infra (scheduler, SMTP, OpenRouter, settings page)
rather than introducing new dependencies.
## 2. Non-goals
- No payment / Paddle work. Pricing copy is updated, but checkout is
still gated behind the existing "Coming soon" CTA.
- No per-user personalised digests. Same content for all recipients.
- No timezone handling for users. Fixed 06:30 UTC daily send.
- No new analytics / metrics dashboards for opens or clicks beyond a
simple audit row per send.
## 3. Component overview
| Component | Change |
|-----------|--------|
| `app/templates/base.html` | BETA chip in header next to brand. |
| `app/static/css/cassandra.css` | `.beta-chip` styles. |
| `app/config.py` | `BETA_MODE: bool = True` env flag. |
| `app/services/access.py` | `FREE_NEWS_WINDOW_HOURS = 6` constant. |
| `app/routers/api.py:222-280` (`news_list`) | Soft-auth dep + free-tier window clamp. |
| `app/templates/partials/news.html` | "Showing last 6h — upgrade for 24h" footer when capped. |
| `app/models.py` | New columns on `User`: `email_digest_opt_in: bool`, `digest_tone: str | None`. New table `EmailSend(user_id, kind, sent_at, status, error)`. |
| `alembic/versions/0017_email_digest.py` | Migration. |
| `app/services/openrouter.py` | Add `build_daily_digest_prompt(tone)` and `build_weekly_digest_prompt(tone)`, bump `PROMPT_VERSION`. |
| `app/services/email_service.py` | Add `render_digest_email(kind, tone, content)` + `send_digest(user, kind, tone, html, text)`. |
| `app/jobs/email_digest_job.py` (new) | Daily-or-weekly orchestrator. Generates content once per tone, fans out to recipients. |
| `app/scheduler_main.py` | Register the new job at 06:30 UTC. |
| `app/templates/settings.html` | "Email digests" section: opt-in toggle, tone radio. |
| `app/templates/login.html` (post-OTP-verify flow) | Default-checked "send me the digest" checkbox. |
| `app/routers/auth.py` | OTP-verify handler reads the new `subscribe_to_digests` form field and sets `email_digest_opt_in` on the new `User`. |
| `app/routers/settings.py` (or wherever existing settings PATCH lives) | New `PATCH /api/settings/digest` endpoint: body `{opt_in: bool, tone: "NOVICE"|"INTERMEDIATE"}`. |
| `app/routers/email.py` (new) | `GET /email/unsubscribe?token=...` — HMAC-verified one-click off switch. |
| `app/templates/pricing.html` | Updated bullets — free gets weekly digest + last 6h news; paid gets daily digest + last 24h news. |
## 4. Detailed design
### 4.1 BETA chip
`app/templates/base.html` only (not `public_base.html`). Insert
immediately after the brand link:
```html
<a href="/" class="brand">{{ BRAND_NAME }}</a>
{% if BETA_MODE %}<span class="beta-chip" title="Beta — feedback welcome">BETA</span>{% endif %}
```
The `BETA_MODE` flag is injected into the template context globally —
add it to `app/templates_env.py` so every render gets it (analogous to
how `BRAND_NAME` is provided today).
CSS: small uppercase pill with `var(--accent)` background and brand-bg
foreground; spaced ~8px from the brand link. Sizing matches the
existing top-right `.meta` chip so the visual weight is balanced.
`BETA_MODE` defaults to `True` in `app/config.py`. Flip to `False` for
general launch — one-line change.
### 4.2 Free-tier news cap (6h window)
`app/routers/api.py`'s `news_list` (lines 222-280) currently has no
auth dep. Add `principal: CurrentUser | None = Depends(maybe_current_user)`
(soft-auth — keeps the endpoint reachable by anonymous visitors,
matching today's behaviour).
Compute the effective window:
```python
window = since_hours
if not is_paid_active(principal):
window = min(window, FREE_NEWS_WINDOW_HOURS) # 6.0
cutoff = utcnow() - timedelta(hours=window)
```
`FREE_NEWS_WINDOW_HOURS = 6.0` lives in `app/services/access.py` as a
module-level constant. Tuning it later is one edit; promoting it to env
config is YAGNI for now.
`is_paid_active` already accepts `CurrentUser | User | None` and admins
auto-pass — no special-casing needed here.
When the cap is in effect, pass `capped: True` into the news partial so
it can render a soft footer:
> Free tier — showing the last 6 hours of news. [Upgrade] for the full
> 24-hour feed plus daily and weekly email digests.
When the user is paid (or admin), the footer is absent. When the
visitor is anonymous, the link goes to `/pricing`.
### 4.3 Database changes
```python
# app/models.py — User columns added:
email_digest_opt_in: Mapped[bool] = mapped_column(Boolean, nullable=False,
default=True, server_default=text("1"))
digest_tone: Mapped[str | None] = mapped_column(String(16)) # NOVICE | INTERMEDIATE
```
Defaults to opted-in (matches the chosen UX). `digest_tone` is nullable;
NULL is interpreted as INTERMEDIATE at render time.
New table:
```python
class EmailSend(Base):
__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"),
)
```
Idempotency: the job queries `EmailSend` for the current day before
sending, so a job restart can't double-deliver.
Migration: `alembic/versions/0017_email_digest.py`.
### 4.4 Digest content generation
Two new prompts in `app/services/openrouter.py`:
- `build_daily_digest_prompt(tone)` — returns a `(system, user)` pair.
Pulls the same `quotes_by_group` + `headlines_by_bucket` data as
the hourly log but with a 24h headline window and a different
instruction set: less "current state" framing, more "what mattered in
the past day, what to watch today". Target length ~600 words.
- `build_weekly_digest_prompt(tone)` — 7-day headline window, weekly
recap + week-ahead anticipation. Target length ~900 words.
Both reuse `call_llm()` and the existing cost-cap / ledger plumbing.
`PROMPT_VERSION` is bumped so the audit trail is unambiguous.
For each digest run, the job generates **two** variants
(NOVICE + INTERMEDIATE) and stores them in memory for the fan-out
batch. There is no DB persistence of digest content — emails are the
artefact. If we later want to render them on a web archive, that's a
separate spec.
Cost: at our current model pricing, two daily generations ≈ $0.04/day,
two weekly generations ≈ $0.06/week. Both well under the existing
`OPENROUTER_MONTHLY_CAP_USD` headroom.
### 4.5 Email rendering and delivery
`render_digest_email(kind, tone, html_body, text_body) -> (subject,
text, html)` in `email_service.py`. Wraps the LLM output in the same
multipart template family used for OTPs (light/dark palette, inline
styles, monospace stack, 520px max-width). Adds two footer rows:
- "Don't want these? [Unsubscribe in one click](.../email/unsubscribe?token=...)"
- "Or change your preferences in [Settings](.../settings)"
Subjects:
- Daily: `"Read the Markets · Daily — {date}"`
- Weekly: `"Read the Markets · Weekly recap — {date}"`
Fan-out: one SMTP send per recipient. Sequential with a small
`asyncio.sleep(0.1)` between sends to stay under common SMTP rate
limits. Failures are caught per-recipient, logged into `EmailSend`,
and don't block the rest of the batch.
### 4.6 Sign-up opt-in checkbox
The OTP-verify POST handler in `app/routers/auth.py` is where the user
is first established — that handler reads a `subscribe_to_digests`
form field and persists it into `User.email_digest_opt_in`. Default to
`True` if the field is absent (covers older clients or curl flows).
The verify template (`app/templates/verify.html`) gets a checkbox:
```html
<label>
<input type="checkbox" name="subscribe_to_digests" checked>
Email me the digest (daily for paid, Sunday for everyone). One-click
unsubscribe in every email.
</label>
```
Pre-existing users get `email_digest_opt_in=True` from the migration's
server-side default — but see §6 for the cutover plan.
### 4.7 Settings page
In `app/templates/settings.html`, add a section:
```
Email digests
[✓] Send me digests
Free tier: Sunday weekly. Paid: daily + Sunday.
Reading level: ( ) Novice (•) Intermediate
Last delivery: 2026-05-24 06:30 UTC — sent
```
The "Last delivery" row reads the most recent `EmailSend` row for this
user. If none, shows "—".
Wire it via the existing settings JS pattern (look for the sync /
tone-toggle handlers in `static/js/`); the endpoints are
`PATCH /api/settings/digest` with body `{opt_in: bool, tone: str}`.
### 4.8 One-click unsubscribe
`GET /email/unsubscribe?token=<base64>`:
- Token is `itsdangerous.URLSafeSerializer` over `{"uid": user_id,
"purpose": "digest_optout"}`, signed with `CASSANDRA_SECRET`.
- Handler verifies, flips `email_digest_opt_in=False`, renders a tiny
confirmation page ("You're unsubscribed. Re-enable any time in
[Settings](/settings).").
- No auth required — that's the whole point of one-click unsubscribe.
- Replay-safe: re-running the same URL is idempotent (the column
is already false; the page renders the same confirmation).
### 4.9 Scheduler integration
`app/scheduler_main.py` already runs the hourly jobs. Add:
```python
schedule_daily( # whatever helper exists, or apscheduler equivalent
"email_digest_job",
hour=6, minute=30,
target=email_digest_job.run,
)
```
The job itself decides what to do:
```python
async def run():
today = utcnow().date()
if today.weekday() == 6: # Sunday — weekly digest for everyone
await _run_weekly()
else:
await _run_daily() # paid only
```
`_run_weekly()` queries all users with `email_digest_opt_in=True`.
`_run_daily()` queries paid users with `email_digest_opt_in=True`.
### 4.10 Pricing copy updates
`app/templates/pricing.html` — modify the bullet lists. New copy:
Free:
- "News aggregator — last 6 hours, auto-tagged by theme" (was: no time qualifier)
- "Cross-asset macro signals across every asset class"
- "Hourly AI interpretation of the news + the tape"
- "Per-group cross-asset summaries"
- "Novice / Intermediate reading levels"
- "**Sunday weekly digest by email**" *(new)*
- "❌ Portfolio import & analysis"
- "❌ Encrypted cloud sync"
Paid (replaces the "Priority email when something material changes (later)" line):
- "Everything in Free"
- "**News aggregator — full 24 hours**" *(replaces the implicit 24h)*
- "Portfolio import (Trading 212 CSV)"
- "AI commentary on diversification, sector and currency concentration, …"
- "Optional encrypted cloud sync across devices"
- "**Daily email digest** (MonSat) + Sunday weekly" *(replaces 'Priority email')*
The intro paragraph at lines 8-13 needs to soften:
> Two tiers. The news aggregator and hourly AI interpretation are
> available to everyone — paid extends the time window from 6h to 24h
> and adds daily editorial by email, plus the portfolio-import features.
(Old copy said "free for everyone — we want the read out where people
can use it." That stance is moderated, not abandoned.)
## 5. Error handling
- **SMTP failure**: per-recipient try/except. Log to `EmailSend` with
`status="error"`, `error=str(exc)[:255]`. Job continues. Job-level
failure metrics surface via existing `JobRun` mechanism.
- **OpenRouter failure**: if the content generation fails for both
tones, the job records `JobRun.status="error"` and sends nothing.
Half-success (one tone) → send the variant that worked, skip the
other; users on the failed tone get nothing today (rather than wrong
content).
- **Cost-cap hit**: same pattern as the hourly job — skip the run with
a logged reason.
- **Unsubscribe token invalid / tampered**: render the same
confirmation page generically; do not leak whether the token was
valid (avoid enumeration).
## 6. Cutover plan
- Migration sets `email_digest_opt_in=True` for all existing users via
`server_default`. This is the user-requested default — they want
every paid beta-tester actually receiving the email.
- BETA mode is on from the first deploy.
- News cap is on from the first deploy.
- The first daily run lands at 06:30 UTC the morning after deploy.
- The first weekly run lands at 06:30 UTC the next Sunday.
- A pre-deploy admin CLI command (`python -m app.cli send-test-digest
--email me@…`) is added for the operator to dry-run a digest into
their own inbox before flipping the scheduler.
## 7. Testing
- Unit: `is_paid_active` window-clamping, opt-in flag round-trip,
token signing/verification.
- Integration: `tests/test_news_api.py` — anonymous + free vs paid
windowing. `tests/test_email_digest.py` — job runs, EmailSend rows
written, idempotency on re-run within the same day.
- Manual: send-test-digest CLI, click unsubscribe link, verify Settings
toggle round-trips.
## 8. Open questions
None at design time — all earlier ambiguities (chip scope, window
shape, content shape, send time, tone variants, opt-in default,
unsubscribe model) were resolved during brainstorming.