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

14 KiB
Raw Permalink Blame History

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
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"
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:

<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:

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

# 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:

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:

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:

<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.").
  • 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:

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:

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.