Shipmind Labs

Courier dispatch: refusal cooldown instead of a penalty score

· 8 min read

Dispatch systems that let a courier decline a job usually record the refusal as a penalty: a reliability score goes down, and the next assignment round reads that score. It works until someone asks what the number means, when it recovers, and why next week's roster changed after a single decline on a Tuesday afternoon. We have built enough queue-and-dispatch systems — collections workflows in lending, real-time moderation tooling for compliance teams, role-specific courier and warehouse apps for a dark-store delivery marketplace — to think the penalty model is the expensive one, and not because of the arithmetic.

The constraint: two clocks in one system#

A roster is planned ahead. Weekly patterns are unrolled over a horizon, holidays drop out, and the result is something a person looks at days before it happens. A refusal happens on the other clock entirely: a job is offered at 12:04, someone declines, and the next offer goes out seconds later.

A penalty score couples those two clocks. The number written at 12:04 is read by the planner that draws a roster for a span that has not started yet, so a decline in the middle of a shift silently edits an artifact people have already arranged their week around. Worse, the score has to come back. Recovery means decay, decay means a scheduled job, and now the fairness of your dispatch depends on whether a cron ran. The state is durable, cumulative, and nobody — least of all the courier — can say when it ends.

What we actually want is narrow: a refusal should lower a courier's priority for the next assignments, and then stop mattering, without anyone writing a row to undo it.

A cooldown carries its own end#

Our shift-planner library models this directly. It is public at https://github.com/shipmindlabs/shift-planner and still pre-alpha — the API is not stable yet — but the shape of the refusal handling is the part we would keep.

A refusal produces a lock over a half-open interval, and the lock knows when it stops:

python
from datetime import datetime, timedelta, timezone

from shift_planner import Cooldown

noon = datetime(2026, 5, 4, 12, tzinfo=timezone.utc)
lock = Cooldown(timedelta(minutes=20)).lock("anna", noon)

lock.holds_at(noon + timedelta(minutes=5))   # True
lock.remaining(noon + timedelta(minutes=5))  # timedelta(minutes=15)
lock.holds_at(noon + timedelta(minutes=20))  # False

There is no unlock, and none is needed. CooldownLock stores since and until, and holding is a comparison against a moment you pass in. Expiry is not an event that has to be delivered; it is a fact about the clock that becomes true on its own. That single property is what removes the background job, the sweep, and the whole class of bugs where a courier stays penalised because a worker process was down.

The interval is half-open, [since, until), the same convention shifts use for [start, start + length). A cooldown that ends exactly when the next offer arrives does not hold, and a shift that ends exactly when the next one starts is not an overlap. Getting one convention wrong in a system like this produces off-by-one behaviour that only shows up at the boundary, which is precisely where dispatch spends its time.

Dispatch becomes a pure read of the clock#

The dispatcher composes a roster with a board of locks. Recording a refusal returns a new dispatcher rather than mutating one, so a decision is always a function of the state you were handed and the moment you were given:

python
from datetime import datetime, timedelta, timezone

from shift_planner import Cooldown, CooldownBoard, Dispatcher, Job, Roster, Shift

noon = datetime(2026, 5, 4, 12, tzinfo=timezone.utc)
roster = Roster(
    [
        Shift("anna", "depot-north", noon, timedelta(hours=4), capacity=5),
        Shift("boris", "depot-north", noon, timedelta(hours=4), capacity=5),
    ]
)

dispatcher = Dispatcher(
    roster,
    cooldowns=CooldownBoard(cooldown=Cooldown(timedelta(minutes=20))),
)
job = Job(location="depot-north", at=noon)

dispatcher.assign(job).worker  # 'anna'

after_refusal = dispatcher.refuse("anna", noon)
after_refusal.assign(job).worker  # 'boris', anna is cooling down
after_refusal.cooldowns.remaining("anna", noon + timedelta(minutes=5))
# timedelta(minutes=15)

later = Job(location="depot-north", at=noon + timedelta(minutes=20))
after_refusal.assign(later).worker  # 'anna', the lock lifted on its own

Nothing between the third call and the last one touched the board. No job ran, no row was updated, no dispatcher intervened. The courier came back into rotation because twenty minutes passed.

This is also why the behaviour is testable without a scheduler and without freezing a global clock. Time is an argument — assign takes a job with an at, refuse takes the moment of the refusal, remaining takes the moment you are asking about. A test that wants to know what happens nineteen minutes in says nineteen minutes; it does not sleep, and it does not patch anything.

Eligibility and ordering are different questions#

A cooldown answers who is a candidate. It does not answer who gets the job. Those stay separate, because conflating them is how ranking systems become unexplainable.

Candidates are couriers on duty at the job's own location. The ordering among them is a policy — LeastLoaded is the default, and anything with a sort_key(candidate) method can replace it:

python
from shift_planner import Dispatcher, EarliestStart

dispatcher.assign(job, taken={"anna": 3}).worker
# 'boris', anna is carrying more work

Dispatcher(roster, policy=EarliestStart()).assign(job, taken={"anna": 3}).worker
# 'anna', on duty since 11:00

When you want to change how work is spread, you change the policy and the load you pass in. The refusal handling stays where it is. In the penalty-score model, both of those live in one number, and every tuning conversation turns into an argument about whether a decline should be worth more or less than a busy hour.

The plan is drawn, not patched#

The long clock stays clean. A Plan unrolls ShiftPattern objects over a Horizon, dropping holidays and stopping once a worker reaches their limit for the span:

python
from datetime import date, time, timedelta

from shift_planner import Horizon, Plan, ShiftPattern

plan = Plan(
    horizon=Horizon(date(2026, 5, 4), days=7),
    patterns=[
        ShiftPattern(
            "anna",
            "depot-north",
            time(8),
            timedelta(hours=8),
            weekdays={0, 1, 2, 3, 4},
            capacity=12,
        ),
    ],
)

roster = plan.applied_to(roster)
plan.applied_to(roster)  # same result, drawing again is a no-op

The roster a plan yields depends on the plan alone. applied_to replaces exactly what the plan speaks for — its own workers inside the horizon — and leaves everything else untouched, so redrawing after an edit is safe rather than something you do carefully at night. Refusals never enter this path. Nothing that happened at 12:04 can change what Thursday looks like, which is the property that makes a published roster worth publishing.

The roster itself refuses to hold a contradiction: two shifts of the same worker that share time raise OverlappingShiftError at construction, naming both. A redraw that would have double-booked someone fails where you can see it instead of producing a plan that dispatch quietly disagrees with later.

What it costs to run#

Almost nothing, and the places it does cost are worth knowing.

The board holds at most one lock per worker, so its size is bounded by headcount rather than by refusal volume. Tuning is a constructor argument: changing the cooldown from twenty minutes to ten is a config change, not a migration and not a backfill of scores computed under the old rule.

The number that matters is the relationship between the cooldown duration and how quickly the same job is re-offered. If a job bounces back around the ring faster than the cooldown lasts, the cooldown is doing its job. If the cooldown is shorter than the re-offer interval, a courier who declined can be handed the same job again in the same round, which reads as the system ignoring them. Set the duration above the interval you actually re-offer at, and check that assumption when the offer loop changes.

The failure mode is honest and should be handled outside the library: if everyone at a location is cooling down, assign returns nothing. That is the correct answer to "who takes this job" when nobody is eligible, and it belongs to the caller to escalate — widen the location, page a coordinator, hold the job. A penalty score never returns nothing; it always produces a ranking, including a ranking of people you should not be assigning to.

One more operational note: every moment in the system must be timezone-aware. Shift and CooldownLock both reject naive datetimes at construction, because comparing naive and aware datetimes raises a TypeError deep inside an overlap check, at the least useful possible moment.

Close#

The design decision is not "be nicer to couriers". It is that a refusal has a natural duration and a score does not. Modelling it as an interval that expires by the clock removes the decay job, removes the accumulating state, keeps the planning horizon independent of what happened this afternoon, and gives the courier an answer to the only question they were ever going to ask: how long. Twenty minutes.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com