Caps and skips

Plan caps, skipped occurrences, and what happens to a missed fire.

The plan cap

The plan tier of the user who created a schedule caps how many engagements the organization may have queued or running when that schedule fires. Plans and limits describes the tier.

Tier Cap
free 1
pro 3
enterprise 10

A tier the table does not list gets the free cap of 1, never an unlimited one. The count covers the whole organization and only the queued and running statuses; an engagement in any other status does not count.

The cap applies to scheduled occurrences only. The ticker is the only caller of the check, and a manual submit from the console or the API is never refused for the cap. Manual runs can therefore take the organization over its cap, and while the count stays at or above it, each scheduled occurrence that comes due is skipped.

When an occurrence is skipped

When an occurrence comes due, the ticker checks that the schedule's creator still exists, then checks the plan cap, then creates the engagement and submits it through the launch gate. A failure at any point skips the occurrence and records one of four reasons:

Reason recorded Cause Engagement left behind
creator user no longer exists the user who created the schedule no longer exists none
plan cap: N engagement(s) already queued or running, tier '…' allows M the organization is at its cap none
launch refused, ownership not proven for … the ownership re-check refused an identifier a blocked engagement
error: … any other exception while firing, including an unreachable queue the engagement, left at pending, if it was created before the exception

The Schedules list shows the reason on the schedule's row as last occurrence skipped: …. The ticker's log also records one warning line per skip.

A blocked engagement from a refused re-check is an ordinary blocked engagement in your list, owned by the schedule's creator. The console shows Start pentest only on a pending engagement, so it offers no way to resubmit a blocked one. The console's submit endpoint, POST /api/engagements/{id}/submit, does accept blocked, and so does the partner API's POST /api/v1/engagements/{id}/submit (Engagements). A submit made either way is manual and is not subject to the cap. Once the missing proof is published — see Ownership verification — the schedule's next occurrence can launch.

A skipped occurrence is gone:

  • It is not retried. The ticker moves the schedule's next run time forward when it claims the occurrence, before it tries to fire it, and a skip does not move it back. The next attempt is the next occurrence of the cadence.
  • Only the latest reason is kept. The schedule holds one skip reason. The next skip overwrites it and the next successful fire clears it.
  • The last run time does not change. last on the row is the time of the most recent successful fire.

When the ticker checks

The ticker runs one pass over the schedules — a tick — then sleeps for its interval: 30 seconds by default, set with --interval, or with TICKER_INTERVAL when it is started by start_ticker.sh. The sleep starts when a tick finishes, so tick starts are the interval plus the tick's own duration apart. An occurrence fires on the first tick that starts at or after its run time: with the default interval, up to 30 seconds late plus the duration of the tick before. The engagement then goes through the ownership re-check and joins the back of the queue (Anatomy of a run).

One tick claims at most 50 due schedules, earliest run time first. Any beyond that wait for the next tick. A tick that fails as a whole — the database unreachable — is logged, and the ticker tries again after its interval. An unreachable queue does not fail the tick: each due occurrence is skipped with error: …, and the engagement it created is left at pending.

Occurrences missed while the ticker was down

If the ticker is not running when an occurrence comes due, nothing fires until it runs again. The first tick after that finds the schedule overdue, fires it once, and computes the next run time from the moment of that tick, not from the missed slot. The occurrences in between are not fired and are not recorded as skips. A daily schedule whose ticker was down for three days fires once when the ticker returns, then continues on its cadence.

How a tick claims a schedule

Before firing anything, a tick claims each due schedule with an update that moves its next run time forward and succeeds only if the next run time still holds the value the tick read. The claims are committed before any firing starts. Two ticks that read the same due schedule in two ticker processes cannot both claim it, because the second update matches no row. On PostgreSQL the read also locks the rows it returns and skips rows another tick has locked; on SQLite that clause has no effect, and the conditional update does the work alone.

That makes a given run time claimed by at most one tick. It does not make the occurrence fire. A claim is committed before the fire is attempted, so if the ticker process stops between the two, that occurrence is lost and no skip reason is recorded. The two fall-back fires in Timezones and DST are two different run times, and each is claimed once.

Next: Managing a schedule.

Last updated 2026-09-23