GMI · TECHNOLOGY OBSERVATORY // ALL SYSTEMS NOMINAL
ENGINEERED BY LEOPARD DATA

Pay-As-You-Go Alerts: Every Message Metered

Grade changes, macro regime shifts, earnings, price moves and news on the things you follow, delivered by email or text and billed per message the way GMI is billed for sending them. Wave 24 is built across four phases; this page records what was built, what the build changed from the design, and what was left out.

BUILT · SEPTEMBER 2026 In final testing ClientWeb · AdminWeb · MAUI

Status: Phases 0–3 are built and deploying to production on 29 September 2026 for final testing. That makes them built, not shipped: shipping is a separate call made after testing. Negative-news alerts ship switched off behind a kill switch until operations turns them on.

Not built: MAUI push notifications (the design made them conditional on everything before them being done), the ML-health-flip event (the grade cache holds no per-symbol ML output to detect a flip in), and the holdings-vs-regime tripwire (deferred). Alerts replaced two earlier roadmap waves: Wave 12 (price & technical alerts) and Wave 22 (tracked-symbol watch). Both are now event types in this one system.

The System in One Paragraph

Every alert is a metered message. Each email or text sent on a user's behalf is recorded in a delivery ledger with our cost and their price. Each day's paid deliveries roll up into one Notification charge per account, which the existing monthly billing engine invoices unchanged, so there's no new payment flow. A small free catalogue (grade changes on tracked symbols, macro regime and grade changes, a weekly digest) is on for every account by default, with one-click unsubscribe. Everything else is opt-in per event, per scope and per channel, behind a "charges apply" consent screen that shows the price list, a live monthly estimate from the user's own lists, and a monthly spend cap ($5 by default) the system can't exceed, even when several detectors fire at the same moment.

AS BUILT
rendering diagram…
flowchart TB
    subgraph DET[Detectors - write facts once]
      D1[gmi-seopagebuilder<br/>grade history rows]
      D2[Macro engine runs<br/>regime, releases, sectors]
      D3[gmi-notifier daily jobs<br/>prices, earnings, analyst,<br/>portfolio GPA]
      D4[News classifier<br/>lexicon + Haiku, ships off]
    end
    EV[(alertevents<br/>unique dedupe key,<br/>formula version)]
    D1 --> EV
    D2 --> EV
    D3 --> EV
    D4 --> EV
    EV --> N[gmi-notifier dispatcher]
    N --> M[Match subscriptions,<br/>stock overrides win,<br/>one alert per account]
    M --> S[Suppress formula-only moves,<br/>kill-switched types, opt-outs]
    S --> TX
    subgraph TX[AlertLedger - one transaction]
      C[Conditional UPDATE<br/>on the cap counter]
      L[(alertdeliveries<br/>unit cost + unit price)]
      Q[EmailToSend or SmsToSend row]
    end
    TX --> SEND[Senders re-check<br/>preferences and STOP]
    L --> R[Nightly rollup:<br/>one Notification charge<br/>per account per day]
    R --> B[Existing monthly<br/>billing engine]
Detectors write facts once, however many users follow them. The dispatcher matches subscriptions, and AlertLedger admits each delivery in one transaction: the cap update, the ledger row and the outbound email or SMS row commit together or not at all. The senders enforce preferences and STOP again at send time.

The Cap Is One Conditional UPDATE

A spend cap is a promise: "you'll never pay more than $5 this month." The obvious implementation, read the month-to-date total, compare, then insert, breaks under concurrency. The earnings job, the end-of-day price job and the news detector can all admit deliveries for the same account in the same second, and each would read the same total and conclude there's room.

The build keeps a running counter on the account's settings row (SpendMonth, MtdSpend) and makes the check and the increment a single statement:

UPDATE alertsettings
   SET MtdSpend   = (CASE WHEN SpendMonth = @month THEN MtdSpend ELSE 0 END) + @price,
       SpendMonth = @month
 WHERE AccountID = @account AND PaidEnabled = 1
   AND (CASE WHEN SpendMonth = @month THEN MtdSpend ELSE 0 END) + @price <= MonthlyCapAmount

One row affected means admitted; zero means CapBlocked. The month rollover is folded into the same statement, so there's no midnight reset job to race against. The update runs in the same transaction as the ledger insert and the outbound EmailToSend / SmsToSend row, so what's billed and what's queued can't diverge. A duplicate delivery (the ledger is unique per account, event and channel) rolls the whole transaction back, counter included. Messages later suppressed or failed refund the counter and are never billed.

Test: Ledger_ConcurrentDetectors_NeverOvershootTheCapResult
40 parallel admits of a $0.06 text against a $1.00 capexactly 16 admitted, 24 CapBlocked
Month-to-date counter afterwards$0.96 (the 17th would have been $1.02)
Counter vs. sum of the ledger's sent rowsequal

Caveat: the unit test runs against SQLite, not MySQL. The guarantee comes from single-statement atomicity of a conditional UPDATE, which both engines provide (InnoDB takes a row lock for it), not from anything SQLite-specific. Free alerts never touch the counter and are never blocked. Users get a free notice at 80% and 100% of their cap.

The Decisions Worth Defending

DecisionAlternativeWhy
Events are separate from deliveries (alertevents with a unique dedupe key, then alertdeliveries) Detect per subscriber A grade change is detected once, however many users follow the stock. Detectors are idempotent on the key, so a retried run can't double-send.
One rollup charge per account per day One charge row per message An active user could get hundreds of messages a month. Invoices stay readable, and the ledger keeps the full itemization behind a delivery-history page.
Free catalogue as three flags on alertsettings (changed in the build) Subscription rows, as the design drafted A missing settings row means "defaults", which makes the free alerts default-on for every existing account with no backfill migration. Unsubscribing flips one flag.
Paid detectors run inside gmi-notifier as once-a-day jobs (changed in the build) Spread across the schedules engine and other workers All alerts code lives in one service, restart-safe through alertdetectorstate. Each job looks only at symbols that accounts with paid alerts follow for that event type, so market-data cost scales with paying subscribers, not with the universe.
Prices live in ServicePricing (price and cost per unit) Constants in code This is how report pricing already works, so prices can change without a deploy and realized margin can be computed per delivery on the admin operations page.
Commodity and dollar moves via ETF proxies (USO, GLD, CPER, UUP) The macro commodities feed That feed is monthly. The ETFs are already in the daily quote batch, so "gold moved 3% today" costs nothing extra.
gmi-notifier calls services directly Call the API over HTTP The platform rule that background processes never talk to their own HTTP API.

The Formula-Change Trap, and Why the Regrade Didn't Cause an Alert Storm

Grade-change alerts are the one alert only GMI can send: "the A we gave this company in June is now a B, and here's the factor that moved." They also had a failure mode that would have fired in the same deploy. Wave 19 moved the grade formula from 1.0 to 1.1 by neutralizing two factors for financial companies, and it shipped to production alongside Alerts. Under a naive design, every user following a bank would have been emailed that day about a change in GMI's formula, not in the company.

Two rules in the build prevent that:

  • Formula-version guard. gmi-seopagebuilder writes a publictickergradehistory row (stamped with AiGradeFormulaVersion) only when a ticker's grade, score, factors or formula version changes. Two consecutive rows with different versions produce no company alerts, and one announcement per version pair ("We updated how GMI grades are calculated, formula 1.0 to 1.1"), deduplicated by a key like formula:1.0->1.1.
  • First row is a baseline. A symbol's first history row never alerts, because there's nothing to compare it with. The history table is new, so on first deploy every ticker's first row is a baseline, whichever formula version it carries.

Together they mean the regrade and the alerts system could ship in the same week in either order: if the history starts at 1.1, there's nothing to compare; if a 1.0 row exists, the version guard suppresses the move. Detecting what changed had to take into account that GMI changes too.

Phase 0: The Compliance Gaps Found Before Building

Before designing a single paid alert, the design surveyed the email and SMS paths GMI already had. The survey found four gaps, and the build found a fifth. Two of them were a present-day risk, because job notifications already texted numbers that had never been verified. So Phase 0 shipped first, and it was worth doing even if alerts had never launched:

FoundBuilt
Email preferences were stored but never enforced. The flags were editable, and no producer or sender read them.User-facing mail is tagged with an account and a category; gmi-emailsender checks the preference at send time and records a SuppressedReason. Enforcing at send time covers every producer, including ones written later.
No unsubscribe, and Gmail and Yahoo now require one-click unsubscribe of bulk senders.A signed, non-expiring token (HMAC-SHA256 over account and category) in every footer, plus List-Unsubscribe and List-Unsubscribe-Post headers. GET shows a confirmation page; only POST applies it, per RFC 8058. Corporate link scanners prefetch every URL in an email, and a GET that unsubscribed would opt people out without them clicking anything. No expiry, on purpose: a link in a two-year-old email has to keep working.
SMS verification existed only in the UI. The client called verification endpoints the API didn't have, and the phone table had no verified or consent columns.Server-side send-verification and verify: a 6-digit code stored hashed, 10-minute expiry, 5 attempts; VerifiedAt, ConsentAt, OptedOutAt on the number. Paid SMS goes only to verified, consented numbers.
No STOP/HELP handling for inbound texts.A Twilio inbound webhook. It has to be anonymous to the platform's auth middleware, so the X-Twilio-Signature (HMAC-SHA1 of the URL and form fields with the auth token) is its authentication, and unsigned requests are rejected. STOP opts the number out on every account that has it, and gmi-smssender refuses opted-out numbers for all texts, including job notifications, not just alerts. START restores; HELP replies with program info.
Found during the build: the email-preferences endpoints had no ownership check, so an authenticated user could read or change another account's preferences.Ownership checks added (admin-or-owner, the same rule every account-scoped endpoint uses).

Senders now also record the provider message id (SendGrid X-Message-Id, Twilio SID), so each ledger row's estimated cost can later be reconciled with what the provider actually charged. The consent record (accepted text, price list, the estimate shown, cap, timestamp, IP, terms version) is append-only and serves as both the billing authorization and the SMS-consent evidence.

Pricing: An Ambiguity Resolved, and the Honest Economics

The brief said the price should give "about 80% profit". The platform's existing Claude billing uses an 80% markup (cost × 1.8, about a 44% margin); read literally, 80% profit is an 80% margin (cost × 5). Per-message prices differ by nearly 3× between the two readings, so the design showed both columns and asked instead of guessing. The decision was margin:

UnitEst. costPrice (cost × 5)The markup reading would have been
Email (an instant alert, or a whole digest)~$0.0008$0.004$0.0015
SMS, per segment~$0.0115$0.06$0.02
News screening surcharge (only when Claude was asked)~$0.001$0.005$0.002

The economics, stated plainly: a typical engaged user lands around $1–3 a month, and almost all of it is SMS, because email costs close to nothing to send. The free catalogue costs GMI under a cent per free user per month. It's a retention and trust feature that pays for itself, not a big revenue line. The existing rule that waives invoices under the $0.50 Stripe minimum means a light alerts-only user effectively pays nothing; that's accepted and noted rather than hidden. Paid alerts need a card on file and the consent screen, and adding a card makes the account Paid tier, which is the existing model.

Negative News: Precision First, and Shipped Off

This is the only alert that needed new intelligence, and the one most likely to embarrass the product. The design principle: one absurd alert costs more trust than ten misses. So the classifier is built for precision:

  • A lexicon pass decides the clear cases using whole-word matches, with guards for figurative use ("plunges into a new market") and market-roundup headlines ("5 stocks to watch", "Nasdaq falls"), which aren't news about one company. A headline is clearly negative only when it has several strong negative terms, nothing positive, and names the company.
  • Only the ambiguous middle goes to Claude Haiku, with a strict prompt ("bad news about this company") and a JSON verdict. An alert fires only when the article is negative and about the company and confidence is at least 0.85.
  • Bounded cost. It reads only newly cached articles (under 2 days old) for symbols that paid accounts follow for news. There's a per-cycle cap on Claude calls: when it's spent, the detector stops without advancing its watermark, so the next article is judged next cycle instead of skipped. The first run sets a baseline and never alerts on history. The $0.005 surcharge applies only to alerts Claude actually adjudicated.
  • Ships kill-switched. Users can select it, but it stays off until someone reviews real verdicts and turns it on from the admin Alerts Operations page. The threshold starts high and gets tuned down later, not the other way round.

What Each Phase Built

PhaseBuilt
0Compliance foundation: preferences enforced at send, HMAC one-click unsubscribe, server-side SMS verification, signed STOP/HELP/START webhook, provider message ids, the ownership fix
1The spine: 9 tables, gmi-notifier (new console, systemd unit and CI steps), AlertLedger, nightly rollup, cap notices, grade history and the formula-version guard, the free catalogue, daily and weekly digests (one email, never empty), quiet hours, nightly recalibration of the estimator's event frequencies; Alerts home, consent, event picker (Quiet / Informed / Active presets) and delivery history on all three apps; AdminWeb operations with realized margin, top spenders, cap hits, STOPs and per-event kill switches
2Paid detectors: price moves (3% noise floor, keyed by trade date so holidays never re-alert), 52-week highs and lows, 50/200-day crosses, drawdown from recorded cost, earnings reminders and results, analyst consensus changes, portfolio and list GPA moves, macro release briefings and reminders, sector-grade changes, commodity and dollar moves; the SMS channel
3The negative-news classifier (shipped off). Not built: MAUI push, the ML-health event, the holdings-vs-regime tripwire

106 unit tests cover the spine, compliance, the paid detectors and the classifier, including a test that fails on "should", "consider", "recommend", "buy now" or "sell now" in any alert template. Every template follows the platform's voice rule: "AAPL moved from A− to B+; the largest factor change was debt-to-equity", never "consider selling". The build added 17 HTTP endpoints, none under the Functions host's reserved admin/ prefix.