Wiki · Bid Center Path 3 · R597

Path 3 — ★ July 1 rollover

The annual flip. Once per year (cron 0 5 1 7 * or manual), the prepare_next_school_year Workflow converts a year of Bid Center work into next year's NetSuite named price lists. 69 active customers → 69 NS payloads → one batch proposed_actions row (batch_id='jul1-2026') → Mike approves the cohort → NS_PUSH_QUEUE drains 69 writes (PushMutexDO per customer) → customer.pricelevel on each NS customer points at the new list. Trace thread: customer + SY-version all the way through.

Path 3 · the annual NS write batch HITL + PushMutexDO first live deploy Jul 1 2026
What this is

The only path that writes back to NetSuite

Paths 1 and 2 capture and stage; Path 3 commits. Once per year on July 1 at 05:00 UTC (or by manual POST /api/bid/rollover/start for a dry run), the Bid Center performs its annual flip. The prepare_next_school_year Cloudflare Workflow enumerates all 69 active customers, picks each one's latest sy_version (is_latest=1), applies the HPS MAP GPO allowance program where applicable, and builds 69 NS named-price-list payloads.

All 69 land in a single batch proposed_actions row with shared batch_id='jul1-2026'. Mike sees one card on admin-dashboard: "69 named price lists ready — approve cohort?" He can approve-all, approve a subset, or reject any line. On approve, the NS_PUSH_QUEUE drains one row per approved customer into ns_pending_pushes. A PushMutexDO Durable Object per customer prevents concurrent collisions on the same NS customer record.

Each push calls POST /api/ns/push/named-price-list with X-Edit-Token + confirm=true, which invokes the NS RESTlet. On 2xx, the customer's customer.pricelevel in NetSuite is updated to point at the new named list (e.g. "ACC SY2627 v2.5"), the row is marked done, and events.bid.namedlist_pushed fires. Failures requeue with backoff and surface in a partial-failure UI for Mike to re-approve as a subset.

When all 69 succeed (or the cohort times out), events.bid.rollover_completed fires. Customer service sees the new list on the next NS customer open; the next SO entered for the customer reads the new pricing. Trace thread: customer_id + sy_version flows from bid_quote_versions through proposed_actions, ns_pending_pushes, and the NS PriceList custom record (carried in the source_sy_version field).

Diagram: ns-bid-center-path-3-july-rollover.html. Sibling paths: Path 1 email · Path 2 direct chat. Master: master wiki.

When to use it

Trigger conditions

★ The only NS write in the Bid Center pillar

Path 1 + Path 2 only write to D1 + folder + dashboard. Path 3 is the only path that produces NetSuite writes. ADR-031: every NS push is HITL-gated; batch HITL approves all 69 in one cohort action; partial failures re-stage for a second HITL pass on the failing subset.

★ The trace thread reaches NetSuite

The customer + SY-version pair that started in Path 1 / Path 2 lands as source_sy_version on the NS PriceList custom record. Audit trail from the original email through to the NS write is one grep on the pair (sample: ACC Distributors / SY2627-v2.5).

Worked example

July 1, 2026 — first live cohort flip

Scenario

June 28, 2026: Mike runs POST /api/bid/rollover/start?dry_run=true. The Workflow enumerates 69 customers, picks each latest SY-version, applies the HPS MAP GPO allowance, and builds 69 named-price-list payloads. Output: a single rendered preview at admin-dashboard "Jul 1 dry-run — 69 customers, 0 NS writes." Mike scans the cohort and spots one customer with a stale SY-version (still on SY2526). He opens Path 2 chat and bumps it before Jul 1.

July 1, 05:00 UTC: cron fires prepare_next_school_year. cron_locks row inserted (prevents double-run). Workflow instance starts. SELECT * FROM bid_customers WHERE status='active' returns 69. For each, SELECT sy_version FROM bid_quote_versions WHERE customer_id=? AND is_latest=1. HPS MAP GPO programs applied. buildNamedPriceList for each customer.

One proposed_actions row staged: batch_id='jul1-2026', 69 line items, status=pending, risk_level=5. Admin-dashboard surfaces: "69 named price lists ready to write back to NS — approve cohort?" Mike opens at 06:12 UTC, scans for anomalies, taps Approve All.

69 rows enqueue into ns_pending_pushes. The drain loop acquires a PushMutexDO per customer (prevents collisions if a Path 1 email lands for the same customer mid-flip), POST /api/ns/push/named-price-list?confirm=true with X-Edit-Token, NS RESTlet writes the PriceList custom record (e.g. "ACC SY2627 v2.5") and updates customer.pricelevel to point at it. events.bid.namedlist_pushed fires per customer.

By 06:31 UTC, 64 of 69 succeeded. 5 failed (transient NS RESTlet timeout). The partial-failure UI shows the 5 failing customers; Mike taps Re-approve subset at 06:45. The 5 re-drain successfully on the retry pass. events.bid.rollover_completed fires at 06:47 UTC with {"cohort_size":69, "succeeded":69, "retries":1}. cron_locks row cleared. customer_health watcher recomputes price-stability signal.

July 2 morning: ACC's CS rep opens the customer in NS, sees the new "ACC SY2627 v2.5" named list attached. The first SO entered for ACC that week reads the new pricing. Year of Bid Center work has landed in NS as 69 named price lists in one cohort.

Step-by-step what happens

Cron / manual trigger → cohort HITL → NS drain

  1. 01

    Trigger fires

    Cron 0 5 1 7 * OR POST /api/bid/rollover/start.

  2. 02

    Acquire cron lock

    INSERT INTO cron_locks (name) VALUES ('jul1-rollover-2026') — UNIQUE constraint prevents double-run.

  3. 03

    Workflow starts

    prepare_next_school_year Workflow class instantiated (Cloudflare Workflows).

  4. 04

    Enumerate 69 active customers

    SELECT * FROM bid_customers WHERE status='active'

  5. 05

    For each: select latest SY-version

    SELECT sy_version FROM bid_quote_versions WHERE customer_id=? AND is_latest=1. Customer with no latest row is skipped + logged.

  6. 06

    Apply programs sidecar

    HPS MAP GPO allowance per customer (SY2526 / SY2627 programs from bid_programs).

  7. 07

    Build NS named price list payload

    buildNamedPriceList(customer, latest_snapshot, programs) → NS RESTlet-shaped JSON.

  8. 08

    ★ Stage batch proposed_actions (HITL gate)

    One row with batch_id='jul1-2026', 69 line items in payload, status=pending, risk_level=5.

  9. 09

    Notify Mike

    admin-dashboard cohort panel surfaces: "69 named price lists ready — approve cohort?"

  10. 10

    ★ Mike reviews + approves cohort

    Approve-all OR approve-subset OR reject specific customers.

  11. 11

    Enqueue NS_PUSH_QUEUE batch

    One row per approved customer into ns_pending_pushes.

  12. 12

    Acquire PushMutexDO per customer

    Durable Object prevents concurrent NS writes for the same customer record.

  13. 13

    POST /api/ns/push/named-price-list per customer

    X-Edit-Token + preview=false + confirm=true.

  14. 14

    NS RESTlet writes PriceList

    Custom NS record inserted; customer.pricelevel pointer updated to new list ID.

  15. 15

    On 2xx: mark done + emit event

    proposed_actions.status='done'; events.bid.namedlist_pushed.

  16. 16

    On fail: requeue + partial-failure UI

    Backoff retry; persistent failures surface in cohort partial-failure panel for Mike to re-approve subset.

  17. 17

    Cohort completion event

    events.bid.rollover_completed when all done OR cohort timeout.

  18. 18

    CS sees new list

    Next NS customer open shows the new named price list attached.

  19. 19

    Next SO reads new list

    Any SalesOrder entered for the customer reads the new pricing.

  20. 20

    Release cron lock

    DELETE FROM cron_locks WHERE name='jul1-rollover-2026'.

Outcomes

What's different after Path 3 runs

Cohort
69
customers in batch
NS writes
69
named price lists
HITL gates
1-2
cohort + partial-failure
Cadence
annual
Jul 1 at 05:00 UTC
Failure modes

What can go wrong

Partial cohort failures

N of 69 NS pushes fail (transient RESTlet timeout, transient auth). They land in the partial-failure UI; Mike re-approves the failing subset. Cohort still completes after one or two retry passes.

Cron double-fire

If a region duplicates the cron, cron_locks UNIQUE constraint blocks the second instance. The lock is also TTL'd to prevent a stuck row from blocking next year.

Customer with no is_latest=1 row

Skipped + logged. Mike sees the skip count in the cohort summary and decides whether to roll the customer manually via Path 2 + a follow-up single-customer push.

Concurrent Path 1 write during cohort drain

PushMutexDO per customer serializes. Path 1 staging still queues; nothing is lost.

NS RESTlet contract changes mid-flip

All 69 fail at the RESTlet step. kill:ns_writes stops further attempts; Mike opens NS, fixes the RESTlet, then re-drains via POST /api/ns/push-queue/drain?batch_id=jul1-2026.

Workflow instance lost mid-run

Cloudflare Workflows is durable; instance state survives Worker restarts. If workflow_run_log shows "in flight" without progress for > 1 hour, kill + restart from the last checkpoint.

Related

Adjacent flows + diagrams

For developers

Code paths + invariants

ConcernWhere
Workflow classsrc/annual_roll_workflow.ts (prepare_next_school_year)
Cron schedule0 5 1 7 * (UTC)
Manual triggerPOST /api/bid/rollover/start (?dry_run=true)
Cron lockcron_locks (UNIQUE name)
HITL kindproposed_actions.kind = 'ns_named_price_list_batch' (batch_id shared)
QueueNS_PUSH_QUEUE · ns_pending_pushes
Durable ObjectPushMutexDO (per customer)
NS endpointPOST /api/ns/push/named-price-list
NS RESTletcustomscript_gfs_platform_push_pricelist
Events firedbid.rollover_started, bid.namedlist_pushed, bid.rollover_completed
// Jul 1 cohort batch const batch_id = 'jul1-' + currentYear(); const customers = await env.DB.prepare('SELECT * FROM bid_customers WHERE status=?').bind('active').all(); for (const c of customers.results /* 69 */) { const latest = await pickLatestSyVersion(env, c.customer_id); if (!latest) { logSkip(c, 'no_is_latest'); continue; } const payload = buildNamedPriceList(c, latest, await loadPrograms(env, c.customer_id)); stageProposedAction({ batch_id, customer_id: c.customer_id, sy_version: latest.sy_version, payload }); } // Mike approves cohort -> NS_PUSH_QUEUE drains with PushMutexDO per customer for (const approved of queue) { await withPushMutex(env, approved.customer_id, async () => { const r = await postNamedPriceList(env, approved); if (r.ok) await emit(env, 'bid.namedlist_pushed', { ...approved }); else await requeueWithBackoff(env, approved); }); }
Changelog

Dated trail

DateRoundChangeTouched by
2026-05-26R597Wiki ships alongside Path 3 diagram. Documents the only NS-write path in the Bid Center pillar; batch HITL via shared batch_id; PushMutexDO per-customer collision prevention; first live deploy Jul 1 2026.Mike + Claude (Agent L)
Schema · data contract

The machine-readable spec

workflow_type · bid_center_july_rollover_path · risk_level 5. Parent: bid_center_lifecycle (master).

Customer + SY-version threading (the trace thread)

The trace thread that started in Path 1 / Path 2 reaches NetSuite here. Grep customer_id + sy_version across these tables to see one customer's journey from email to NS write.

Record / tableField carrying customer + SY-versionSample value
bid_quote_versionscustomer_id + sy_version (thread origin)"ACC Distributors / SY2627-v2.5"
bid_price_snapshotscustomer_id + sy_version + sku"ACC / SY2627-v2.5 / SKU-419"
proposed_actions (batch)batch_id + customer_id + sy_version"jul1-2026 / ACC / SY2627-v2.5"
ns_pending_pushescustomer_id + payload.source_sy_version"ACC Distributors / SY2627-v2.5"
NS PriceList (custom record)name · source_sy_version"ACC SY2627 v2.5"
NS customer.pricelevelpoints at named list IDtraces back to bid_quote_versions

D1 tables written (Path 3)

TableOperationPurpose
cron_locksINSERT then DELETEprevent double-run
workflow_run_logINSERT + UPDATEworkflow audit
proposed_actionsINSERT (batch) then UPDATEbatch HITL row
ns_pending_pushesINSERT-many then UPDATEper-customer queue rows
eventsINSERT-manybid.rollover_started, bid.namedlist_pushed (x N), bid.rollover_completed

Endpoints

MethodPathPurpose
POST/api/bid/rollover/startmanual / dry-run trigger
POST/api/proposed-actions/bulk-decidecohort approve / subset / reject
POST/api/ns/push/named-price-listper-customer NS write (preview + confirm)
POST/api/ns/push-queue/drainforce-drain a stuck batch

Events fired

event_typeWhen
bid.rollover_startedworkflow instance starts
bid.cohort_stagedbatch proposed_action inserted
bid.cohort_approvedMike approves cohort
bid.namedlist_pushedper-customer 2xx from NS
bid.namedlist_failedper-customer non-2xx
bid.rollover_completedcohort done OR timeout
Runbook · when it breaks

It broke — what now

Scenario · Jul 1 cohort half-finished, dashboard says "in flight"

Cron fired, 40 of 69 succeeded, 5 failed, 24 still in queue. Mike needs visibility.

  1. Check queue state: SELECT status, COUNT(*) FROM ns_pending_pushes WHERE batch_id='jul1-2026' GROUP BY status
  2. Check workflow run log: SELECT * FROM workflow_run_log WHERE workflow_type='prepare_next_school_year' ORDER BY started_at DESC LIMIT 1
  3. Surface partial failures: open admin-dashboard cohort panel, expand failing rows, decide re-approve or skip
  4. Force-drain if stuck: POST /api/ns/push-queue/drain?batch_id=jul1-2026

Scenario · All 69 NS pushes fail at the RESTlet step

NS RESTlet contract likely broke (e.g. new required field, auth changed).

  1. Stop the bleeding: kill:ns_writes in KV pauses every NS push
  2. Read RESTlet logs: NS → Customization → Scripts → customscript_gfs_platform_push_pricelist → Execution Log
  3. Fix the RESTlet: update field mapping or auth as needed
  4. Test with one customer: POST /api/ns/push/named-price-list?customer_id=<test>&preview=true
  5. Lift kill switch + re-drain: POST /api/ns/push-queue/drain?batch_id=jul1-2026

Scenario · Cron lock stuck from a prior failed run

Workflow won't start; logs say "lock held."

  1. Inspect: SELECT * FROM cron_locks WHERE name LIKE 'jul1-rollover-%'
  2. Check TTL: verify locked_at is older than the TTL window
  3. Force-clear (manual): DELETE FROM cron_locks WHERE name='jul1-rollover-2026' — only if you're sure no instance is running
  4. Re-trigger: POST /api/bid/rollover/start

Scenario · Dry-run before Jul 1 shows wrong cohort size

Expected 69, dry-run returns 64. Five customers missing.

  1. List skips: open the dry-run output — the skip log lists customers with no is_latest=1 row
  2. Inspect each skipped customer: SELECT * FROM bid_quote_versions WHERE customer_id='<X>' ORDER BY created_at DESC
  3. Open Path 2 chat: bump the customer's SY-version via ADD_UPDATE_CUSTOMER_PRICING
  4. Re-run dry-run: verify cohort count is back to 69

Logs to check

Kill switches

Backlog · open questions

What's not done · what's uncertain