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');
Changelog

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.

DateRoundChangeTouched by
2026-05-26R586Added CHANGELOG · SCHEMA · RUNBOOK · BACKLOG sections — wiki became best-in-class operating documentation.Mike + Claude
2026-05-25R584/R585Wiki originally shipped — 8-section structure (hero / what / when / steps / outcomes / failure-modes / related / for-developers).Mike + Claude
If today is more than 60 days past the latest changelog row, treat live system behavior as the source of truth. The doc may have drifted — verify against the workflow contract in workflow_definitions WHERE workflow_type='annual_price_roll' before acting on these claims.
Schema · data contract

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)

FieldTypeDescription
effective_datedateUsually July 1 (SY anchor). Required.
scopestring'all' | 'bid:<id>' | 'customer:<id>'
dry_runbooleanDefault true — preview before commit.
inflation_pctdecimal?Optional global escalator.

D1 tables written

TableOperationTrigger
proposed_actionsINSERT (one per item)Often thousands of rows in a single roll
pricing_masterUPDATE (post-HITL)Each line, after Mike approves
pricing_historyINSERT (one per change)Audit
annual_roll_runsINSERTWorkflow class state — Pillar 4 Workflows binding

Endpoints called

MethodPathPurpose
POST/api/annual-roll/startKicks off the Workflow class
GET/api/annual-roll/status/:run_idLive progress
POST/api/annual-roll/:run_id/commitAfter preview review, commit

Events fired

event_typeWhenSubscribers
annual_roll.startedOn invocationaudit
annual_roll.preview_readyAll rows staged in proposed_actionsMike notification
annual_roll.completedFinal commit settleddownstream margin re-baseline
Runbook · when it breaks

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.

  1. Check status: GET /api/annual-roll/status/<run_id>
  2. Inspect step: Workflows engine UI in CF dashboard shows the failing step.
  3. 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.

  1. Count actually staged: SELECT COUNT(*) FROM proposed_actions WHERE related_run_id='<run_id>' AND status='pending'
  2. Pagination: Use bulk-decide page or query directly.
  3. Bulk approve: POST /api/proposed-actions/bulk-decide with 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.

  1. Find the gap: SELECT * FROM ns_pending_pushes WHERE related_run_id='<run_id>' AND status='failed'
  2. Retry: POST /admin/ns-push/retry?action_id=<id> per failed row.
  3. Reconciliation cron: Picks up failed pushes every 15 minutes anyway.

Logs to check

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:

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.

Backlog · open questions

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.