Timers and replay

The running jobjob_7Hq2 · working · $49.00 held

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 timerHolds
keywhat it is, by the grammar below: clk_8Tx1#US:rung:notice 90d:2027@0
statusarmed, fired, superseded or held: States
placeits kind's place in the tick, 1 to 8
owed_at, armed_at, fired_at, late_bywhen it was due, armed and run, and the gap
attempt, wakeshow many times it proposed, and how many times it was delivered
versionthe bundle it was armed under: Across versions
committhe 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:

captured/time-timers.txt · magic values: none
$ 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:

captured/time-timers.txt · 200 · tmr_9Tq2 · fired · magic values: none
→ 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:

PartIsapis.tax's
ownerthe job, the clock occurrence or the subscription the timer belongs tojob_4Tx9, clk_8Tx1#US, sbn_7Hq2
kindcycle, schedule, ask, task, deadline, rung, due, window or moneyrung
namethe rung, the deadline's point, the schedule or the stepnotice 90d, at_risk, annual
occurrencethe period, the item or the cycle, by name2027, 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.

StatusHoldsThe runtime
armedowed_atfires at its owed time, in its place
firedfired_at, commitdone: every later wake answers replayed: true with the same commit
supersededsuperseded_by, byreplaced before it fired: a move, a repin or a policy change armed the successor superseded_by names
heldowed_at: null, held_since, held_by, remainingpaused 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:

captured/time-timers.txt · 200 · tmr_9Tq6 · armed · magic values: none
→ 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:

OnThe runtimeFor example
clock.movedsupersedes each notice, escalate, ack and due timer still ahead and arms its successor at @n+1; the run timer keeps its keythe extension moves the federal date to 2028-10-16, and notice 30d:2027@1 rings on 2028-09-16
policy.setsupersedes a silent timer and re-arms it from the last activity, with the new spanapi.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 resumeholds and resumes as States saysthe organizer waits on the books
calendar.postponemoves each clock it names, which re-arms as clock.moveda disaster relief notice
an edit to the calendar's closed daysre-rolls every date it touches: the calendar is never pinneda closed day the IRS declares in March
a commit that changes what a due readruns 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.repinnedre-arms the clock's timers under the newer bundle, superseding with by: 'repin'a 2027-06 return joins Acme's subject
subscription.updatere-arms the next mint with the new input; jobs already minted keep theirsAcme 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:

KindAfter a gap, the runtime
a schedulemints each missed occurrence as its own period, and period_taken keeps each one once; catch_up can mint only the latest
clock rungssends only the latest owed notice or escalate rung, and records the earlier ones superseded; a run rung runs once
ack dailyrecords each missed day
a deadlinerecords at risk, then late, in order
moneycollects 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:

CallAnswers
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 backward409 test_clock_backward, with the call that creates a clock there
DELETE /test_clocks/:idvoids 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:

captured/time-tick.txt · magic values: client.email = silent@example.test, metadata.sandbox.ai = down
$ 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:

captured/time-three-ways.txt · magic values: owner.email = signs@example.test, metadata.sandbox.asks = {"organizer.books":"after 160d","organizer.assets":"waive","organizer #2":"after 2d"}, metadata.sandbox.outside = {"reject":{"file.US-NY":["ein_mismatch"]}}, metadata.sandbox.events = { notice.received: { at: 2027-11-14T15:00:00Z, data: { notice, reply_by } } }
$ 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.

WhatRuns on
a job, its clocks and its armed timersthe bundle it opened under; a deploy never rewrites an armed timer
a minted jobthe 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 timersstays runnable until its last timer closes; GET /versions?pinned=true lists each one
a rollbackarms nothing and rewrites nothing: each timer keeps the version it was armed under
a repin you choosePOST /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:

Run
npx services.dev check --against live services/annual-return.ts
magic values: none
services/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 subject

A repin the owner chooses, tried first:

captured/time-timers.txt · 200 · dry run · job_4Tx3 · 2027-01 → 2027-06 · 12 timers differ · nothing committed · magic values: none
→ 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:

Call
→ GET /versions?pinned=true
200 · 2 versions · each runnable until its last timer closes · magic values: none
  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