Service hold trigger
Service hold is the heaviest customer-facing action the platform takes. It blocks new orders, signals to the customer that we've stopped extending credit, and creates a sales-rep follow-up that has to happen fast.
The cascade is gated by Mike (the HITL gate) even when the AR aging cron triggers it. This is intentional: an automated hold would damage relationships in cases where there's context (customer on a payment plan, dispute pending) that the data doesn't reflect.
Risk level 4 (high). Reversal exists but the customer-facing notice is hard to un-send.
Trigger conditions
- AR aging tick detects a customer with
days_overdue > 90 AND total_open > 25000— chains here. - Mike manually triggers from /admin-dashboard.html when a phone call reveals trouble.
- Customer health prediction (R558) surfaces a high-risk customer with prior dispute history.
- Repeated invoice disputes from the same customer trigger an AR review that recommends hold.
Preconditions are deliberately loose (warn, not block): days_overdue >= 60 AND ar_balance >= 10000. The intent is to let Mike see the proposal even for borderline cases — the HITL gate is the real safety.
The 4 beats
-
01
Flag customer on hold
UPDATE
customers SET entitystatus='hold', last_modified=now WHERE id=?. The hub UI and Pages Functions read this field; new order forms refuse the customer. -
02
Notify sales rep
A draft email to the sales rep lists the AR balance, days overdue, prior holds, and recommended next call. Status=pending_review.
-
03
Draft customer-facing notice
A draft email to the customer explaining the hold and the path back (payment, payment plan, dispute resolution). Mike polishes before send.
-
04
NS customer credit_hold flag
Enqueue NS push to set
credit_hold=trueon the customer record. Blocks new SOs at the NS level.
What's different after the workflow runs
customers.entitystatus='hold'in D1.- NS customer record has
credit_hold=true. - Sales rep has a notification email awaiting approval.
- Customer has a draft notice awaiting Mike's polish.
auto_decisionsrecords this hold decision for next-time reflection.- reflexion_log carries a
service_hold,customer:?tagged row.
What can go wrong and how to recover
D1 has the hold but NS doesn't. Drainer cron retries within 15 min. Worst case: customer can place an order through a non-platform path (rare). Manual recovery via POST /admin/ns-push/retry.
If Mike approves the draft before reviewing, the customer might get a notice on a borderline case. Recovery is a follow-up email + relationship management. R560's atomic-claim prevents accidental bulk-approve here.
Standard path: dispute flows through customer_invoice_dispute, which can stage an ar_hold_review proposed action to lift the hold.
If prior_holds > 0, the proposal note flags it. Mike weighs the pattern when approving.
Adjacent workflows + diagrams
Code paths + invariants
| Concern | Where |
|---|---|
| Workflow contract | workflow_definitions WHERE workflow_type='service_hold_trigger' |
| D1 flag | customers.entitystatus='hold' |
| NS flag | customer.credit_hold=true |
| Decision audit | auto_decisions table |
| Reflexion tag | service_hold,customer:<id> |
| Risk level | 4 |
| Expected duration | ~15 min |
| Trigger | event · sources=ar_aging_tick_threshold, manual |
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.
| Date | Round | Change | Touched by |
|---|---|---|---|
2026-05-26 | R586 | Added CHANGELOG · SCHEMA · RUNBOOK · BACKLOG sections — wiki became best-in-class operating documentation. | Mike + Claude |
2026-05-25 | R584/R585 | Wiki originally shipped — 8-section structure (hero / what / when / steps / outcomes / failure-modes / related / for-developers). | Mike + Claude |
workflow_definitions WHERE workflow_type='service_hold_trigger' before acting on these claims.The machine-readable spec
Canonical fields, table names, endpoint signatures. What code should match, what tests should assert. workflow_type · service_hold_trigger · risk_level · 4.
Inputs (required + optional)
| Field | Type | Description |
|---|---|---|
customer_id | integer | Required. |
hold_reason | string | 'ar_aging' | 'credit_block' | 'compliance' | 'manual' |
hold_severity | string | 'soft' | 'hard' |
D1 tables written
| Table | Operation | Trigger |
|---|---|---|
proposed_actions | INSERT | Hold proposal |
customer_programs | INSERT (post-HITL) | program_type=service_hold |
ns_pending_pushes | INSERT | Set NS customer status |
events | INSERT (customer.service_hold) | downstream block in SO creation |
Endpoints called
| Method | Path | Purpose |
|---|---|---|
POST | /api/workflow/execute | Runner entrypoint |
POST | /api/customer/:id/hold/release | Release hold |
Events fired
| event_type | When | Subscribers |
|---|---|---|
customer.service_hold | Post-HITL | SO creation guard |
customer.service_hold.released | On release | audit |
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 · Hold set but customer still received a new SO
Hold enforcement at SO creation is unwired. Today it's just a flag.
- Cancel the SO: Mike or AR cancels in NS.
- Add precondition: propose_sales_order must check active service_hold.
- Long-term: Wire the kill at SO creation, not just at HITL.
Scenario · Hold released but customer_programs row still active
Release endpoint didn't update the row.
- Update directly:
UPDATE customer_programs SET status='released', released_at=datetime('now') WHERE customer_id=? AND program_type='service_hold' AND status='active' - Push to NS: Set NS customer status back to active.
Scenario · Hold reason was wrong — customer paid 2 days ago
Stale AR data triggered the hold.
- Verify AR:
SELECT total_open FROM customer_aging WHERE customer_id=? - Release:
POST /api/customer/<id>/hold/releasewith reason 'paid' - Apologize: Customer-facing apology if order was blocked.
Logs to check
workflow_run_log· top-level run auditworkflow_step_log· per-step traceworkflow_verify_results· post-window verify outcomescron_locks· stuck cron lock detectionevents· workflow.completed / workflow.failed event trailreflexion_log· per-run narrative (if reflexion_enabled)npx wrangler tail· live Worker logs
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:
kill:ns_writes· stops every NS push platform-widekill:proposed_apply· stops HITL approvals from executing fan-outkill:high_risk_ops· stops risk_level >= 4 fan-out
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.
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.
-
STUB
Hold enforcement at SO creation
Documented but not implemented.
-
OPEN
Auto-release on payment
Today manual. When payment lands, hold should auto-release.
-
DEFER
Hold severity differentiation
Soft hold = warning, hard hold = block. Today no enforcement diff.