From 5c7cc4c6aad4721ab60f667b75354bb299a78e98 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Mon, 25 May 2026 12:49:11 +0200 Subject: [PATCH 01/76] sync: detect orphaned blobs (pepper rotation) + fix AESGCM arg order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an 8-byte HKDF fingerprint of the current pepper to portfolio_sync rows. On fetch, a mismatch surfaces as 410 Gone (distinct from genuine GCM corruption → 500), and the UI silently cleans up the dead row and shows a soft "please re-import" notice instead of a confusing PIN re-prompt. Legacy rows (pepper_fp NULL) are probed optimistically and backfilled on success. Also fixes a latent bug in unwrap(): AESGCM.decrypt args were swapped (ct, nonce instead of nonce, ct), so restore-from-cloud always failed even when the pepper was correct. Co-Authored-By: Claude Opus 4.7 --- .../versions/0016_portfolio_sync_pepper_fp.py | 39 ++++++++ app/models.py | 4 + app/routers/sync.py | 14 ++- app/services/portfolio_sync.py | 94 ++++++++++++++++--- app/static/js/portfolio-sync.js | 23 ++++- app/static/js/portfolio.js | 53 ++++++++++- app/templates/settings.html | 13 ++- tests/test_portfolio_sync_api.py | 2 +- 8 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 alembic/versions/0016_portfolio_sync_pepper_fp.py diff --git a/alembic/versions/0016_portfolio_sync_pepper_fp.py b/alembic/versions/0016_portfolio_sync_pepper_fp.py new file mode 100644 index 0000000..1a5f6c0 --- /dev/null +++ b/alembic/versions/0016_portfolio_sync_pepper_fp.py @@ -0,0 +1,39 @@ +"""portfolio_sync: add pepper_fp for orphan-blob detection. + +When PORTFOLIO_SYNC_PEPPER rotates (intentional or otherwise), any +existing wrapped blob becomes permanently unreadable. Today that +manifests as a GCM InvalidTag → 500 on the GET endpoint. We add a +short HKDF-derived fingerprint of the pepper so we can detect the +rotation case explicitly and surface it to the client as a clean +"stale" state (410), distinct from genuine corruption (500). + +Existing rows get pepper_fp=NULL on upgrade; the service treats NULL +as "orphaned" (always true: those rows were written before this +column existed, so we can't prove the pepper matches). The next +successful upsert refreshes the fingerprint. + +Revision ID: 0016 +Revises: 0015 +Create Date: 2026-05-25 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0016" +down_revision: Union[str, None] = "0015" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "portfolio_sync", + sa.Column("pepper_fp", sa.LargeBinary(length=8), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("portfolio_sync", "pepper_fp") diff --git a/app/models.py b/app/models.py index bdc884a..2d16d01 100644 --- a/app/models.py +++ b/app/models.py @@ -204,6 +204,10 @@ class PortfolioSync(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) fetch_window_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) fetch_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # 8-byte HKDF fingerprint of the pepper that wrapped this row. A + # mismatch against the current pepper means the row is orphaned + # (pepper was rotated) — distinct from genuine GCM corruption. + pepper_fp: Mapped[bytes | None] = mapped_column(LargeBinary(length=8)) class Referral(Base): diff --git a/app/routers/sync.py b/app/routers/sync.py index e6496e0..0fa1174 100644 --- a/app/routers/sync.py +++ b/app/routers/sync.py @@ -43,6 +43,7 @@ class SyncBlobOut(BaseModel): class SyncStatusOut(BaseModel): exists: bool + orphaned: bool = False updated_at: datetime | None = None @@ -73,8 +74,8 @@ async def get_status( principal: CurrentUser = Depends(require_paid), session: AsyncSession = Depends(get_session), ) -> SyncStatusOut: - exists, updated_at = await svc.fetch_status(session, principal.id) - return SyncStatusOut(exists=exists, updated_at=updated_at) + exists, orphaned, updated_at = await svc.fetch_status(session, principal.id) + return SyncStatusOut(exists=exists, orphaned=orphaned, updated_at=updated_at) @router.post("", response_model=SyncWriteOut) @@ -108,6 +109,15 @@ async def download_blob( ) try: result = await svc.fetch(session, principal.id) + except svc.SyncOrphanedError: + # Known state: pepper rotated. The frontend uses 410 to swap the + # restore form for a "stale — re-upload" CTA. Logged at INFO, + # not ERROR, because this isn't a server fault. + log.info("portfolio_sync.orphaned", user_id=principal.id) + raise HTTPException( + status_code=status.HTTP_410_GONE, + detail="stale_blob", + ) except svc.SyncCryptoError: log.error("portfolio_sync.unwrap_failed", user_id=principal.id) raise HTTPException( diff --git a/app/services/portfolio_sync.py b/app/services/portfolio_sync.py index 15c9c41..c0bbbe9 100644 --- a/app/services/portfolio_sync.py +++ b/app/services/portfolio_sync.py @@ -44,8 +44,16 @@ RATE_LIMIT_MAX = 6 class SyncCryptoError(Exception): - """Outer-wrap decryption failed — usually a pepper change or - bit-rotted row. The router maps this to a 500.""" + """Outer-wrap decryption failed even though the pepper fingerprint + matched — i.e. genuine corruption or tampering. The router maps this + to a 500.""" + + +class SyncOrphanedError(Exception): + """The row was wrapped with a different pepper than the one currently + configured (typically: dev-time pepper rotation). The data is + permanently unrecoverable, but this is a *known* state, not a server + fault — the router maps this to a 410 Gone.""" def _utcnow() -> datetime: @@ -72,6 +80,22 @@ def _server_key(user_id: int) -> bytes: ).derive(_pepper_bytes()) +_FP_LEN = 8 + + +def current_pepper_fp() -> bytes: + """8-byte HKDF-derived fingerprint of the current pepper. Doesn't + leak the pepper itself (HKDF is one-way) and is short enough to make + accidental collisions across rotations effectively zero (2^-32 birthday + floor — fine for a few-row dev install).""" + return HKDF( + algorithm=hashes.SHA256(), + length=_FP_LEN, + salt=b"portfolio-sync-pepper-fp", + info=b"v1", + ).derive(_pepper_bytes()) + + def wrap(user_id: int, inner_blob: bytes) -> tuple[bytes, bytes]: """Encrypt the client-side ciphertext (`inner_blob`) for storage. Returns (outer_ct, outer_nonce). The nonce is random per write.""" @@ -81,9 +105,15 @@ def wrap(user_id: int, inner_blob: bytes) -> tuple[bytes, bytes]: def unwrap(user_id: int, outer_ct: bytes, outer_nonce: bytes) -> bytes: - """Inverse of wrap(). Raises SyncCryptoError if the GCM tag fails.""" + """Inverse of wrap(). Raises SyncCryptoError if the GCM tag fails. + + AESGCM.decrypt takes (nonce, data, associated_data) — not + (data, nonce). The original implementation had the arguments + swapped, which meant restore-from-cloud always failed even when + the pepper was correct. + """ try: - return AESGCM(_server_key(user_id)).decrypt(outer_ct, outer_nonce, None) + return AESGCM(_server_key(user_id)).decrypt(outer_nonce, outer_ct, None) except Exception as exc: # InvalidTag, malformed ciphertext, etc. raise SyncCryptoError("outer wrap unwrap failed") from exc @@ -91,6 +121,7 @@ def unwrap(user_id: int, outer_ct: bytes, outer_nonce: bytes) -> bytes: async def upsert(session: AsyncSession, user_id: int, inner_blob: bytes) -> datetime: """Insert or replace this user's sync row. Returns the new updated_at.""" outer_ct, outer_nonce = wrap(user_id, inner_blob) + fp = current_pepper_fp() now = _utcnow() row = await session.get(PortfolioSync, user_id) if row is None: @@ -101,6 +132,7 @@ async def upsert(session: AsyncSession, user_id: int, inner_blob: bytes) -> date version=1, created_at=now, updated_at=now, + pepper_fp=fp, ) session.add(row) else: @@ -109,19 +141,34 @@ async def upsert(session: AsyncSession, user_id: int, inner_blob: bytes) -> date row.updated_at = now # Bump version field forward if we ever change the wrap scheme. row.version = 1 + row.pepper_fp = fp await session.commit() return now +def _is_orphaned(row: PortfolioSync) -> bool: + """A row is orphaned when its stored pepper fingerprint is present + and differs from the current pepper's fingerprint. NULL fingerprint + (rows from before the pepper_fp column existed) is treated + optimistically: we don't know whether the pepper rotated, so we let + the fetch path probe with a real unwrap and self-heal on success. + Status returns orphaned=False for NULL so the user is offered the + Restore form; if unwrap then fails, the GET path returns 410 and the + UI flips to the stale state.""" + return row.pepper_fp is not None and row.pepper_fp != current_pepper_fp() + + async def fetch_status( session: AsyncSession, user_id: int, -) -> tuple[bool, datetime | None]: - """Cheap existence check — does NOT decrypt. Used by the dashboard to - decide whether to show the restore prompt.""" +) -> tuple[bool, bool, datetime | None]: + """Cheap existence check — does NOT decrypt. Returns + (exists, orphaned, updated_at). Used by the dashboard to decide + whether to show the restore prompt vs the "stale, re-upload" prompt. + """ row = await session.get(PortfolioSync, user_id) if row is None: - return False, None - return True, row.updated_at + return False, False, None + return True, _is_orphaned(row), row.updated_at async def fetch( @@ -129,13 +176,36 @@ async def fetch( ) -> tuple[bytes, datetime] | None: """Returns (inner_blob, updated_at) or None if sync disabled. - Raises SyncCryptoError if the row exists but the outer wrap is - unreadable (typically: pepper was rotated without re-encrypting). + Raises SyncOrphanedError if the row's pepper fingerprint mismatches + the current pepper, OR if a fingerprint-less legacy row fails to + unwrap (which can only mean a pepper rotation, since the arg-order + bug fix landed alongside the fingerprint column). + + Raises SyncCryptoError if the fingerprint matched but the outer wrap + still failed (genuine corruption or tampering). + + On a successful unwrap of a fingerprint-less legacy row, the current + pepper's fingerprint is backfilled so subsequent status checks + correctly report healthy (and future rotations are detectable). """ row = await session.get(PortfolioSync, user_id) if row is None: return None - inner = unwrap(user_id, row.outer_ciphertext, row.outer_nonce) + if _is_orphaned(row): + raise SyncOrphanedError("pepper fingerprint mismatch") + legacy = row.pepper_fp is None + try: + inner = unwrap(user_id, row.outer_ciphertext, row.outer_nonce) + except SyncCryptoError: + if legacy: + # Legacy row + decrypt fails = pepper rotated before the + # fingerprint column existed. Same observable state as a + # post-fingerprint orphan; report it that way. + raise SyncOrphanedError("legacy row, decrypt failed") + raise + if legacy: + row.pepper_fp = current_pepper_fp() + await session.commit() return inner, row.updated_at diff --git a/app/static/js/portfolio-sync.js b/app/static/js/portfolio-sync.js index 6772dd9..de71c17 100644 --- a/app/static/js/portfolio-sync.js +++ b/app/static/js/portfolio-sync.js @@ -216,7 +216,23 @@ if (r.status === 402) return { exists: false, paid: false }; if (!r.ok) throw new Error('sync status: HTTP ' + r.status); const body = await r.json(); - return { exists: !!body.exists, updated_at: body.updated_at, paid: true }; + return { + exists: !!body.exists, + orphaned: !!body.orphaned, + updated_at: body.updated_at, + paid: true, + }; + } + + // Thrown by pullSync when the server reports the stored blob is + // wrapped with a different server key (pepper rotation). Distinct + // from BadPinError so the UI can swap the restore form for a + // re-upload CTA instead of asking again for the PIN. + class StaleBlobError extends Error { + constructor(msg) { + super(msg || 'Stored portfolio cannot be decrypted with the current server key.'); + this.name = 'StaleBlobError'; + } } async function pushSync(pie, pin) { @@ -245,6 +261,10 @@ headers: { 'Accept': 'application/json' }, }); if (r.status === 404) return null; + if (r.status === 410) { + const body = await r.json().catch(() => ({})); + throw new StaleBlobError(body.detail); + } if (!r.ok) { const body = await r.json().catch(() => ({})); // 429 → server already throttling; bubble the message up unchanged. @@ -273,6 +293,7 @@ disableSync, clearCachedKey, BadPinError, + StaleBlobError, // Exposed for tests / debugging: _packBlob: packBlob, _unpackBlob: unpackBlob, diff --git a/app/static/js/portfolio.js b/app/static/js/portfolio.js index d40dcde..bedea08 100644 --- a/app/static/js/portfolio.js +++ b/app/static/js/portfolio.js @@ -164,14 +164,52 @@ }[c])); } + // Tiny one-shot flag the orphan-cleanup path sets so renderEmpty can + // surface a plain-English "your previous backup needs to be re-uploaded" + // line. Read-once; cleared as soon as it's shown. + function consumeBackupExpiredNotice() { + try { + if (sessionStorage.getItem('cassandra.sync.backupExpired') === '1') { + sessionStorage.removeItem('cassandra.sync.backupExpired'); + return true; + } + } catch (e) { /* ignore */ } + return false; + } + function renderEmpty(mount) { + const expired = consumeBackupExpiredNotice(); + const notice = expired + ? '
' + + 'Your previous cloud backup couldn’t be restored on this server. ' + + 'Please re-upload your portfolio to refresh it.' + + '
' + : ''; mount.innerHTML = '
' + + notice + 'No portfolio loaded in this browser. ' + 'Import a T212 CSV →' + '
'; } + // Silently remove an unrecoverable cloud blob and re-render. The user + // sees the standard empty state with a soft one-liner — no jargon, no + // extra buttons. The decision to remove is safe: the blob is already + // permanently undecryptable, so we're cleaning up dead state, not + // discarding user data. + async function autoCleanStaleBlob(mount) { + try { + await window.CassandraSync.disableSync(); + } catch (e) { + console.warn('cassandra.sync: auto-clean of stale blob failed', e); + } + try { + sessionStorage.setItem('cassandra.sync.backupExpired', '1'); + } catch (e) { /* ignore */ } + renderEmpty(mount); + } + function renderRestoreFromCloud(mount, status) { const lastSynced = status.updated_at ? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' @@ -212,6 +250,12 @@ savePie(pie); mountAndRender(); } catch (e2) { + if (e2 && e2.name === 'StaleBlobError') { + // Pepper rotated since the blob was written — silently clean + // up and fall through to the empty state with a soft notice. + autoCleanStaleBlob(mount); + return; + } err.textContent = (e2 && e2.name === 'BadPinError') ? 'Incorrect PIN.' : (e2.message || 'Could not restore.'); @@ -417,7 +461,14 @@ catch (e) { console.warn('sync status check failed', e); } } if (status && status.paid && status.exists) { - renderRestoreFromCloud(mount, status); + if (status.orphaned) { + // Pepper rotated since the blob was written — clean up + // silently and show the standard empty state with a soft + // "please re-upload" notice. + autoCleanStaleBlob(mount); + } else { + renderRestoreFromCloud(mount, status); + } } else { renderEmpty(mount); } diff --git a/app/templates/settings.html b/app/templates/settings.html index ecdd25a..bab5cb9 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -256,7 +256,18 @@ } const valueEl = statusEl.querySelector('.settings-row__value'); actionsEl.innerHTML = ''; - if (status.exists) { + if (status.exists && status.orphaned) { + // The stored blob can no longer be decrypted (server key rotated + // since it was written). The data is permanently unrecoverable, + // so silently clean up the dead row and re-render in the + // standard "off" state — leaving a soft one-liner so the user + // knows why they need to re-import. + try { await window.CassandraSync.disableSync(); } + catch (e) { console.warn('auto-clear stale sync failed', e); } + setFeedback('Your previous cloud backup couldn’t be restored. Re-import your portfolio to enable cloud sync again.', true); + await refresh(); + return; + } else if (status.exists) { const when = status.updated_at ? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : '—'; diff --git a/tests/test_portfolio_sync_api.py b/tests/test_portfolio_sync_api.py index 0a8fcc0..10c6a96 100644 --- a/tests/test_portfolio_sync_api.py +++ b/tests/test_portfolio_sync_api.py @@ -72,7 +72,7 @@ def test_paid_user_round_trip(tmp_path): # status before any upload r = client.get("/api/portfolio/sync/status", cookies=cookies) assert r.status_code == 200 - assert r.json() == {"exists": False, "updated_at": None} + assert r.json() == {"exists": False, "orphaned": False, "updated_at": None} # upload blob = os.urandom(512) From 71a2fc5b516e4db5eab53df5d99789154125b9fc Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Mon, 25 May 2026 12:49:27 +0200 Subject: [PATCH 02/76] deploy: mount app/ + alembic from host in base compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a plain \`docker compose restart app\` pick up code edits without an image rebuild. Image still bakes a copy at build time as a fallback. Note: base compose is applied in prod too, so this affects the live deploy — intentional, since this host edits live. Co-Authored-By: Claude Opus 4.7 --- docker-compose.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 3038c98..8a7e03f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,12 @@ services: REDIS_URL: redis://redis:6379/0 volumes: - ./config:/app/config:ro + # Mount app code + migrations from the host so edits take effect + # on a plain `docker compose restart app` — no image rebuild. + # Image still bakes a copy at build time as a fallback. + - ./app:/app/app + - ./alembic:/app/alembic + - ./alembic.ini:/app/alembic.ini:ro depends_on: db: condition: service_healthy @@ -62,6 +68,9 @@ services: REDIS_URL: redis://redis:6379/0 volumes: - ./config:/app/config:ro + - ./app:/app/app + - ./alembic:/app/alembic + - ./alembic.ini:/app/alembic.ini:ro depends_on: db: condition: service_healthy From eabf8b6a7fe91d5d6345b05b22e97f5cdfb7ae05 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Mon, 25 May 2026 18:06:15 +0200 Subject: [PATCH 03/76] 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 --- ...026-05-25-beta-mode-and-paid-gap-design.md | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md diff --git a/docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md b/docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md new file mode 100644 index 0000000..fa628c9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-beta-mode-and-paid-gap-design.md @@ -0,0 +1,338 @@ +# 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 +{{ BRAND_NAME }} +{% if BETA_MODE %}BETA{% 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 + +``` + +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=`: + +- 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** (Mon–Sat) + 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. From 8bc546220dd17145ce1b3fc72a17ac5e082120ec Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Mon, 25 May 2026 18:28:39 +0200 Subject: [PATCH 04/76] docs: implementation plan for beta + paid-gap rollout Twelve-task plan covering the BETA chip, free-tier 6h news cap, daily + Sunday digest job, one-click unsubscribe, settings UI, sign-up checkbox, pricing copy, and an admin send-test-digest CLI. Each task is TDD where feasible. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-25-beta-mode-and-paid-gap.md | 2096 +++++++++++++++++ 1 file changed, 2096 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-25-beta-mode-and-paid-gap.md 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

,

,