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 |
Dated trail · spot stale claims
Dated trail of when this doc was last touched, what changed, and what to look at if it feels stale.
| Date | Round | Change | Touched by |
|---|---|---|---|
2026-05-26 | R586 | Added CHANGELOG · SCHEMA · RUNBOOK · BACKLOG sections — wiki became best-in-class operating documentation. | Mike + Claude |
2026-05-25 | R584/R585 | Wiki originally shipped — 8-section structure (hero / what / when / steps / outcomes / failure-modes / related / for-developers). | Mike + Claude |
workflow_definitions WHERE workflow_type='annual_price_roll' before acting on these claims.The machine-readable spec
Canonical fields, table names, endpoint signatures. What code should match, what tests should assert. workflow_type · annual_price_roll · risk_level · 4.
Inputs (required + optional)
| Field | Type | Description |
|---|---|---|
effective_date | date | Usually July 1 (SY anchor). Required. |
scope | string | 'all' | 'bid:<id>' | 'customer:<id>' |
dry_run | boolean | Default true — preview before commit. |
inflation_pct | decimal? | Optional global escalator. |
D1 tables written
| Table | Operation | Trigger |
|---|---|---|
proposed_actions | INSERT (one per item) | Often thousands of rows in a single roll |
pricing_master | UPDATE (post-HITL) | Each line, after Mike approves |
pricing_history | INSERT (one per change) | Audit |
annual_roll_runs | INSERT | Workflow class state — Pillar 4 Workflows binding |
Endpoints called
| Method | Path | Purpose |
|---|---|---|
POST | /api/annual-roll/start | Kicks off the Workflow class |
GET | /api/annual-roll/status/:run_id | Live progress |
POST | /api/annual-roll/:run_id/commit | After preview review, commit |
Events fired
| event_type | When | Subscribers |
|---|---|---|
annual_roll.started | On invocation | audit |
annual_roll.preview_ready | All rows staged in proposed_actions | Mike notification |
annual_roll.completed | Final commit settled | downstream margin re-baseline |
It broke at 2am — what now
Different from "how do I use this." This is the page Mike pulls up when something is wrong: logs to check, recovery steps, who to escalate to.
Scenario · Annual roll Workflow stuck mid-run (Workflows binding hung)
CF Workflows occasionally hang on long-running steps. State is durable.
- Check status:
GET /api/annual-roll/status/<run_id> - Inspect step: Workflows engine UI in CF dashboard shows the failing step.
- Resume: Workflows auto-resumes on next ping. Manual retry:
POST /api/annual-roll/<run_id>/resume
Scenario · Roll proposed 500 rows but Mike only saw 50 in queue
Likely the queue UI paginates at 50. Or the proposed_actions risk_level filter is hiding them.
- Count actually staged:
SELECT COUNT(*) FROM proposed_actions WHERE related_run_id='<run_id>' AND status='pending' - Pagination: Use bulk-decide page or query directly.
- Bulk approve:
POST /api/proposed-actions/bulk-decidewith cumulative-cap check.
Scenario · Some items rolled, some didn't — partial commit
A subset of NS pushes failed and the run halted, leaving D1 ahead of NS.
- Find the gap:
SELECT * FROM ns_pending_pushes WHERE related_run_id='<run_id>' AND status='failed' - Retry:
POST /admin/ns-push/retry?action_id=<id>per failed row. - Reconciliation cron: Picks up failed pushes every 15 minutes anyway.
Logs to check
workflow_run_log· top-level run auditworkflow_step_log· per-step traceworkflow_verify_results· post-window verify outcomescron_locks· stuck cron lock detectionevents· workflow.completed / workflow.failed event trailreflexion_log· per-run narrative (if reflexion_enabled)npx wrangler tail· live Worker logs
Kill switch · emergency stop
If this workflow is misbehaving in a high-impact way (creating bad proposed_actions in volume, pushing wrong things to NS), flip a kill switch:
kill:ns_writes· stops every NS push platform-widekill:proposed_apply· stops HITL approvals from executing fan-outkill:high_risk_ops· stops risk_level >= 4 fan-out
See kill-switches-state-machine.html for the full state machine + recovery procedure.
Escalation
Primary: Mike Levine (single-admin) · mikelevine@globalfoodsolutions.co. For prolonged outage during business hours, notify warehouse lead + accounting lead so they can defer dependent work.
What's not done · what's uncertain
What's not done, what's uncertain, what we punted. Captured so it survives context switches and doesn't die in someone's head.
-
OPEN
Per-category escalators
Today single inflation_pct. Should be per-product-category (proteins differ from grains).
-
STUB
Customer-specific opt-outs
No table for 'customer X is locked at last year's price'. Mike does this in his head.
-
DEFER
Roll preview as PDF
Today Mike previews in the dashboard. A side-by-side PDF would help.
-
DECISION
What if roll happens mid-quote?
Quote was generated at price A; roll happens to price B before customer accepts. Honor quote or roll? Today honor quote; could be configurable.