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

Recurring Reports: A Series on Top of One-Shot Schedules

Wave 11: a report that re-runs weekly, bi-weekly, monthly or quarterly, with Claude writing up what changed since the last run. The main design choice was to not build a second scheduler, and that one choice kept every existing billing and allowance rule working untouched.

BUILT · SEPTEMBER 2026 ClientWeb · AdminWeb · MAUI

What Was Already There

  • One-shot scheduling. Each schedule row owns one pre-created job in a ScheduledWaiting state. The schedules engine releases due jobs and re-stamps their start time, so a report counts against the free-tier allowance of the month it runs in (a fix made after launch, when scheduling far ahead was a way to skip the monthly cap). A backstop at fire time fails the job if the account has lost its allowance since.
  • Report Comparison. A manual Claude comparison of two reports, with an estimate charge at submit, reconciled to actual token use afterwards (80% markup, $0 on failure) and capped at $45.
  • Job notifications. The report engine emails or texts a job's recipients when it starts and when it finishes.

Decision: Keep Exactly One Future Run Materialized

The textbook design is a recurrence engine: store the rule and have a scheduler fire it. That would have meant re-implementing the run-month allowance, the fire-time backstop, the Scheduled Reports page and the job hand-off. Every one of those is a billing rule, and every copy of a billing rule is a chance for the two to disagree.

Instead, a recurring schedule is a series, and the schedules engine keeps exactly one upcoming run of each active series as an ordinary one-shot schedule plus a waiting job, created through the same service the UI uses. As a result:

  • the free-tier run-month allowance and the fire-time backstop apply unchanged;
  • the next run appears on the existing Scheduled Reports page, and deleting it there skips just that one run;
  • the report engine, the per-symbol report charge and the output files don't know recurring reports exist.

The next run is created strictly after max(now, last run). A series that was paused for three months therefore resumes with one run, not a burst of three make-up runs, and a skipped month can't loop back on itself.

PER-RUN STATE MACHINE
rendering diagram…
stateDiagram-v2
    [*] --> Scheduled: engine creates the next run
    Scheduled --> Running: job released at run time
    Scheduled --> Skipped: removed from Scheduled Reports,<br/>allowance used, or lists deleted
    Scheduled --> Cancelled: series paused, edited or deleted
    Running --> ReportFailed: job ended with errors
    Running --> Completed: auto-compare off
    Running --> AwaitingComparison: auto-compare on
    AwaitingComparison --> Completed: analysis done,<br/>one combined notification
    Completed --> [*]
    ReportFailed --> [*]
    Skipped --> [*]
    Cancelled --> [*]
Each run is a row in recurringreportruns, which is also the Report History timeline the user sees. The engine pass moves rows forward every 30 seconds in a fresh dependency-injection scope.

Date Math That Doesn't Drift

RecurringReportCadence computes run n from the original local anchor (anchor + n·7d, + n·14d, AddMonths(n), AddMonths(3n)) and only then converts to UTC in the schedule's time zone. It never adds one period to the previous run. That rules out two classic bugs:

  • Month-end drift. "Monthly on the 31st" lands on Feb 28 and returns to Mar 31. Chaining from the previous run would stick on the 28th forever.
  • DST creep. A 9:00 run stays at 9:00 local time across daylight-saving changes. A time that falls inside a spring-forward gap runs just after it.
  • Pinned by 8 cadence tests, alongside 17 state-machine tests that run the engine pass over EF InMemory.

Automatic Change Analysis, One Notification

When a run's report succeeds and auto-compare is on, the pass finds the comparison base (the previous successful run of the series, or for a new series the latest earlier report of exactly the same lists), checks the $45 cap against the standard estimate, and submits through the same comparison path a manual comparison uses: same entitlement check, same estimate charge, same reconcile-to-actual billing. The DOCX lands in the existing Report Comparisons folder as {schedule} - change analysis {date}.docx, with the blob path prefixed by the comparison job id so two runs on one day can't collide.

A small decision that matters to users: with auto-compare on, the scheduled job is created with no recipients, so the report engine stays quiet. The pass sends one email or text when the report and its analysis are both ready (or a report-only or failure notice that says why). Otherwise every run would send three messages. Recipients are stored as ids and re-checked against the account's current addresses at send time, so a removed phone number doesn't keep getting texts.

A Recurring Charge Needs Explicit Consent

A one-off report shows its price before you click. A recurring one charges again every week, so the schedule dialog shows the report cost per run, the analysis estimate per run, the total per run, the projected monthly cost (52/12, 26/12, 1 or 1/3 runs a month), and the next four run dates. The analysis estimate comes from the size of the account's last report of the same lists when there is one, and otherwise from a conservative per-symbol heuristic capped at the comparison console's ~100k-token prompt limit.

When the per-run total is above $0, the schedule can't be saved until an "I understand charges apply" box is ticked, and any change to what's being bought (lists, cadence, model) clears the tick. Free tier: recurring reports are allowed, and each run uses the free report for the month it runs in (runs in a month already used are recorded as Skipped). Auto-compare is paid-only: refused with a 402 at save, and checked again at run time in case the account has downgraded since.

Caveats, Stated

  • Series are defined on symbol lists, not position reports. The roadmap said "per position report", but ML reports run on symbol lists and comparisons group runs by symbol-list set. A position-report entry point can come once position reports feed report generation.
  • Texts aren't billed per message yet. That matches existing job notifications. Pay-as-you-go Alerts, since built, kept report notifications transactional: free, and never counted against an alerts spend cap.
  • Migration first. Three new tables plus one nullable column, applied by hand before the API, schedules engine or comparison console deploys (migrations/ isn't auto-applied; an unapplied column once broke every account read in prod).
  • Found in self-review: updating a paused series skipped the check that its symbol lists belong to the caller's account (only active saves were validated). Fixed with a test before merge.