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.
Trigger conditions
- The cron
0 5 1 7 *fires on July 1 at 05:00 UTC (annual flip). - Mike manually invokes
POST /api/bid/rollover/start— typically for a dry-run a few days before Jul 1 (?dry_run=truerenders the 69 payloads but skips NS_PUSH_QUEUE). - A previous Jul 1 cohort failed partially and the partial-failure UI is being drained (re-approval of the remaining subset).
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 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).
July 1, 2026 — first live cohort flip
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.
Cron / manual trigger → cohort HITL → NS drain
- 01
Trigger fires
Cron
0 5 1 7 *ORPOST /api/bid/rollover/start. - 02
Acquire cron lock
INSERT INTO cron_locks (name) VALUES ('jul1-rollover-2026')— UNIQUE constraint prevents double-run. - 03
Workflow starts
prepare_next_school_yearWorkflow class instantiated (Cloudflare Workflows). - 04
Enumerate 69 active customers
SELECT * FROM bid_customers WHERE status='active' - 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. - 06
Apply programs sidecar
HPS MAP GPO allowance per customer (SY2526 / SY2627 programs from
bid_programs). - 07
Build NS named price list payload
buildNamedPriceList(customer, latest_snapshot, programs)→ NS RESTlet-shaped JSON. - 08
★ Stage batch
proposed_actions(HITL gate)One row with
batch_id='jul1-2026', 69 line items in payload, status=pending, risk_level=5. - 09
Notify Mike
admin-dashboard cohort panel surfaces: "69 named price lists ready — approve cohort?"
- 10
★ Mike reviews + approves cohort
Approve-all OR approve-subset OR reject specific customers.
- 11
Enqueue
NS_PUSH_QUEUEbatchOne row per approved customer into
ns_pending_pushes. - 12
Acquire
PushMutexDOper customerDurable Object prevents concurrent NS writes for the same customer record.
- 13
POST /api/ns/push/named-price-listper customerX-Edit-Token+preview=false+confirm=true. - 14
NS RESTlet writes
PriceListCustom NS record inserted;
customer.pricelevelpointer updated to new list ID. - 15
On 2xx: mark done + emit event
proposed_actions.status='done';events.bid.namedlist_pushed. - 16
On fail: requeue + partial-failure UI
Backoff retry; persistent failures surface in cohort partial-failure panel for Mike to re-approve subset.
- 17
Cohort completion event
events.bid.rollover_completedwhen all done OR cohort timeout. - 18
CS sees new list
Next NS customer open shows the new named price list attached.
- 19
Next SO reads new list
Any SalesOrder entered for the customer reads the new pricing.
- 20
Release cron lock
DELETE FROM cron_locks WHERE name='jul1-rollover-2026'.
What's different after Path 3 runs
- 69 NS
PriceListcustom records written (or fewer if some customers were rejected). customer.pricelevelon each NS customer points at the new named list.- Next SO entered for each customer reads the new pricing.
events.bid.rollover_completedemitted with cohort stats (size, succeeded, retries).customer_healthwatcher recomputes the price-stability signal for the cohort.- Bid Center state for SY2526 effectively archived; SY2627 becomes the active SY going forward.
What can go wrong
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.
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.
is_latest=1 rowSkipped + 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.
PushMutexDO per customer serializes. Path 1 staging still queues; nothing is lost.
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.
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.
Adjacent flows + diagrams
Code paths + invariants
| Concern | Where |
|---|---|
| Workflow class | src/annual_roll_workflow.ts (prepare_next_school_year) |
| Cron schedule | 0 5 1 7 * (UTC) |
| Manual trigger | POST /api/bid/rollover/start (?dry_run=true) |
| Cron lock | cron_locks (UNIQUE name) |
| HITL kind | proposed_actions.kind = 'ns_named_price_list_batch' (batch_id shared) |
| Queue | NS_PUSH_QUEUE · ns_pending_pushes |
| Durable Object | PushMutexDO (per customer) |
| NS endpoint | POST /api/ns/push/named-price-list |
| NS RESTlet | customscript_gfs_platform_push_pricelist |
| Events fired | bid.rollover_started, bid.namedlist_pushed, bid.rollover_completed |
Dated trail
| Date | Round | Change | Touched by |
|---|---|---|---|
2026-05-26 | R597 | Wiki 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) |
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 / table | Field carrying customer + SY-version | Sample value |
|---|---|---|
bid_quote_versions | customer_id + sy_version (thread origin) | "ACC Distributors / SY2627-v2.5" |
bid_price_snapshots | customer_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_pushes | customer_id + payload.source_sy_version | "ACC Distributors / SY2627-v2.5" |
NS PriceList (custom record) | name · source_sy_version | "ACC SY2627 v2.5" |
NS customer.pricelevel | points at named list ID | traces back to bid_quote_versions |
D1 tables written (Path 3)
| Table | Operation | Purpose |
|---|---|---|
cron_locks | INSERT then DELETE | prevent double-run |
workflow_run_log | INSERT + UPDATE | workflow audit |
proposed_actions | INSERT (batch) then UPDATE | batch HITL row |
ns_pending_pushes | INSERT-many then UPDATE | per-customer queue rows |
events | INSERT-many | bid.rollover_started, bid.namedlist_pushed (x N), bid.rollover_completed |
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST | /api/bid/rollover/start | manual / dry-run trigger |
POST | /api/proposed-actions/bulk-decide | cohort approve / subset / reject |
POST | /api/ns/push/named-price-list | per-customer NS write (preview + confirm) |
POST | /api/ns/push-queue/drain | force-drain a stuck batch |
Events fired
| event_type | When |
|---|---|
bid.rollover_started | workflow instance starts |
bid.cohort_staged | batch proposed_action inserted |
bid.cohort_approved | Mike approves cohort |
bid.namedlist_pushed | per-customer 2xx from NS |
bid.namedlist_failed | per-customer non-2xx |
bid.rollover_completed | cohort done OR timeout |
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.
- Check queue state:
SELECT status, COUNT(*) FROM ns_pending_pushes WHERE batch_id='jul1-2026' GROUP BY status - Check workflow run log:
SELECT * FROM workflow_run_log WHERE workflow_type='prepare_next_school_year' ORDER BY started_at DESC LIMIT 1 - Surface partial failures: open admin-dashboard cohort panel, expand failing rows, decide re-approve or skip
- 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).
- Stop the bleeding:
kill:ns_writesin KV pauses every NS push - Read RESTlet logs: NS → Customization → Scripts →
customscript_gfs_platform_push_pricelist→ Execution Log - Fix the RESTlet: update field mapping or auth as needed
- Test with one customer:
POST /api/ns/push/named-price-list?customer_id=<test>&preview=true - 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."
- Inspect:
SELECT * FROM cron_locks WHERE name LIKE 'jul1-rollover-%' - Check TTL: verify
locked_atis older than the TTL window - Force-clear (manual):
DELETE FROM cron_locks WHERE name='jul1-rollover-2026'— only if you're sure no instance is running - 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.
- List skips: open the dry-run output — the skip log lists customers with no
is_latest=1row - Inspect each skipped customer:
SELECT * FROM bid_quote_versions WHERE customer_id='<X>' ORDER BY created_at DESC - Open Path 2 chat: bump the customer's SY-version via
ADD_UPDATE_CUSTOMER_PRICING - Re-run dry-run: verify cohort count is back to 69
Logs to check
workflow_run_log·prepare_next_school_yearauditns_pending_pushes· Jul 1 queue state by batch_idproposed_actions·kind='ns_named_price_list_batch'events·bid.rollover_*+bid.namedlist_*trailcron_locks· stuck rollover detection- NS Execution Log ·
customscript_gfs_platform_push_pricelist npx wrangler tail· live Worker logs
Kill switches
kill:ns_writes· stops every NS push (including Jul 1)kill:bid_rollover· specific to theprepare_next_school_yearworkflowkill:proposed_apply· stops HITL approvals from executing fan-out
What's not done · what's uncertain
- STUBBatch-HITL cohort UI for 69 rows
Agent N owns. Pattern: single
batch_id, 69 expandable line items, approve-all / subset / reject-row controls. Partial-failure follow-up panel. - STUBDry-run mode
?dry_run=truerenders all 69 payloads but skipsNS_PUSH_QUEUE. Scheduled to land before June 28, 2026 so Mike can preview the cohort. - STUBCohort completion report
Auto-emit a Markdown summary (succeeded / failed / skipped / retries) after
bid.rollover_completed; post to admin-dashboard + email. - OPENMid-year named-list refresh
Today rollover is annual-only. Should we allow per-customer mid-year named-list refresh for emergency cases? Decision pending; ADR draft TBD.
- OPENCohort timeout policy
If the cohort drain stalls (e.g. NS RESTlet down for hours), at what point do we emit
bid.rollover_completedas partial? Current: 24-hour timeout. Tune after first live run. - DECISIONAuto-approve threshold
If a future Jul 1 cohort has identical structure to prior years (no anomalies), should auto-approve fire for ≤ N-line cohorts? Today: no auto-approve, ever.