Bid Center — Path 3 · ★ July 1 rollover

cron OR manual → enumerate 69 customers → build named price list payloads → batch HITL → NS_PUSH_QUEUE drain → NS records updated

The annual flip. Once per year on July 1 (cron 0 5 1 7 *) or by manual trigger, the Bid Center converts an entire year of accumulated bid work into next year's NetSuite named price lists. The prepare_next_school_year Workflow enumerates 69 active customers, picks each customer's latest SY-version, applies the HPS MAP GPO allowance program where applicable, and builds 69 proposed NS named price list payloads. All 69 land in a single batch proposed_actions row with shared batch_id='jul1-2026'. On approval, NS_PUSH_QUEUE drains 69 writes (one per customer, gated by PushMutexDO). Partial failures loop back. Customer service sees the new named list on the next NS customer open. First live deploy is July 1, 2026.

workflow contract batch HITL (ADR-031) ★ T-35d to first live deploy · 2026-07-01 audited 2026-05-27 — see gaps section
As of 2026-05-27 · T-35d to rollover
This doc describes the target contract — not what is built today. Reality audit (2026-05-27):

Bottom line: the rollover can run today, but only as a manual-trigger pricing-derivation workflow against last-12-months invoices — not as the bid-curated, NS-price-list cohort flow described below. Closing the gap requires either implementing the gaps or rewriting this doc to match what ships. See Gaps section at end.

Pipeline — ★ annual flip

idle
Bid Center path 3 - July 1 rollover - cron 0 5 1 7 * fires (or manual) - cron lock acquired - prepare_next_school_year workflow starts - enumerate 69 customers - per-customer latest SY-version - apply programs sidecar - build NS named price list payload - stage batch proposed_action with 69 rows - Mike batch HITL approve - NS_PUSH_QUEUE drains - PushMutexDO per customer - POST /api/ns/push/named-price-list per customer - NS PriceList written - on success mark done + event - on fail requeue or surface in partial-failure UI - cohort complete event - customer service sees new list - next SO reads new list - customer_health recomputes - cron lock released - customer + SY-version threaded throughout LANE A / Trigger + workflow start LANE B / Per-customer payload build (loop x 69) LANE C / ★ Batch HITL gate (single proposed_action, 69 line items) LANE D / NS_PUSH_QUEUE drain (PushMutexDO per customer) LANE E / Closeout (events, customer health, cron lock release) WHAT THIS DOES: On July 1 at 05:00 UTC, the cron fires. Mike can also trigger manually for dry runs or off-cycle rollovers. D1 TABLE: cron_locks (about to be acquired) TECHNICAL DETAILS: TRIGGER FIRES CRON schedule: 0 5 1 7 * (Jul 1 05:00 UTC) defined: wrangler.toml [triggers.crons] MANUAL POST /api/bid/rollover/start?dry_run=false GUARDS rate_limit, X-Edit-Token for manual mode STATUS: REAL (contract) - first live Jul 1 2026 ★ trigger: cron 0 5 1 7 * OR manual Jul 1 anchor · first live 2026 manual via /api/bid/rollover/start FINANCE-KEY · annual flip i WHAT THIS DOES: Acquire a cron_locks row to prevent double-run (e.g. cron + manual at the same time). Released at the end of the path. D1 TABLE: cron_locks (INSERT, cron_name='bid_rollover') TECHNICAL DETAILS: ACQUIRE CRON LOCK LOCK INSERT INTO cron_locks (cron_name, locked_at, expires_at) VALUES ('bid_rollover', now(), now()+'6 hours') COLLISION if already locked: abort with already-running STATUS: REAL acquire cron lock cron_name='bid_rollover' 6h TTL · collision check SECURITY i WHAT THIS DOES: prepare_next_school_year Workflow instance starts. Cloudflare Workflows class (src/annual_roll_workflow.ts) handles step durability. D1 TABLE: workflow_run_log (INSERT) TECHNICAL DETAILS: WORKFLOW STARTS CLASS src/annual_roll_workflow.ts WORKFLOW prepare_next_school_year (workflow_type) STEP DURABILITY CF Workflows native checkpointing STATUS: REAL (shell) - first live Jul 1 2026 prepare_next_school_year starts src/annual_roll_workflow.ts CF Workflows class BACKEND · workflow i WHAT THIS DOES: SELECT all active customers. 69 active today. Filter excludes archived. D1 TABLE: bid_customers (READ WHERE status='active') TECHNICAL DETAILS: ENUMERATE 69 ACTIVE CUSTOMERS QUERY SELECT customer_id FROM bid_customers WHERE status='active' ORDER BY customer_id COUNT 69 STATUS: REAL enumerate 69 active customers bid_customers WHERE status='active' cohort = 69 DATABASE i WHAT THIS DOES: For each customer, look up the latest SY-version (where is_latest=1). This is the customer's "current quote" - what gets pushed to NS. D1 TABLE: bid_quote_versions (READ) TECHNICAL DETAILS: PICK LATEST SY-VERSION QUERY SELECT sy_version FROM bid_quote_versions WHERE customer_id=? AND is_latest=1 INVARIANT exactly one is_latest=1 per customer STATUS: REAL pick latest SY-version per customer bid_quote_versions WHERE is_latest=1 trace key: customer + SY-version DATABASE i WHAT THIS DOES: For each customer, apply the HPS MAP GPO allowance program (SY2526 / SY2627). Programs adjust per-item pricing for compliance reasons. D1 TABLE: bid_programs (READ) TECHNICAL DETAILS: APPLY PROGRAMS SIDECAR PROGRAMS HPS MAP GPO allowance SY2526, SY2627 LOOKUP bid_programs WHERE customer_id=? AND sy_version=? EFFECT per-item adjustment applied to bid_price_snapshots STATUS: REAL apply HPS MAP GPO programs SY2526 / SY2627 allowance per-item price adjust MESSAGEBUS · compliance i WHAT THIS DOES: Build the NS named-price-list payload for this customer: header (customer, named_list_name, effective_date), lines (sku, price, UOM). Store in scratch until staged. D1 TABLE: scratch in workflow state TECHNICAL DETAILS: BUILD NS NAMED PRICE LIST PAYLOAD SHAPE { customer_id, named_list_name: '<customer> <sy_version>', effective_date: '2026-07-01', source_sy_version: <sy_version>, lines: [{sku, price, uom, brand, allowance_applied}] } SIZE ~127 commercial items x 8 brands minus customer overrides STATUS: REAL build NS named-price-list payload customer + SY-version + lines + allowance named_list_name = '<customer> <sy_version>' BACKEND i WHAT THIS DOES: Steps 5-7 repeat for each of 69 customers. CF Workflows checkpoints between iterations so a Worker restart resumes mid-loop. D1 TABLE: workflow_step_log (per iteration) TECHNICAL DETAILS: LOOP x 69 STEP DURABILITY each iteration is a workflow step on restart resume at last completed customer STATUS: REAL loop x 69 (CF Workflows step-durable) resume mid-loop on Worker restart BACKEND i WHAT THIS DOES: All 69 payloads are staged as a single batch with shared batch_id='jul1-2026'. Each customer is a line item; UI allows approve-all, approve-subset, reject per customer. D1 TABLE: proposed_actions (INSERT 69 rows with shared batch_id) TECHNICAL DETAILS: STAGE BATCH PROPOSED_ACTION INSERTS for each customer: INSERT INTO proposed_actions kind='ns_named_price_list', batch_id='jul1-2026', customer_id, sy_version, payload (JSON), status='pending', risk_level=5 INVARIANT (ADR-031) no NS write without an approval row UI cohort card with 69 expandable line items STATUS: REAL (contract) ★ stage batch_id='jul1-2026' with 69 line items proposed_actions cohort · per-customer rows under one batch_id ADR-031: no NS write without approval · risk_level=5 FINANCE-KEY · THE batch HITL gate i WHAT THIS DOES: Admin dashboard surfaces the cohort. Mike sees a single banner ("69 named price lists ready to write back to NS - approve?") with expandable per-customer line items. D1 TABLE: proposed_actions (READ for dashboard) TECHNICAL DETAILS: NOTIFY MIKE SURFACES admin-dashboard.html cohort panel optional: email digest COPY "69 named price lists ready to write back to NS - approve?" STATUS: REAL (contract) - cohort UI thread Agent N owns notify Mike (cohort banner) "69 ready to write back - approve?" CLOUD · UI: Agent N i WHAT THIS DOES: Mike opens the cohort panel. Can: approve all 69, approve a subset (e.g. 65 + send 4 back for review), or reject individual customers. X-Edit-Token required. D1 TABLE: proposed_actions (UPDATE status per row) TECHNICAL DETAILS: MIKE APPROVES COHORT ACTIONS approve-all -> status='approved' on all 69 approve-subset -> status='approved' on selected, 'rejected' or 'pending_review' on rest reject-row -> status='rejected' GUARD X-Edit-Token + audit trail STATUS: REAL ★ Mike approves cohort (all / subset / reject) X-Edit-Token + per-row decision FINANCE-KEY · HITL decision i WHAT THIS DOES: For each approved row, INSERT into ns_pending_pushes. NS_PUSH_QUEUE drainer picks up rows in FIFO order. D1 TABLE: ns_pending_pushes (INSERT one row per approved customer) TECHNICAL DETAILS: ENQUEUE NS_PUSH_QUEUE INSERT ns_pending_pushes record_type='named_price_list', customer_id, payload (JSON), proposed_action_id, batch_id, status='queued' STATUS: REAL enqueue NS_PUSH_QUEUE (one row per approved customer) ns_pending_pushes FIFO MESSAGEBUS · durable queue i WHAT THIS DOES: PushMutexDO is a Durable Object keyed by customer_id. Drainer must acquire the mutex before writing - prevents two concurrent writes to the same NS customer. DURABLE OBJECT: PushMutexDO (one instance per customer_id) TECHNICAL DETAILS: PushMutexDO NAMESPACE bind='PUSH_MUTEX' KEY customer_id INTERFACE POST /acquire?customer=X&ttl=300 POST /release?customer=X STATUS: REAL acquire PushMutexDO per customer prevents concurrent NS writes for one customer SECURITY · mutex i WHAT THIS DOES: POST /api/ns/push/named-price-list per customer with the payload. Server-side double-checks the approved proposed_action exists. The NS RESTlet writes the PriceList record. ENDPOINT: POST /api/ns/push/named-price-list?preview=false&confirm=true TECHNICAL DETAILS: POST NS PUSH HEADERS X-Edit-Token Content-Type: application/json BODY customer_id, sy_version, named_list_name, lines[] NS RESTlet customscript_gfs_platform_push_pricelist STATUS: REAL (contract) POST /api/ns/push/named-price-list preview=false · confirm=true · X-Edit-Token BACKEND · per customer i WHAT THIS DOES: NS RESTlet writes the custom PriceList record and updates customer.pricelevel pointer. NS RECORDS: custom PriceList record + customer.pricelevel TECHNICAL DETAILS: NS RESTlet WRITES PriceList RECORDS custom named price list (one per customer per SY) customer.pricelevel = id of new named list STATUS: REAL NS RESTlet writes PriceList custom record + customer.pricelevel BACKEND · NS write-back i WHAT THIS DOES: On 2xx success: mark done. On non-2xx: backoff retry up to 3x; on terminal failure, surface in the cohort partial-failure UI so Mike can re-approve or fix. D1 TABLES: proposed_actions (UPDATE status), ns_pending_pushes (UPDATE attempts) TECHNICAL DETAILS: SUCCESS / FAIL BRANCH SUCCESS (2xx) proposed_actions.status='done' emit events.bid.namedlist_pushed (customer_id, sy_version) FAIL (non-2xx) ns_pending_pushes.attempts++ backoff: 1m, 5m, 30m terminal -> status='partial_failure', surface in cohort UI STATUS: REAL 2xx done · non-2xx requeue / partial-failure backoff 1m / 5m / 30m · terminal -> partial-failure UI SECURITY · escalation i WHAT THIS DOES: When all 69 are either done or partial_failure, fire cohort complete event. D1 TABLE: events TECHNICAL DETAILS: COHORT COMPLETE EVENT EVENT bid.rollover_completed (batch_id, total, succeeded, failed) SUBSCRIBERS analytics, customer_health (price stability) STATUS: REAL events.bid.rollover_completed cohort-level event MESSAGEBUS i WHAT THIS DOES: Customer service opens the customer record in NS the next morning and sees the new named price list attached. Next SO entered for the customer reads the new pricing. NS RECORDS READ: customer.pricelevel + PriceList TECHNICAL DETAILS: CS SEES NEW NAMED LIST INTERACTION CS opens NS customer record sees customer.pricelevel = new named list any new SO entered uses new prices STATUS: REAL CS sees new named list in NS customer.pricelevel reads new list BACKEND · loop closed i WHAT THIS DOES: The next Sales Order entered for that customer reads the new named price list - new SY-version prices automatically flow downstream into the SO / Item Fulfillment / Invoice chain. NS RECORDS WRITE: SalesOrd (reads new pricelevel) TECHNICAL DETAILS: NEXT SO READS NEW LIST INTERACTION SO entered -> reads customer.pricelevel -> new named list prices STATUS: REAL next SO reads new pricing flows into SO / IF / Invoice chain BACKEND i WHAT THIS DOES: customer_health watcher consumes the rollover event and re-runs the price-stability signal for each affected customer. D1 TABLE: v_customer_health (refresh) TECHNICAL DETAILS: CUSTOMER_HEALTH RECOMPUTE SIGNAL price stability per customer SUBSCRIBES bid.rollover_completed STATUS: REAL customer_health recomputes price stability signal CLOUD i WHAT THIS DOES: cron_locks row released so the next year's run (or a manual re-trigger) can proceed. D1 TABLE: cron_locks (DELETE) TECHNICAL DETAILS: RELEASE CRON LOCK ACTION DELETE FROM cron_locks WHERE cron_name='bid_rollover' STATUS: REAL release cron lock cron_name='bid_rollover' SECURITY i WHAT THIS DOES: Customer + SY-version is the trace thread across the entire path - from bid_quote_versions through proposed_actions, ns_pending_pushes, and into the NS PriceList record. EXAMPLE: ACC Distributors / SY2627-v2.5 -> named_list_name 'ACC SY2627 v2.5' in NS TECHNICAL DETAILS: TRACE THREAD KEY customer_id + sy_version CARRIED THROUGH bid_quote_versions -> proposed_actions -> ns_pending_pushes -> NS PriceList.name STATUS: REAL customer + SY-version threading e.g. ACC Distributors / SY2627-v2.5 FINANCE-KEY · trace thread i Jul 1 fires 69 customers customer + SY-version cohort of 69 PROPOSED approve mutex acquired partial-failure loop cohort done
Glossary
Database / D1
Backend (Worker, NS RESTlet)
Cloud (dashboard, customer_health)
Messagebus (ns_pending_pushes, events)
Mutex / cron lock / partial-failure escalation
★ NS write-back gate
batch_id: cohort identifier (e.g. jul1-2026)
PushMutexDO: per-customer Durable Object mutex
partial-failure loop: failures re-surface as a smaller cohort for re-approval

Tables, endpoints, code paths

kindnamepurpose
Cron0 5 1 7 *Jul 1 05:00 UTC
Workflow classsrc/annual_roll_workflow.tsprepare_next_school_year
D1 tablecron_locksprevents double-run
D1 tablebid_customers69 active cohort
D1 tablebid_quote_versionsis_latest=1 per customer
D1 tablebid_programsHPS MAP GPO allowance
D1 tableproposed_actionsbatch_id='jul1-2026'
D1 tablens_pending_pushesNS_PUSH_QUEUE
Durable ObjectPushMutexDOper-customer mutex
EndpointPOST /api/bid/rollover/startmanual trigger
EndpointPOST /api/ns/push/named-price-listper-customer NS write
EndpointPOST /api/proposed-actions/bulk-decidecohort approve / subset / reject
NS RESTletcustomscript_gfs_platform_push_pricelistwrites NS PriceList
Eventbid.namedlist_pushedper customer success
Eventbid.rollover_completedcohort done

★ Gaps to close before rollover (audit 2026-05-27 · T-35d)

Items below were identified by comparing this doc to the actual implementation in src/annual_roll_workflow.ts, src/index.ts, wrangler.jsonc, and migrations 117-144. Mike needs a decision per row: (A) ship the code to match the doc, (B) rewrite the doc to match reality, or (C) run the rollover with current scope and defer the rest.

#GapDoc claimReality (2026-05-27)SeveritySuggested action
1 Cron trigger 0 5 1 7 * Cron fires Jul 1 05:00 UTC Not in wrangler.jsonc[triggers.crons]. Workflow is manual-only. BLOCKING Add the cron OR commit to "Mike-initiated only" and update the doc + runbook. If cron is desired, add cron handler in scheduled() that calls the annual-roll start endpoint with school_year='SY2728', cutover_date='2026-07-01'.
2 Workflow class name prepare_next_school_year Class instantiated as prepare_next_school_year Class is AnnualRollWorkflow (binding ANNUAL_ROLL_WORKFLOW). The skill file PREPARE_NEXT_SCHOOL_YEAR.md is an operator skill, not a Workflows class. HIGH (terminology) Rename in this doc + wiki, OR rename the class. Choose one source of truth.
3 Endpoints /api/bid/rollover/start, /api/ns/push/named-price-list, /api/ns/push-queue/drain Listed in tables + runbook + diagram None exist. Wired endpoints: POST /api/workflows/annual-roll/start, POST /api/workflows/annual-roll/approve/<run_id>. Push queue drain happens implicitly via NS_PUSH_QUEUE consumer; no admin force-drain endpoint. BLOCKING Replace doc endpoints with the real ones. The runbook scenarios that reference POST /api/ns/push-queue/drain?batch_id=jul1-2026 will fail with 404 if Mike runs them under stress.
4 Batch HITL via proposed_actions with shared batch_id='jul1-2026', 69 line items, approve-all/subset/reject-row Diagram steps 8-11 + cohort UI panel + POST /api/proposed-actions/bulk-decide Workflow uses a single step.waitForEvent('annual_roll:admin_approval', timeout: '30 days') with one decision payload {decision: 'approved'|'rejected', notes}. There is no per-customer cohort UI, no 69-row staging, no approve-subset path. /api/proposed-actions/bulk-decide exists generally but is not what the annual roll uses. BLOCKING Either (a) stage 69 proposed_actions rows in step 5 + replace the waitForEvent with a poll-on-cohort-status, building the partial-approve UI; OR (b) accept the current "all-or-nothing per run" gate and remove all subset-approval language from the doc + wiki + runbook scenario 4.
5 69-customer cohort source: bid_customers WHERE status='active' + bid_quote_versions WHERE is_latest=1 Diagram steps 4-5 Workflow enumerates SELECT DISTINCT customer FROM customer_pricing_proposed WHERE source='workflow:annual_roll' after writing rows via a 12-month invoice median (step 2 in the code). It does not read bid_quote_versions at all. The Bid Center curated state and the rollover input are decoupled. BLOCKING (architecture) Decide: do we roll curated bids (what the doc says, what the new R612 NS Price Roll tab stages) or derived invoice medians (what the code does)? The two answers can disagree by hundreds of $/customer. Mike must pick before Jul 1.
6 NS named-price-list records + customer.pricelevel pointer + RESTlet customscript_gfs_platform_push_pricelist Diagram steps 13-15 + tables row "NS RESTlet" Push payload is {target_table:'pricing_customer_prices', action:'bulk_replace', ns_record_type:'customer', push_uuid, ...} to RESTlet #2948 (the existing SuiteAPI RESTlet). No "named price list" custom record exists in NS today. customer.pricelevel is not touched by the workflow. BLOCKING (NS-side) Confirm with Mike: does NS have a custom PriceList record type provisioned, and does #2948 or a new customscript_gfs_platform_push_pricelist handle it? If no — either build the NS RESTlet + custom record, or pivot the doc to describe "per-customer item-pricing bulk_replace" which is what actually ships.
7 R612 NS Price Roll 26-27 tab at gfs-pricing.pages.dev/#nsroll is the operator surface for staging Implied as the pre-rollover prep surface (not yet in diagram) No #nsroll hash route in the gfs-pricing Pages project. Bid Center has /customers, /bids, /items, /price-matrix, /regional, /coverage, /compare per admin-dashboard hub links (lines 933-941), but nothing labeled "NS Price Roll" or "rollover staging." HIGH Either ship the R612 tab before Jul 1 (operator must have a place to review 69 drafts) OR remove the reference. Without it there is no pre-flight surface; Mike is approving a workflow run blind.
8 R616 variance flag (10% AND $5) pre-push Implied as the gate on outliers in NS Price Roll tab Workflow does flag outliers in step 4, but the threshold is a single 15% drift via drift_threshold_pct param. No $5 absolute floor; no dual-threshold AND logic. Flags land in pricing_anomalies, not surfaced in any "NS Price Roll" tab. MEDIUM Update drift_threshold_pct caller to pass 10 AND add AND ABS(cpp.derived_price - cp.price) > 5.0 to the WHERE clause in step 4 SQL. Surface the flagged rows in the NS Price Roll tab (depends on gap #7).
9 R621 dynamic SY label (SY2627 → SY2728 auto-bump on Jul 1) Implied — doc currently still says SY2627 throughout Dashboard auto-detects the current SY (per Mike's reference). The workflow takes school_year as a param; nothing in the workflow code itself bumps version labels on each customer. The doc still hard-codes SY2627, batch_id='jul1-2026', "ACC SY2627 v2.5" — these are stale by Jul 1. MEDIUM Doc-side: replace hard-coded SY2627 with SY<current>→SY<next> notation. Code-side: confirm versionHistory table receives a row per customer per rollover (not visible in current workflow steps 8-11; customer_pricing_history is global, not per-customer-version).
10 Customer readiness flags reset on Jul 1 (clear prior year, set new year = false until reviewed) Step in Mike's verbatim description; not currently in the diagram No such DB fields exist. Grepped sy_readiness, ready_for_rollover, readiness_flag, readiness_status across migrations 117-144 + src/index.ts — zero hits. The "verified for new SY" concept lives only in skill docs. BLOCKING Add a migration: ALTER TABLE customers ADD COLUMN sy_readiness TEXT (or per-customer rows in customer_sy_readiness(customer_id, sy_version, status, verified_at, verified_by)). Add reset step at workflow start (clear) + close (verify). Without this, "readiness" is folklore.
11 R614 sync-verify + healthcheck post-rollover Mentioned in Mike's flow step 7 Step 10 in code does mirror-back verify via ns_touchpoint_log after a 30-min sleep — this is the closest match. There is no separate "sync-verify (R614)" or post-rollover healthcheck endpoint. /api/healthcheck and /api/ns/touchpoints exist (per pricing project memory) but are not invoked by the workflow. LOW Either add an explicit step 12 calling /api/healthcheck + /api/ns/touchpoints?since=<rollover_start> and persisting results to annual_roll_runs, OR document that step 10 mirror-back IS the post-rollover verify.
12 Regional sheets rebuild via rebuild_regional_sheets skill Mike's flow step 3 Skill file exists at ~/Desktop/GFS-NetSuite-Cloudflare/source-documents/Bids_and_Customer_Pricing/Prompts/REBUILD_REGIONAL_SHEETS.md and writes via scripts/post-session-sync.js --skill rebuild_regional_sheets. This is operator-driven, runs outside the platform Worker. It is not orchestrated by the annual-roll workflow. LOW (operator process) Add a doc-only callout: "Step 0 (operator): run rebuild_regional_sheets skill ~T-7d before Jul 1 to refresh the regional file inputs the NS Price Roll tab reads from." Migration 144 regional_price_sheets exists so the table is ready; the skill→D1 sync path needs confirmation.
13 Stale hard-coded "69 customers" Diagram + wiki KPI cards + ld+json + arrows all say "69" Admin dashboard hub still says "69 customers · 44 bids · 127 items" (line 934). But bid_customers table size depends on status='active' at run time. Hard-coding 69 in the schema.org howto + diagram labels will be wrong by Jul 1 if any customer churn occurs. LOW (cosmetic) Replace "69" with "all active bid_customers" or "~69" in the diagram, and let the actual cohort size land in events.bid.rollover_completed.

Mike's call before Jul 1: Gap #5 (curated-bid vs. derived-invoice input) and Gap #6 (NS named-list vs. bulk_replace push) are architecturally load-bearing. Pick a direction this week so there is time to either build or rewrite.