Timers and replay
Every kind of time compiles to one timer on the record, keyed so a replay arms it again and no move of a date rewrites it. It is armed, fired, superseded or held, re-armed only by a closed list of events, committed once however often it is woken, and pinned with the bundle it was armed under.
A timer answers one question: what is owed, and has it run? Every kind of time on Keeping time compiles to one, tmr_…, on the record. Each fires once for each time it is owed, in the tick's order, under the version it was armed with, and the same way on a test clock as on the brand's own.
| A timer | Holds |
|---|---|
key | what it is, by the grammar below: clk_8Tx1#US:rung:notice 90d:2027@0 |
status | armed, fired, superseded or held: States |
place | its kind's place in the tick, 1 to 8 |
owed_at, armed_at, fired_at, late_by | when it was due, armed and run, and the gap |
attempt, wakes | how many times it proposed, and how many times it was delivered |
version | the bundle it was armed under: Across versions |
commit | the commit it made under its key; every later wake answers replayed: true with that commit |
Every job, clock and subscription carries next_timer. GET /timers?on=&status=, timers list --on, list_timers and timers.list() read them. Acme's 2027 return, the day after it is minted:
$ npx services.dev timers list --on job_4Tx9 --test-clock tc_7Hq4
job_4Tx9 · annual-return · period 2027 · on 2027-06 · at 2028-02-02 14:00Z · UTC · 4 fired · 1 held · 48 armed, the next 6 below
id status owed fired place key
tmr_9Tq1 fired 2028-02-01 14:00Z 2028-02-01 14:00Z 2 sbn_7Hq2:schedule:annual:2027
tmr_9Tq3 fired 2028-01-19 14:00Z 2028-02-01 14:00Z 6 clk_8Tx1#US-CA:rung:notice 90d:2027@0
tmr_9Tq5 fired 2028-01-19 14:00Z 2028-02-01 14:00Z 6 clk_8Tx1#US-NY:rung:notice 90d:2027@0
tmr_9Tq2 fired 2028-01-19 14:00Z 2028-02-01 14:00Z 6 clk_8Tx1#US:rung:notice 90d:2027@0
One timer, whole: woken twice after a restart, committed once:
→ GET /timers/tmr_9Tq2
{
"id": "tmr_9Tq2",
"object": "timer",
"key": "clk_8Tx1#US:rung:notice 90d:2027@0",
A key is <owner>:<kind>:<name>:<occurrence>[@<move>]. The occurrence is named by itself, never by its current date, so a date that moves or rolls never changes a key, and a replay arms the same keys:
| Part | Is | apis.tax's |
|---|---|---|
| owner | the job, the clock occurrence or the subscription the timer belongs to | job_4Tx9, clk_8Tx1#US, sbn_7Hq2 |
| kind | cycle, schedule, ask, task, deadline, rung, due, window or money | rung |
| name | the rung, the deadline's point, the schedule or the step | notice 90d, at_risk, annual |
| occurrence | the period, the item or the cycle, by name | 2027, US-NY, #3 |
@<move> | how many times the clock has moved, on a notice, escalate, ack or due timer | @0, then @1 after the extension |
A run rung leaves out @<move>, so a move can never arm a second extension. Keys compare byte by byte, which settles the tick's last tie.
| Status | Holds | The runtime |
|---|---|---|
armed | owed_at | fires at its owed time, in its place |
fired | fired_at, commit | done: every later wake answers replayed: true with the same commit |
superseded | superseded_by, by | replaced before it fired: a move, a repin or a policy change armed the successor superseded_by names |
held | owed_at: null, held_since, held_by, remaining | paused while the job waits on a party: the time left is kept, not spent |
A held timer resumes under its own key at resumed_at + remaining. A date the world sets never waits: clock rungs are never held, and a deadline that follows a clock never moves its late_at. Only its at-risk timer is held, and it re-arms at the earlier of resumed_at + remaining and late_at; one whose time passed while it was held fires at resume, in place. Acme's at-risk timer, held from the mint by the organizer and resumed when the books arrive:
→ GET /timers/tmr_9Tq6
{
"id": "tmr_9Tq6",
"object": "timer",
"key": "job_4Tx9:deadline:at_risk:2027",
A guarantee with excludes: 'held' reads the same state: time held on the client is never the brand's to pay for. A deadline pauses the same way.
An armed timer re-arms only on one of these, recorded with what caused it:
| On | The runtime | For example |
|---|---|---|
clock.moved | supersedes each notice, escalate, ack and due timer still ahead and arms its successor at @n+1; the run timer keeps its key | the extension moves the federal date to 2028-10-16, and notice 30d:2027@1 rings on 2028-09-16 |
policy.set | supersedes a silent timer and re-arms it from the last activity, with the new span | api.forsale's agent moves stall_days from 14 to 7: a deal whose account last replied on 3 November goes gone_dark on 10 November, not 17 |
| a hold, and its resume | holds and resumes as States says | the organizer waits on the books |
calendar.postpone | moves each clock it names, which re-arms as clock.moved | a disaster relief notice |
an edit to the calendar's closed days | re-rolls every date it touches: the calendar is never pinned | a closed day the IRS declares in March |
a commit that changes what a due read | runs due again; a new answer is clock.moved { by: 'due' } | a signed Form 872 sets statute_extended_to, and the assessment and refund dates move to it |
clock.repinned | re-arms the clock's timers under the newer bundle, superseding with by: 'repin' | a 2027-06 return joins Acme's subject |
subscription.update | re-arms the next mint with the new input; jobs already minted keep theirs | Acme adds Texas in March 2027 |
Nothing else re-arms a timer; a deploy never does.
After an outage, a deploy or a year's advance, every timer owed in the gap fires in the tick's order, each kind by its rule:
| Kind | After a gap, the runtime |
|---|---|
| a schedule | mints each missed occurrence as its own period, and period_taken keeps each one once; catch_up can mint only the latest |
| clock rungs | sends only the latest owed notice or escalate rung, and records the earlier ones superseded; a run rung runs once |
ack daily | records each missed day |
| a deadline | records at risk, then late, in order |
| money | collects every owed line, in order |
A timer more than 5 minutes late lists on the owner console with its late_by. One armed after its day fires at once and is never late.
A test clock, tc_…, is frozen at a time and moves only forward:
| Call | Answers |
|---|---|
POST /test_clocks/:id/advance { to } | 202 advancing, then test_clock.ready on the feed once every timer owed by then has fired |
advance { to, redeliver: true } | the same, with every wake delivered twice: each second one answers replayed: true, and nothing outside is sent twice |
| an advance backward | 409 test_clock_backward, with the call that creates a clock there |
DELETE /test_clocks/:id | voids every job on it |
tick 1y, tick --to <time> | the CLI's shorthand; --trace prints each pass, what fired and what it armed, --compare runs the span every way, and --redeliver adds a run with every wake twice |
A week of one brand's time:
$ npx services.dev tick --to 2026-12-01T09:00Z --test-clock tc_7Hq3 --trace
tc_7Hq3 · 2026-11-24 09:00:00Z → 2026-12-01 09:00:00Z · UTC · 10 fired · 0 held · each instant in place order
pass owed fired place kind on what
1 2026-11-24 14:02:11Z 2026-11-24 14:02:11Z 3 ask job_7Hq7 glossary · reminded 1d before expiry
1 2026-11-25 14:02:11Z 2026-11-25 14:02:11Z 1 cycle sbn_7Hq2 renewed · cycle 2 · $149.00 posted as owed · allowances reset, 0 of 4
Advancing to where it already is fires nothing. Live, the same timers fire on the brand's clock, each event with owed, fired and late_by.
A wake comes at least once, so the runtime makes a second one harmless. The gate commits each key once: a second wake of a fired timer answers replayed: true with the first commit, and counts in wakes. A step that sends to an outside system carries one submission id per attempt, and one whose send crashed before its receipt was recorded asks the system before it sends again: a send in doubt.
tick 1y --compare --redeliver runs the Tax Plan's year four ways: one advance, 365 daily ones, wall time, and one advance with every wake twice. The sha256 is over the record in order, each event's type, subject, owed time, key and data. Fired time, late_by, attempt and wakes are left out, since they differ by way. All four agree, and the run states its three invariants:
$ npx services.dev tick 1y --compare --redeliver # the Tax Plan's year, 2027-01-20 14:00Z to 2028-01-20 14:00Z, run four ways: the hash is over each event's type, subject, owed time, key and data, in order
one advance of 1y on a test clock 295 events · sha256:f6dbf921ab734154…
365 advances of 1d on a test clock 295 events · sha256:f6dbf921ab734154…
a year of wall time, the brand's own clock, one alarm wake 295 events · sha256:f6dbf921ab734154…
one advance, every wake delivered twice 295 events · sha256:f6dbf921ab734154… · 214 replayed: true · 0 submissions sent twice
A version is a bundle: the Service file and every module it imports, lib/tax.ts, rules.ts, the Pool and the Services it orders with it, printed 2027-06 · sha256:c71e44b2…. What a version pins, what counts as breaking, and moving jobs in flight are on Versions; this section is what each timer does across them.
| What | Runs on |
|---|---|
| a job, its clocks and its armed timers | the bundle it opened under; a deploy never rewrites an armed timer |
| a minted job | the bundle live at that mint |
a clock with lives_on: 'subject' | its job's bundle, until a newer job of the Service joins the subject: it repins, recording clock.repinned |
| a version with armed timers | stays runnable until its last timer closes; GET /versions?pinned=true lists each one |
| a rollback | arms nothing and rewrites nothing: each timer keeps the version it was armed under |
| a repin you choose | POST /owner/jobs/:id/repin { to, dry_run } prints the diff, timer by timer, then supersedes each changed timer by: 'repin' |
In June 2027 the tax brand adds a 90-day notice, shortens an ack and changes lateFiling in lib/tax.ts. The 2026 return, opened in February, keeps its rungs and its lib:
npx services.dev check --against live services/annual-return.tsservices/annual-return.ts · against live 2027-01 · sha256:3f9a0c1d… · deploys as 2027-06 · sha256:c71e44b2…
bundle the Service file and every module it imports: lib/tax.ts, rules.ts, pools/preparers.ts, services/amended-return.ts, services/carryforward-watch.ts
rungs clocks.file: + notice 90d · ack daily 7d → ack daily 5d
lib lib/tax.ts: lateFiling changed · fileDue, extendedDue, payDue and refundCloses unchanged
armed job_4Tx3 · 38 armed timers on 2027-01 · they keep it, rungs and lib/tax.ts both
pinned 2027-01 stays runnable until its last timer closes · a rollback to it arms nothing and rewrites nothing
next jobs opened and minted from the deploy run 2027-06 · a subject clock repins when a 2027-06 job joins its subjectA repin the owner chooses, tried first:
→ POST /owner/jobs/job_4Tx3/repin { "to": "2027-06", "dry_run": true }
arm clk_7Hq9#US:rung:notice 90d:2026@1 2027-07-17 13:00Z
arm clk_7Hq9#US-NY:rung:notice 90d:2026@1 2027-07-17 13:00Z
arm clk_7Hq9#US-CA:rung:notice 90d:2026@1 2027-08-17 13:00Z
arm clk_7Hq9#US-NJ:rung:notice 90d:2026@1 2027-08-17 13:00Z
Every version still running a timer:
→ GET /versions?pinned=true 2027-06 sha256:c71e44b2… live · 1,412 armed timers on 61 subjects
2026-01 sha256:9d2c7a50… 1 clock · expiring on sbj_7Hr3, to 2045-12-31 · its client left before a newer return joined
retired 2027-01 on 2028-02-01, when Acme's 2027 return joined its subject and its last clocks repinned