NetSuite push queue + drainer

From HITL approval to confirmed NS write · 9 stages · vertical pipeline · ADR-031 + R560 race fix

Every write that touches NetSuite goes through this path: a proposed_action claimed by Mike, an ns_pending_pushes insert, a Cloudflare Queue handoff, a NetSuite RESTlet write, and finally a status flip to applied. R560 closed a CRITICAL race condition where two reviewers could claim the same row; the atomic UPDATE ... RETURNING claim guarantees one winner. Failures retry 3x then land in the DLQ.

ADR-031 R560 race fix DLQ on 3rd retry

Pipeline — vertical, top to bottom

NS push queue + drainer — proposed_actions → HITL claim → ns_pending_pushes → NS_PUSH_QUEUE → consumer → NS RESTlet → applied (+ retry → DLQ) Happy path Failure path 01 / proposed_actions row status = 'pending' proposed_change_json populated DATABASE · D1 02 / HITL decide R560 atomic claim: UPDATE ... RETURNING SECURITY · X-Edit-Token 03 / ns_pending_pushes INSERT payload_json built from proposed_change_json DATABASE · status='queued' 04 / NS_PUSH_QUEUE producer env.NS_PUSH_QUEUE.send() CF Queue enqueue MESSAGEBUS · binding 05 / Queue consumer receives batch queue() handler in src/index.ts CLOUD · CF Worker 06 / NS RESTlet customscript_gfs_platform_query TBA OAuth1 · POST write EXTERNAL · NetSuite 07 / NS API write actual record mutation returns ns_internal_id EXTERNAL · system of record 08 / status='applied' ns_pending_pushes flip + applied_at timestamp BACKEND · success proposed_actions_applied_mirror audit row written decision trail DATABASE · D1 retry 1 of 3 CF Queue retry policy exponential backoff SECURITY · transient retry 2 / 3 log + count fail circuit breaker check SECURITY · re-enqueue DLQ — gfs-ns-push-dlq env.NS_PUSH_DLQ 3rd failure lands here MESSAGEBUS · CF Queue ns_push_dlq_log D1 row with error_json manual replay via endpoint DATABASE · D1 replay: POST /api/audit-dlq/replay reinserts into ns_pending_pushes

Stage detail — 9 stages

01 proposed_actions row D1

Every write tool that touches NetSuite lands here first as status='pending'.
Table
proposed_actions
Key fields
action_type · target_record · proposed_change_json · risk_tier · status
Author
chat tool that staged it (e.g. update_vendor_cost)

02 HITL decide R560 race fix

Mike approves or rejects. The R560 hardening replaced a check-then-update with an atomic claim.
Endpoint
POST /api/proposed-actions/:id/decide
SQL
UPDATE proposed_actions SET status='approved', claim=? WHERE id=? AND status='pending' RETURNING *
Race fix
R560 atomic claim — one winner per row; second concurrent decide returns 0 rows
Auth
CF Access JWT + role gate + X-Edit-Token header (ADR-031)

03 ns_pending_pushes INSERT

On approval, the consumer-friendly payload is materialized into the push queue table.
Table
ns_pending_pushes
Payload
payload_json built from proposed_change_json + canonical NS field map
Status
status='queued'

04 NS_PUSH_QUEUE producer

CF Queue enqueue via the worker binding.
Binding
env.NS_PUSH_QUEUE
Call
env.NS_PUSH_QUEUE.send({ id, payload })
Note
producer is fire-and-forget at this point; status remains queued

05 Queue consumer

CF Worker queue handler picks up the batch and calls the NS RESTlet.
Handler
queue() entrypoint in src/index.ts
Batch size
configurable per CF Queue settings

06 NS RESTlet call

Single-purpose RESTlet handles all platform writes.
RESTlet
customscript_gfs_platform_query (R55)
Auth
TBA OAuth1 (Token-Based Authentication)
Source
src/lib/ns.ts (~100 LOC)

07 NS API write

NetSuite executes the mutation against the system of record.
Returns
ns_internal_id for the affected record

08 status='applied'

On confirmed NS response, ns_pending_pushes flips status and stamps timestamp.
Field updates
status='applied', applied_at=CURRENT_TIMESTAMP, ns_internal_id=?
Mirror
an audit row written to proposed_actions_applied_mirror

09 Failure path → DLQ DLQ

Up to 3 retries with exponential backoff. 3rd failure lands in gfs-ns-push-dlq queue.
Retry policy
CF Queue native retry, exponential backoff
DLQ binding
env.NS_PUSH_DLQ
Log table
ns_push_dlq_log (id, payload_json, error_json, attempts, last_at)
Replay
POST /api/audit-dlq/replay — admin-only; reinserts into ns_pending_pushes

Tables, endpoints, bindings

kindnamepurpose
tableproposed_actionsHITL queue — every NS write proposal lives here first
tablens_pending_pushesqueued-for-push payloads after approval
tablens_push_dlq_log3rd-failure audit + replay surface
tableproposed_actions_applied_mirroraudit mirror of applied decisions (R563)
endpointPOST /api/proposed-actions/:id/decideHITL approve/reject — atomic claim
endpointPOST /api/ns-push/drainmanual drain trigger
endpointPOST /api/audit-dlq/replayreplay DLQ entry back to ns_pending_pushes
bindingenv.NS_PUSH_QUEUECF Queue producer/consumer
bindingenv.NS_PUSH_DLQdead-letter queue
RESTletcustomscript_gfs_platform_queryNetSuite endpoint for writes (R55 TBA)

R560 race-condition fix — why it matters

Pre-R560 the decide flow was a SELECT-then-UPDATE. Two reviewers could each see status='pending' and both submit approve — resulting in two duplicate NS writes. Codex audit CRITICAL #1.

-- Pre-R560 (BROKEN):
SELECT status FROM proposed_actions WHERE id=?;          -- both see 'pending'
UPDATE proposed_actions SET status='approved' WHERE id=?; -- both succeed

-- R560 (FIXED):
UPDATE proposed_actions
   SET status='approved', claim=?, decided_at=CURRENT_TIMESTAMP
 WHERE id=? AND status='pending'
RETURNING id, proposed_change_json;  -- second concurrent call returns 0 rows