Wiki · Workflow companion

Annual price roll (SY rollover)

July 1, every customer × SKU price recomputes for the next school year. The single biggest cascade on the platform — durable execution via the AnnualRollWorkflow class, bulk HITL per customer cohort, NS bulk push, R2 PDF generation, and a pricing_history snapshot.

Stub · Contract present, runner stub-only
What this is

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.

When to use it

Trigger conditions

Heuristic

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.

Step-by-step what happens

The 3 beats

  1. 01

    Launch AnnualRollWorkflow durable execution

    The runner emits an ANNUAL_ROLL_WORKFLOW binding invoke with {next_sy, markup_rules}. Inside the class, the four pillars (context → cost → margin → fan-out) execute as step.do blocks with per-step retries.

    Writes annual_roll_workflow_runs
    Time ~120 min wall
    Kind workflow_class_invoke
    Status stub
    Note native CF retry policy
  2. 02

    Per-customer cohort notification

    Each customer cohort batch generates a draft email staged in outbound_email_log with status=pending_review. Mike approves one cohort at a time — bulk-approve is available but defaults to single-decide for the roll.

    Writes outbound_email_log
    Time per cohort
    Kind hitl_email_draft
    Status stub
    Note one_per cohort
  3. 03

    Render per-customer quote PDFs

    For every approved cohort, an HTTP POST to /api/quote/pdf renders a fresh SY quote PDF and stores it at quotes/{id}.pdf in R2. Browser Rendering binding is the engine.

    Writes R2 bucket quotes/
    Time ~6–10s per quote
    Kind http_call
    Status stub
    Note one_per cohort
Outcomes

What's different after the workflow runs

New SY pricing
Persisted
≤ 60 min visible in pricing_master
NS bulk push
Drained
≤ 4h end-to-end
pricing_history
Snapshotted
fresh audit row per cohort
Quote PDFs
Rendered
R2 versioned per customer
Failure modes

What can go wrong and how to recover

Cost rollup is stale (>7d)

Preconditions block the launch. Mike re-runs the cost rollup first via recompute_assembly_cost_rollup, then re-launches.

Per-cohort email approval stalls

The workflow class blocks at step.waitForEvent. The roll won't advance for that cohort until Mike approves. Other cohorts continue.

NS push queue saturates

The drainer is rate-limited — bursts can stretch the 4h SLA to 6–8h. Acceptable; the workflow is durable.

Kill switch tripped mid-roll

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.

Related

Adjacent workflows + diagrams

For developers

Code paths + invariants

ConcernWhere
Workflow contractworkflow_definitions WHERE workflow_type='annual_price_roll'
Native classsrc/annual_roll_workflow.ts
BindingANNUAL_ROLL_WORKFLOW (wrangler.jsonc)
Kill switchkill:high_risk_ops via /admin-dashboard.html
Reflexion tagannual_roll, sy:<next_sy>
Cron0 8 1 7 * America/New_York
Risk level5
Expected duration~240 min
Triggercron · 0 8 1 7 * (America/New_York) · manual trigger allowed
// Pillar 4 launch — durable execution const handle = await env.ANNUAL_ROLL_WORKFLOW.create({ params: { next_sy, markup_rules, cohort_size } }); // Kill switch is checked in preconditions before launch if (kills.has('high_risk_ops')) throw new Error('kill switch tripped');