Watstock 2 — Developer Handbook
One book for the web desk and the mobile app. Read this before you open a ticket or add a screen.
The product is an advisory research desk. It scores a fixed US universe five trading sessions ahead. It never executes, never holds funds, and never acts as a broker.
Watstock is a FREE information product. No paid tier, no paywall, no locked rank, no reserve window, nothing to buy.TIERS.freeandTIERS.proare the same unlocked object, every prediction row carrieslocked: false, andGET /api/billing/mereports unlimited capabilities to every caller, signed in or not.PLANS, thesubscriptiontable and theplan/status/currentPeriodEndfields survive only as accounting records for people who paid before the change;POST /api/billing/checkoutis closed (410) and/api/billing/*otherwise stays mounted for webhooks and those historical records. Shipping a paywall, or Stripe used to unlock digital content, in a free app is an App Store rejection — a reachable checkout is a launch blocker, not a tidy-up.
### Before you touch tier handling, read this The server must keep reporting `tier: "pro"`. The live Android app decodesGET /api/billing/meinlib/billing/entitlement_limits.dart: ``dart watchlistLimit: isPro ? _nullableInt(json) : _int(json, 10)Anulllimit means *unlimited* **only on theisProbranch**. On the free branchnullis coerced to **10**. So sendingtier: "free"— even with every other field unlimited — silently re-gates every installed Android user to a watchlist of 10, with no error, no crash and nothing in the logs, in a binary that cannot be force-updated."pro"here is not a billing claim. On a free product it is the wire string that means *"nothing is withheld"*. **Do not "tidy"resolveTierto answer"free"` because the product is free. Wherever you touch tier handling, leave a comment saying exactly this. Full explanation: [entitlements](./entitlements.md#the-server-must-keep-reporting-tier-pro). This is the difference between the two surfaces in one sentence: on the web, which we redeploy at will, a tier word is just copy; on mobile**, a tier value is a decoder branch inside software nobody can recall.
| You are… | Jump to |
|---|---|
| Shipping the website | Web |
| Shipping iOS or Android | Mobile |
| Calling the API from any shell | HTTP API |
| Wiring sign-in | Auth |
Touching tiers, locked or /api/billing/* | Entitlements — the warning above first |
| Matching the white / cyan desk | Design |
Companion pages in this folder: product · architecture · API · auth · entitlements · client apps · design · compliance · local setup.
Application name: Watstock 2. GitLab: https://gitlab.com/feymanlabs/watstock2.git. Feynman slug: watstock2.
1. What we are building
Every published call is one of three words — BUY, HOLD, SELL — scored over five trading sessions.
The engine blends:
- Live tape (Massive / Polygon, Yahoo fallback)
- FinBERT + FinLex headline sentiment
- A small hybrid technical ensemble
- A 10% World Monitor equity-layer sleeve (licensed globe + CII)
Users paper-trade the call, then send a ticket to their own bank. The scoreboard is public. Nothing is backfilled. Misses keep a forensic line.
What we are not
| Forbidden | Why |
|---|---|
| Order routing, broker APIs, wallets, custody | We do not execute or hold funds |
| “Guaranteed” or unsourced accuracy claims | Retired 2017 language. Illegal and false |
| 30-day / any-asset / commodity predictions | Out of scope |
| IBM Watson, legacy partner logos | Retired |
| Personalized investment advice | Footer says research only |
| Editing or deleting a published prediction | The record is the product |
| A paywall, a price, a "Pro" badge or a checkout | The product is free; a purchase flow is a store rejection |
If a feature needs execution, funds, or a suitability conversation, it does not ship.
Horizon and correctness (methodology_v2)
Public on /method. Never tuned retroactively. One versioned object in `src/lib/entitlements.ts` holds both halves of the rule — when a call may be published and when it counts correct — and both are stamped on the prediction row.
| Signal | Published when (5-session forecast) | Correct when (5-session return) |
|---|---|---|
| BUY | >= +1.0% and sentiment >= −0.22 | >= +1.0% |
| SELL | <= −1.0% and sentiment <= +0.22 | <= −1.0% |
| HOLD | everything inside ±1.0% | abs(return) < 1.0% |
The two bands are the same number, deliberately. methodology_v1 published at ±1.65% with a forced HOLD inside ±0.75% while grading at ±1.0%, so a forecast of +1.4% published as HOLD and was then graded correct only if the name stayed flat — the score rewarded the desk for being wrong about its own number. The grading numbers are identical in v1 and v2, so no call on the record changes grade; the version moved because the published population did.
Read the rule with ruleFor(row.methodology) and grade with isCallCorrect(signal, returnPct, row.methodology). A row with no version is a v1 row. Never grade a row under the active version because it is active, and never edit a published version's numbers — add methodology_v3 instead, and move the prediction.methodology column default in a new ordered migration at the same time (the default is the one copy of the version string that lives outside TypeScript).
A name with fewer than 10 resolved calls shows “insufficient history”, never a percentage. A figure spanning both versions is two measurements in one number; label it.
Universe
Fixed list in `src/lib/universe.ts`. Clients must not invent tickers. Adding a name is a backend change so the tape, layer map, and scoreboard stay aligned.
Sectors: Technology, Communication, Consumer, Financials, Healthcare, Energy, Industrials. Listed hedges include GLD, USO, FRO.
Voice
Editorial, precise, short. White paper, cyan #00A4D1, ink #071018. No hype. No emoji in product chrome.
Compliance footer (verbatim, every prediction surface and ticket):
WATSTOCK provides research and analytics, not personalized investment advice. Markets involve risk, including loss of capital. Past performance of our scoring does not guarantee future results. WATSTOCK does not execute orders, hold funds, or act as a broker-dealer.
Source: `src/lib/compliance.ts`. Do not paraphrase.
2. System map
Web (this repo) Desktop shell iOS Android
TanStack Start Tauri / Electron Swift or Expo Kotlin or Expo
\ | | /
\ | | /
+----------------+--------+--------+---------------+
|
HTTPS + session
|
Watstock 2 API
/api/tape /api/stock /api/chat /api/billing
/api/paper /api/geo /api/auth /api/scoreboard
|
Massive/Yahoo Stripe World Monitor Pro
FinBERT/FinLex webhook CII + licensed map
(legacy)
|
Postgres / PGLite
predictions · subscription · paper · watchlist
chat_query · chat_quotaOne API. Four shells. Do not fork the scoring engine into a client.
Nothing is gated, on any surface. Decisions still belong on the server — a blur on the client was always decoration, and today there is nothing to blur. Official rank is assigned after sorting by expected 5-day return; clients display row.rank, never index + 1 of a visible slice.
What the server must still get exactly right is the wire shape: tier, locked and the nullable limits are a contract with binaries that cannot be recalled. Change values, never keys.
3. Web (this repository)
This GitLab project is the web product. Ship it at the marketing domain and at https://www.feynmanlabs.com/apps/watstock2.
Stack
React 19 · TypeScript · Vite · TanStack Start / Router / Query · Tailwind v4 · Better Auth · PGLite (preview) / Postgres (prod). Stripe is present but legacy — webhooks and historical records only.
How to run
npm install
# optional: set -a && source .secrets && set +a
npm run dev # 0.0.0.0:8080
npm run typecheck
npm run build
npm start # scripts/serve.mjs — honors PORTDocker: docker compose up --build. Migrations: npm run db:migrate (migrations/0001_*.sql … 0016_methodology_versioning.sql). Add a new numbered file; do not edit applied ones.
Routes to keep stable
These are deep links for desktop and mobile.
| Path | Screen |
|---|---|
/ | Weekly ranking — the full book, rank 1…N |
/stock/$symbol | Name + 5-day. Open to everyone |
/world | Licensed World Monitor map + layer |
/chat | Beginner desk chat (+ stored-thread history when signed in) |
/scoreboard | Public record |
/desk | Paper book |
/watchlist | Names |
/method | 1% rule |
/pricing | "Watstock is free" — no plans, no prices, no checkout |
/signup /login /account | Identity |
/t/$id | Public ticket |
/alerts | Alerts (every signed-in account) |
/deck | Investor pitch (12 slides) |
/handbook | This handbook, in-app |
Where code lives
| Path | Role |
|---|---|
src/routes/* | Pages |
src/routes/api/* | HTTP JSON — the contract for every client |
src/lib/entitlements.ts | TIERS (both keys unlocked), historical plans, 1% rule, chat caps |
src/lib/predict.ts | Advice types + trading calendar |
src/lib/market.server.ts | Live tape + ensemble (server only) |
src/lib/equity-layer.ts | World Monitor feature classes + mapped book |
src/lib/chat/* | Desk chat: topics, context, Grok, quota |
src/lib/billing/stripe.server.ts | Legacy: webhooks + historical subscription records |
src/lib/auth/* | Better Auth + Feynman session |
src/lib/paper.ts | Mock book, $100,000 starting cash |
src/lib/api-client.ts | Fetch wrappers — copy these into native until @watstock/sdk exists |
src/styles.css | Design tokens |
migrations/ | Schema |
Secrets never leave the server. Read them with runtimeEnv() in src/lib/env.server.ts.
Adding a page
- Create
src/routes/<name>.tsxwithcreateFileRoute("/name"). src/routeTree.gen.tsis hand-maintained in this repository (there is no Node here to regenerate it). Add the route's entry by hand. Deleting a route file without removing its tree entry, or vice versa, is a build error — prefer replacing a route's *content* over deleting the file.- Link from
src/components/app-shell.tsx(desktop) and/ormobile-nav.tsx(tabs). - Keep the page usable at 390×844. Tap targets ≥ 44px.
Web navigation (current)
- Desktop masthead: Hotstocks · Chat · World · Scoreboard · Paper desk · Watchlist · Methodology · Deck
- Mobile-web tabs: Hotstocks · Chat · Desk · Watch
That second line is the web app at a narrow width. The native apps in §4 are a separate design that shares the tokens and the voice, not a reskin of these breakpoints — see design.
App Store mount
The Feynman store mounts at /apps/watstock2. Middleware strips that prefix so routes stay /, /api/tape, /stock/AAPL. Honor PORT / HOST / BASE_PATH from the store.
4. Mobile (iOS / Android)
Two implementation paths. Pick one per team. Do not mix scoring logic into the binary.
| Path | When to choose |
|---|---|
| Expo (React Native) | One team, fastest parity with web types |
| Native Swift + Kotlin | App Store polish, platform charts |
Suggested packages later: @watstock/sdk exporting types + fetch client from src/lib/api-client.ts. Until it exists, copy api-client.ts, entitlements.ts, predict.ts, paper.ts, compliance.ts. Do not rewrite the 1% rule or rank math.
Shared contract (every binary)
- Call the HTTP API. Never scrape HTML.
- There is no gate to honour. Every capability is unlocked for every caller and
lockedis alwaysfalse. Never render a lock, an upgrade prompt or a price. - Print the compliance footer on every prediction and ticket.
- World Monitor: official embed or
GET /api/geo. No unofficial scrape. - Paper-trade only. Tickets say send this to your bank. No broker SDK.
- Same account as the web.
GET /api/billing/meanswerstier: "pro"for everyone, signed in or not — the string means "nothing is withheld", not "this account pays". Do not build a purchase flow around it, and decode the three unlimited numbers as nullable ints (see the warning at the top of this handbook).
Screens
Share the tokens and the voice with the live web; do not port the web layout. The comps (WatstockDesign.pdf, Watstock App.dc.html) predate the free product — wherever they show a locked banner, a "Pro" tag or a paywall, build the unlocked version instead.
| Screen | Notes |
|---|---|
| Ranking | The full book, official rank 1…N. No banner, no ghost rows, no gaps |
| Full call | Huge ticker, BUY/HOLD/SELL tag, paper-trade. Open to everyone |
| Desk chat | Same /api/chat as web. Starters: Hormuz, HOLD, energy stress |
| World | WKWebView / WebView of the official embed + /api/geo cards |
| Paper trade sheet | BUY / SELL qty, notional — mock only |
| Ticket | Share sheet + copy instruction + full footer |
| ~~Paywall~~ | Do not build. Nothing is for sale; this screen is a store rejection |
| Scoreboard | Public. “Insufficient history” until 10 resolved |
| Account | Profile, preferences, sign out. No plan management |
Navigation
- Bottom tabs: Hotstocks · Chat · Desk · Watch
- Stack: ranking → stock → trade sheet → ticket
- No upgrade modal — there is nothing for it to open.
- Chat is a first-class tab. Beginners start here.
Auth on device
- Email / password → Better Auth bearer (see Auth).
- “Continue with …” →
ASWebAuthenticationSession/ Chrome Custom Tabs. - Store the token in Keychain / EncryptedSharedPreferences.
- Signing in syncs the account's watchlist, paper book and notices. It unlocks nothing, because nothing is locked — a signed-out visitor already sees the whole book.
In-app purchase: not applicable. There is no digital content to unlock and no second price to implement, in StoreKit, Play Billing or anywhere else. Do not add one.
World Monitor on mobile
<iframe
src="https://www.worldmonitor.app/embed?layers=conflicts,earthquakes,weather,waterways,economic¢er=48.809,0.421&zoom=1.63&theme=light&variant=full"
title="World Monitor live map"
allowfullscreen>
</iframe>Native: WKWebView / WebView with that URL. Height ≥ 420pt. Theme light on the white desk. Do not invert the licensed map. Server-side CII goes through WORLD_MONITOR_API_KEY only — the app never ships that key.
Desk chat on mobile
POST /api/chat with { question, history }. Show related[] as tappable names.
- Every caller gets
advice+fiveDayPcton each card; tap →/stock/{symbol}.related[].lockedis present and alwaysfalse. - Daily cap: 4 signed out, 40 signed in. Surface
remainingunder the composer. That cap is abuse protection on a metered LLM endpoint, not a gate on content — say "come back tomorrow", never "upgrade". degraded: truemeans no model answered. Label it "the desk is offline"; never render it as a reply.
Offline
Tape is live. If the device is offline, show the last cached ranking with a stale dateline. Never pretend a cached 5-day call is new. Issued tickets may live in local storage.
Push (later)
Not wired. The server already stores user_pref.alerts_enabled. Register a device token against a future POST /api/alerts/device. Until then, in-app alerts on World are enough. Alerts are available to every signed-in account.
Deep links
| URL | Screen |
|---|---|
https://<host>/ | Ranking |
https://<host>/stock/AAPL | Apple |
https://<host>/chat | Desk chat |
https://<host>/t/{id} | Public ticket |
watstock://stock/AAPL | Same, custom scheme |
Stores
| Store | Bundle | Notes |
|---|---|---|
| Apple App Store | app.watstock.ios (placeholder) | Export compliance: HTTPS only |
| Google Play | app.watstock | Data safety: account email, no bank numbers |
| Feynman App Store | slug watstock2 | Web build; native apps are separate |
Privacy nutrition: we store email, paper book, watchlist, chat questions, and (for accounts that subscribed before the product went free) a historical subscription id. We do not store bank credentials, and we take no payment.
Suggested app repo layout
apps/mobile/
app/ # Expo Router or native coordinators
src/api/ # copied api-client + types
src/theme/ # tokens from src/styles.css
src/screens/ # Ranking, Stock, Chat, World, Desk, TicketKeep the mobile repo thin. New scoring logic lands here (watstock2), not in the app.
Build order for the mobile team
- SDK copy + golden tests, written for the free product: the ranking JSON carries every rank 1…N for a signed-out caller;
lockedis present on every row and alwaysfalse;watchlistLimit: nulldecodes to unlimited, not 10. - Ranking + stock — the whole book, no paywall step.
- Desk chat — Hormuz starter must return mapped names.
- Paper desk + ticket share — the “send to bank” moment.
- World Monitor tab — official embed +
/api/geo. - Sign-in + account sync (watchlist, paper book, notices).
5. Desktop
Tauri 2 wrapping the production web origin (Electron is acceptable).
- Window 1280×800 default, min 390×640.
- Persist cookies for the API origin.
- World Monitor and the user's bank or broker open in the system browser.
- About box: “Advisory only. We do not execute.”
- Deep link
watstock://stock/AAPL→/stock/AAPL. - Never embed
MASSIVE_API_KEY,XAI_API_KEY, or Stripe secrets in the binary.
6. HTTP API
Base URL: local http://127.0.0.1:8080, production the public host, App Store https://www.feynmanlabs.com/apps/watstock2.
All JSON. Accept: application/json. Web: credentials: "include". Native: Authorization: Bearer <token> plus optional Feynman identity headers.
Errors: { "error": "human message" } with 4xx/5xx.
Market
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /api/status | no | Feed health |
| GET | /api/tape?ranking=1 | no | Full-universe ranking. Ungated |
| GET | /api/tape?symbols=AAPL,MSFT | no | Watchlist slice (max 50) |
| GET | /api/stock?symbol=AAPL | no | One name. The real prediction, for everyone |
| GET | /api/scoreboard | no | Public record |
| GET | /api/geo?country=US | no | World Monitor layer |
| GET | /api/ticket/:id | no | Public verification |
Ranking: every name, rank 1…N, full 5-day fields, to everyone — signed in or not. No reserve window, no visibility cut-off, no redaction; the response does not depend on the session. locked is on every row and is always false: the key stays because shipped mobile builds read it per row, and an absent key would reach them as null. A client that drew a lock on locked === true simply never draws one.
Accuracy: accuracy.ready === false → “insufficient history”. Never print % on total < 10.
Chat
| Method | Path | Notes |
|---|---|---|
| GET | /api/chat | { remaining, limit, used, tier, signedIn, aiReady } |
| POST | /api/chat | Body { question, history?, threadId? } |
| GET | /api/chat/history?limit=20 | Signed in only. Stored threads, newest first. 401 signed out |
| DELETE | /api/chat/history/:id | Signed in only. { ok: true }. 404 when not the caller's |
Response:
{
"reply": "Yes — the World Monitor sleeve is marking the Strait…",
"model": "grok-4.5",
"related": [
{
"symbol": "XOM",
"name": "Exxon Mobil",
"locked": false,
"advice": "BUY",
"fiveDayPct": 1.8,
"why": "Chokepoints: Hormuz disruption 70"
}
],
"topics": ["hormuz"],
"remaining": 3,
"limit": 4,
"unlockCalls": true,
"compliance": "WATSTOCK provides research…"
}unlockCalls is always true and related[].locked always false; both keys stay on the wire for shipped clients. 429 when the daily cap is used — abuse protection on a metered endpoint, not a paywall, and it resets daily. Guests get an HttpOnly cookie ws_chat_sid. Questions are written to chat_query (desk intelligence).
If XAI_API_KEY is missing — or the model call fails or comes back empty — the server does not answer. It returns degraded: true, a reason of "no_key" | "upstream" | "empty", model: "desk-fallback" and a status note that echoes the question and lists the parsed channels / mapped names. Label it as "the desk is offline"; never render it as a reply. A degraded turn is not stored in history. Do not mock a second chat in the app.
Signed-in turns that a model actually answered are stored: the response carries threadId + threadTitle, and sending that threadId back on the next POST /api/chat appends to the same thread. Guests get threadId: null (a rotating ws_chat_sid cookie is not a durable identity).
Identity & billing
| Method | Path | Notes |
|---|---|---|
| GET | /api/me | { authenticated, user: { id, email, name, image, source } } |
| * | /api/auth/* | Better Auth |
| GET | /api/billing/me | Always 200, never 500. Everyone → unlocked, tier: "pro" |
| POST | /api/billing/checkout | Closed. 410 on every call. Nothing is for sale |
| POST | /api/billing/portal | Legacy. Lets a pre-free subscriber cancel. Not an upgrade path |
| POST | /api/billing/webhook | Stripe only — refunds and cancellations on historical records. Clients never call this |
Watstock is free, so /api/billing/* exists for webhooks and historical records and nothing else. No client may link to checkout; the route answers 410 precisely so a reachable purchase path cannot come back. The five historical fields (plan, planLabel, status, cancelAtPeriodEnd, currentPeriodEnd) describe a subscription row that may still exist for accounting — they never decide access.
`tier` must stay `"pro"` — re-read the warning at the top of this handbook before changing anything here.
Paper desk (authenticated)
Starting cash $100,000. Mock fills at last print.
| Method | Path |
|---|---|
| GET | /api/paper |
| POST | /api/paper/order |
| POST | /api/paper/ticket |
| GET | /api/paper/tickets → { tickets } (own, newest first, max 40) |
| GET | /api/paper/ticket/:id → one ticket, 404 when not the caller's |
| POST | /api/paper/reset |
GET /api/paper does not carry tickets — read those from the two GETs above. A ticket is instruction text for the user's own bank; nothing here routes or settles it.
On the Feynman host, send X-Feynman-User-Id (and email/name) because the proxy may strip fl_session.
Watchlist, alerts, cron
Watchlist is REST for native and server-functions for web, over one implementation:
| Method | Path |
|---|---|
| GET | /api/watchlist → { symbols, limit } — limit is always null now (unlimited) |
| PUT | /api/watchlist/:symbol → { symbols } |
| DELETE | /api/watchlist/:symbol → { symbols } |
Every reply carries the full list, newest first. The watchlist is unlimited, so the 409 { ok: false, error: "limit", limit } branch is unreachable: the cap it compares against is Number.POSITIVE_INFINITY. The branch is kept — in the route and in the web server-functions (listWatchlist, addWatch, removeWatch) — because shipped clients handle it, and a client that never receives it never shows it. Do not re-derive a finite cap to make the branch live.
A first GET /api/watchlist seeds 8 starter names (STARTER_WATCHLIST in src/lib/entitlements.ts). That is a starting point, not a cap — nothing stops the ninth add. Re-adding a name already on the list is a 200 no-op.
GET/POST /api/alerts — 401 when signed out, otherwise on for every account. The old 402 "Alerts are a Pro feature" is gone.
| Method | Path |
|---|---|
| GET | /api/alerts/settings → { alerts, resolvingOnWatched, weeklyEmail, allowed } |
| PUT | /api/alerts/settings → the full four-key object (body is any subset of the three stored flags; allowed is read-only) |
| GET | /api/notifications?limit=50 → { items, unread } |
| POST | /api/notifications/read → { unread } (body { ids } or { all: true }) |
Settings and the inbox are open to every signed-in account. allowed on the settings response is TIERS[tier].alerts resolved per request, which is now always true; the key stays for client compatibility, and a client that paired allowed: false with an upgrade prompt simply never renders one. Senders still go through alertsActiveFor(userId) in `src/lib/notifications.server.ts` — the stored opt-in and the capability, re-resolved at send time — because an opt-out must be honoured at the moment of sending. That is a consent rule, not a tier rule. Two producers write notices via createNotice: issuing a bank ticket (kind: "ticket", links to /desk) and the nightly resolver, which notifies every watcher of a symbol whose call just resolved (kind: "call", links to /stock/<SYMBOL>, honours user_pref.resolving_on_watched, capped at 500 notices per run). Both swallow their own failures to console.error, so a notice write can never break the ticket issue or the cron run. studio, move and ranking have no producer yet.
POST /api/cron/resolve + x-cron-secret — ops only. Returns { ok, written, notified }: written is the prediction_outcome rows inserted, notified the watcher notices written for them. The fan-out runs between the resolve and the scoreboard refresh on purpose — once the outcome rows are committed, that run is the only one that can ever notify those calls, so nothing fallible may sit in front of it.
Email delivery
A notice exists in the app the moment createNotice writes it. Mailing it is a separate, scheduled step, so nothing user-facing ever waits on a mail provider.
| Method | Path | Notes |
|---|---|---|
| POST | /api/cron/alerts | one email per undelivered notice → { ok, scanned, sent, skipped, failed } |
| POST | /api/cron/weekly | ranking digest, max once per recipient per UTC day → same shape |
| GET · POST | /api/unsubscribe?token=… | one-way off switch; no session, signed token only |
Both cron routes take x-cron-secret and fail closed: no CRON_SECRET, 503 and nothing sent. ?limit= is optional and clamped to 1000; the default is 200.
dispatchPendingAlerts (`src/lib/alerts/dispatch.server.ts`) claims then sends. alert_delivery (migrations/0015_alert_delivery.sql) holds one row per decision — sent, failed *or* deliberately skipped, with the reason — and a partial unique index on (user_id, notice_id, channel) is what makes "never twice" true rather than likely. The row is inserted before sendMail, so of two overlapping runs only the one that won the insert sends anything. A retryable mail failure is the single case that deletes its claim; everything else keeps its row and is never attempted again. Every attempt also carries a deterministic Idempotency-Key derived from the rendered message, so neither the mailer's own single retry nor a re-queued run can put two copies in an inbox. The weekly digest has its own partial unique index on (user_id, channel, UTC day) where notice_id is null.
The gate is alertsActiveFor(userId) and nothing else — stored preference AND capability, re-resolved per send, so anyone who has not opted in gets a skipped row, not an email. kind: "call" additionally honours resolving_on_watched. A notice older than 72 hours is never mailed: the ledger is new, and the pre-existing notice backlog must not go out as one batch.
No mailer configured is a counted no-op, per hasStripe() / hasXai() — 200 with sent: 0 and a note, no DB work, nothing thrown. hasMailer() needs RESEND_API_KEY and MAIL_FROM; a key without a From address still degrades to zero.
Every message carries COMPLIANCE_FOOTER (the dispatcher re-checks the rendered text and refuses to send without it), a visible unsubscribe link, and the RFC 8058 pair List-Unsubscribe + List-Unsubscribe-Post — both headers or neither. Copy reports what the desk published; it never advises, never implies execution, and carries no urgency. /unsubscribe is the human landing page and is not behind sign-in. Email can only be turned back on from /account.
Operator schedule: resolve then alerts nightly (resolve writes the call notices alerts mails), weekly once a week. Needs CRON_SECRET, RESEND_API_KEY, MAIL_FROM, SITE_URL and a real BETTER_AUTH_SECRET — see docs/api-reference.md for the exact calls.
7. Auth & identity
One member, two doors.
| Source | How the API sees the user | Id format |
|---|---|---|
| Watstock Better Auth | Session cookie or Authorization: Bearer | Better Auth user id |
| Feynman Labs App Store | fl_session and/or X-Feynman-User-* | fl:<store-user-id> |
GET /api/me returns source: "watstock" | "feynman". Paper book and subscriptions key off that id. Do not create a second paper book if the same human uses both doors in v1.
Native session (recommended)
POST /api/auth/sign-in/emailwith{ email, password }.- Persist
session.tokenin the Keychain. - Send
Authorization: Bearer <token>on every/api/*call. - OAuth: system browser + claimed HTTPS redirect. Never embed Google in a WebView.
Sign out: POST /api/auth/sign-out and drop the bearer. Feynman logout is a store concern — stop sending X-Feynman-User-*.
8. Entitlements
Single source of truth: `src/lib/entitlements.ts`. Full page: entitlements.
Watstock is free. There is no gating left to implement. Every capability is unlocked for every caller.
| Capability | Signed out | Signed in |
|---|---|---|
| Weekly ranking | full book, rank 1…N | full book, rank 1…N |
| 5-day BUY / HOLD / SELL + path | yes | yes |
| Daily sentiment + World Monitor read | yes | yes |
| Public scoreboard, tickets with a verification URL | yes | yes |
| Watchlist | — (needs an account) | unlimited, 8 seeded |
| Paper book, alerts, notices | — (needs an account) | yes |
| Desk chat | 4 / day | 40 / day |
Signing in is about having an account — a watchlist, a paper book, stored chat threads — not about unlocking content. The chat cap is abuse protection on a metered LLM endpoint, not a gate.
What survives in the code, and why
TIERS still exists with both keys fully unlocked — TIERS.free and TIERS.pro are literally the same object. Neither key is deleted, because dozens of call sites index TIERS[tier] and a shipped Android build reads the pro answer. The same goes for locked on every prediction row (always false), allowed on alert settings (always true), unlockCalls (always true) and reserved (always 0). Values change; keys do not. Removing a key is a breaking change for a binary nobody can force-update.
The one thing that will bite you
`resolveTier` returns `"pro"` for everyone and must keep doing so. The shipped Android decoder reads watchlistLimit: isPro ? _nullableInt(json) : _int(json, 10) — null is unlimited only on the isPro branch, and is coerced to 10 on the free branch. "Tidying" the string to "free" because the product is free silently re-gates every installed Android user. See the warning at the top of this handbook and in entitlements.
This is the web/mobile split in practice: on the web we redeploy and a wrong word is gone; on mobile a wrong wire value is permanent for anyone who does not reinstall.
No upgrade surfaces
UpgradeSheet renders null, /pricing has no prices, and POST /api/billing/checkout answers 410. Do not add an upsell, a price, a "Pro" badge or an unlock action anywhere — a paywall, or Stripe used to unlock digital content, in a free app is an App Store rejection.
WATSTOCK_FORCE_TIER is still read so existing harnesses keep working, but it can only name a tier; it can never take access away.
9. Desk chat (web + app)
Beginners ask the desk. The book answers. This is a product surface and an intelligence feed (chat_query).
Flow:
- Parse the question for tickers and topics (Hormuz, energy, defense, cyber…).
- Map onto the licensed book (
MAPPED_BOOKinequity-layer.ts) — Hormuz →USO,XOM,CVX,FRO, airlines as the other side. - Pull live World Monitor scores +
predictManyon up to 8 covered names. - Return the real rows. Nothing is redacted —
related[].lockedisfalseandadvice/fiveDayPctare always present. - Answer with Grok (
grok-4.5) whenXAI_API_KEYis set; otherwise the desk fallback, which still uses the live layer.
History (web)
/chat renders the signed-in user's stored threads from GET /api/chat/history: a sidebar on large screens, the same panel inside the drawer below lg. Opening a thread replays its turns and makes it the thread the next question appends to (the id goes back up as threadId), so a conversation stays one thread instead of one thread per question. The trash control calls DELETE /api/chat/history/:id.
Behaviour worth keeping if you touch it:
- The query is
enabledonly when signed in. A guest must never fire it — the 401 is not an error worth surfacing, it just means there is no history. - The panel shows its "not available right now" line only when there is nothing already on screen (
errored && threads.length === 0). The query is invalidated after every answered question and every successful delete, andretry: falsemakes one blip terminal, so an unguarded error branch would blank a good cached list. - The delete callbacks compare against a ref, not the
threadIdcaptured whenmutate()ran. Open and delete are disabled only while an answer is landing, not while a DELETE is in flight, so the reader can open a different thread mid-request; comparing the stale id would wipe a live transcript and silently fork a new server thread on the next question. - Guests still get a working desk. Nothing on this panel is required to ask a question.
Do not build an on-device LLM. Do not let the model invent tickers outside the universe. Do not tell anyone to upgrade when they hit the daily cap — say "come back tomorrow".
10. Design system
White paper + cyan. Not cream newsprint. Not a dark terminal.
Copy these tokens into Swift / Compose / RN. Do not approximate.
| Token | Hex | Use |
|---|---|---|
paper | #FFFFFF | Page |
ink | #071018 | Headlines |
fg | #141A20 | Body |
muted | #5C6772 | Kickers |
faint | #8B959E | Placeholders |
line | #E2E7EC | Rules |
accent | #00A4D1 | Actions, 3px masthead rule |
accent-deep | #0086AD | Pressed |
buy | #0F8A4B | BUY, up |
sell | #CC2F3A | SELL, down |
hold | #6B7280 | HOLD |
Type: Newsreader (display) · IBM Plex Sans (UI) · IBM Plex Mono (prices, %, ranks). Kickers: 11px, 0.1em, uppercase.
Components: newspaper tags for signals (not neon pills); the ranking table shows every row with no lock banner and no ghost tickers; no price card and no upgrade sheet; ticket is print-like.
Motion: short fades. Respect prefers-reduced-motion. No count-up casinos on PnL.
Wordmark: watstock™ in ink on white. Store name: Watstock 2.
11. Environments & secrets
| Variable | Required | Purpose |
|---|---|---|
MASSIVE_API_KEY / POLYGON_API_KEY | live tape | US prints and bars |
HF_TOKEN | recommended | FinBERT |
WORLD_MONITOR_API_KEY | layer | CII + features |
XAI_API_KEY | chat (optional) | Grok; fallback still answers |
DATABASE_URL | prod | Postgres |
BETTER_AUTH_SECRET / BETTER_AUTH_URL | prod auth | Session |
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET | legacy | Server only. Webhooks + pre-free subscription records |
STRIPE_PRICE_WEEKLY / STRIPE_PRICE_MONTHLY | legacy | Price ids. Nothing is for sale |
CRON_SECRET | prod | /api/cron/resolve; required by /api/cron/alerts and /api/cron/weekly (503 without it) |
RESEND_API_KEY / MAIL_FROM | Outbound mail. Both, or hasMailer() is false and dispatch is a no-op | |
SITE_URL | Absolute links and unsubscribe URLs (falls back to BETTER_AUTH_URL, then PUBLIC_URL) | |
WATSTOCK_FORCE_TIER | legacy | Read, but can only name a tier — never removes access |
PORT / HOST / BASE_PATH | App Store | Store assigns |
Watstock is free and Stripe is optional. Nothing is gated with or without those keys; they matter only where subscription records from before the change still live.
Set these as masked, protected GitLab CI/CD variables and on the Feynman App Store env API. Never commit .secrets. Never put keys in a mobile binary.
| Name | API origin | Notes |
|---|---|---|
| Local | http://127.0.0.1:8080 | Stripe off. Nothing is gated either way |
| Staging | Feynman watstock2 or a staging host | WATSTOCK_FORCE_TIER can name a tier but never removes access |
| Production | Public Watstock host | Stripe keys only where pre-free records live |
Point every unreleased binary at staging. A leaked Massive key from a debug APK is an incident.
12. QA before you merge or ship
Web
- Home as anonymous: the full ranking,
rank1…N, no gaps and no lock banner. /stock/AAPLsigned out: the real prediction,locked: false, forecast path present./chat→ “Strait of Hormuz”: World Monitor numbers + mapped names, withadviceandfiveDayPcton the cards.- Walk every surface for a price, a "Pro" badge, an upgrade prompt or a checkout link. There must be none.
POST /api/billing/checkoutanswers 410. - Paper desk: one BUY; ticket text includes “does not execute”.
/t/{id}loads from the immutable row.- 390×844 (mobile web): no horizontal scroll; tab bar and chat composer clear the home indicator.
npm run typecheckandnpm run buildpass.
Mobile (native)
- Same ranking JSON test as web (golden fixture): every rank present,
locked: falseon every row. GET /api/billing/me→tier: "pro",watchlistLimit: null, and the app shows an unlimited watchlist — not 10. This is the decoder trap; test it on a real build, not just in a unit test.- Hormuz chat starter maps to energy names, with the 5-day call visible.
- No paywall screen, no upgrade modal, no "manage plan" row in Account.
- Ticket share includes the full compliance footer.
- World Monitor embed loads
theme=light. - Offline: stale banner, no “fresh” 5-day claim.
- VoiceOver / TalkBack on the three signal tags.
These are two test plans on purpose. The web list checks a layout we can redeploy; the mobile list checks a decoder we cannot.
13. What not to build
- A paywall, a price, a "Pro" badge, an upgrade sheet, or a checkout of any kind.
- A
resolveTierthat answers"free". It re-gates every installed Android user. - A decoder that coerces a
nulllimit to a number.nullmeans unlimited. - Removing
tier,locked,allowed,unlockCallsorreservedfrom a response because they are now constants. Shipped clients read them. - A second predictor (on-device FinBERT, “offline AI”).
- Brokerage connectors (Plaid invest, Alpaca, IBKR).
- Editable history of calls.
- Dark or newsprint themes that fight the white + cyan desk unless product asks.
- StoreKit / Play Billing prices. There is nothing to sell.
- Accuracy percentages on fewer than 10 resolved calls.
- Chat answers that invent tickers.
The scoreboard’s credibility is the company. If a client ships execution, custody, or a fabricated accuracy number, pull the build and notify product.