Annual price roll (SY rollover)
The annual price roll is the load-bearing workflow of the GFS calendar. Once a year, on July 1, every active customer × SKU pricing record needs to be recomputed for the new school year (SY2026 → SY2027, and so on). The roll touches more rows in one pass than every other workflow combined.
Because the blast radius is so large, this is the one workflow that runs on a native Cloudflare Workflow class — AnnualRollWorkflow — for durable execution. step.waitForEvent gates per customer cohort, so a crash or a deploy mid-roll resumes from the last committed step instead of starting over.
The contract is stored in workflow_definitions WHERE workflow_type='annual_price_roll'. Risk level 5 (the maximum). Expected duration ~4 hours wall clock from launch to last customer email approved.
Trigger conditions
- Annual cron fire on July 1 at 08:00 ET — the default path.
- Manual trigger from /annual-roll-control.html when the calendar slips or a re-run is needed.
- Cost-basis refresh completed across all assemblies and Mike wants the new floor.
- Customer-program tier change that ought to roll into next SY pricing.
The kill switch kill:high_risk_ops is checked in preconditions. If anyone flips it from /admin-dashboard.html the launch refuses, even on the cron path.
The 3 beats
-
01
Launch AnnualRollWorkflow durable execution
The runner emits an
ANNUAL_ROLL_WORKFLOWbinding invoke with{next_sy, markup_rules}. Inside the class, the four pillars (context → cost → margin → fan-out) execute asstep.doblocks with per-step retries. -
02
Per-customer cohort notification
Each customer cohort batch generates a draft email staged in
outbound_email_logwithstatus=pending_review. Mike approves one cohort at a time — bulk-approve is available but defaults to single-decide for the roll. -
03
Render per-customer quote PDFs
For every approved cohort, an HTTP POST to
/api/quote/pdfrenders a fresh SY quote PDF and stores it atquotes/{id}.pdfin R2. Browser Rendering binding is the engine.
What's different after the workflow runs
- Every active customer has a new pricing_master row for the next school year.
- pricing_history holds a complete before/after snapshot stamped with Mike's cohort approvals.
- NetSuite itempricing records reflect the new values within the NS push drain window.
- A customer-specific PDF quote is live in R2 per cohort.
- reflexion_log carries a tagged row per cohort for next-year prep.
What can go wrong and how to recover
Preconditions block the launch. Mike re-runs the cost rollup first via recompute_assembly_cost_rollup, then re-launches.
The workflow class blocks at step.waitForEvent. The roll won't advance for that cohort until Mike approves. Other cohorts continue.
The drainer is rate-limited — bursts can stretch the 4h SLA to 6–8h. Acceptable; the workflow is durable.
Cleanest recovery: let the in-flight cohort finish, do not approve the next email, examine reflexion_log + workflow_run_log, then resume manually after fix.
Adjacent workflows + diagrams
Code paths + invariants
| Concern | Where |
|---|---|
| Workflow contract | workflow_definitions WHERE workflow_type='annual_price_roll' |
| Native class | src/annual_roll_workflow.ts |
| Binding | ANNUAL_ROLL_WORKFLOW (wrangler.jsonc) |
| Kill switch | kill:high_risk_ops via /admin-dashboard.html |
| Reflexion tag | annual_roll, sy:<next_sy> |
| Cron | 0 8 1 7 * America/New_York |
| Risk level | 5 |
| Expected duration | ~240 min |
| Trigger | cron · 0 8 1 7 * (America/New_York) · manual trigger allowed |