How Acta works
Everything is a job, and every job is a row.
Acta is a library in your .NET process and tables in your database. Everything that has to outlive a request is a job: a row a worker claims under a lease, retries under a budget, and records in a ledger. Steps, waits, signals, and progress are durable state on that row. Children are more rows, with lineage. A schedule makes one row claimable again and again, or starts a fresh one for each occurrence. One model, one engine, one set of views, and nothing else to run.
The model in one figure
A permanent job, through one firing.
A scheduled definition owns one permanent job by default, and the figure follows it. Admission makes the job claimable when a schedule is due, a worker runs the handler for a bounded amount of work, and the job returns to Idle holding no thread. Acta's progress and the job's are two different rows, and they never touch.
The schedule cursor says when the job is next due; the firing envelope says which schedules admitted this firing and how it settled. Admission writes both in one apply, so an occurrence is admitted once across every worker.
The watermark is a variable on the job, written by the handler after each batch and read first on every execution. Acta keeps it the way it keeps the job: a row in checkpoints_view, carried across firings, retries, and workers. It is where the next firing starts, and where a recovered firing continues after a crash.
A firing that finds nothing to sync still moves the cursor. A manual trigger moves the watermark and not the cursor. Each kind of progress answers one question, and neither has to be reconciled with the other.
Ten questions
The model, one question at a time.
Each answer links to the section of the developer guide that states it in full, and where there are rows to look at, to the lab that prints them.
The only thing a worker claims. A job is a row with a name, an input, a status, a lease, a retry budget, and a ledger of events. A worker claims it under the lease, runs your handler, and writes what happened. Nothing else is claimed on its own.
A definition is the [Job] on a handler: the durable name, the retry policy, the schedules. An instance is a row made from it, enqueued by your code, admitted by a schedule, or started by a parent. One definition, any number of rows, each with its own public handle.
Durable state that belongs to a job. A step records its outcome, so a re-run returns the stored result instead of repeating the work; variables, timers, signals, and progress are checkpoints on the same job. They share the job's claim, lease, retry budget, and events, and are never claimed on their own.
Ordinary jobs. A parent starts children through Map, Parallel, or Join; each child is a row with the parent's lineage, its own retries, and its own result, and the parent waits on a durable latch without holding a worker. Fan-out is more jobs, not another kind of thing.
The default for a definition with a [Schedule]: one permanent job the schedule fires again and again. It is Idle between firings and holds no thread; admission makes it Ready when a schedule is due, a worker claims it, and the handler does a bounded amount of work and returns. Its steps and progress persist across firings, and the handler may reset them as its final action. This is how a synchronization controller runs without a loop that must never die.
The other mode for a schedule. Each occurrence becomes a fresh ordinary root job with its own state, retries, result, and retention, and MaxOutstanding bounds how many of one schedule's roots may be unsettled at once. Use it when each period is its own business event: a statement run, an import whose files must each be tracked. Backfilling past periods is application work under either mode.
Two passes over the same rows, on every worker. Admission turns a due schedule into a firing: it moves the cursor and writes the firing envelope in one apply, so an occurrence is admitted once however many workers run. Claiming takes Ready rows under a lease and runs them. A schedule never runs code; it only makes a row claimable.
The row, its status, the lease, the firing envelope, every recorded step, every checkpoint, and every event are in your database. The dead worker's lease runs out, a heartbeat on any worker reclaims the row, and the next execution re-enters the handler from the top, where the recorded steps and your own committed progress say where to continue. The figure and the rows below are from a real kill.
A step's recorded outcome is repeat-safe, but your writes and Acta's record of them commit separately, so a handler can run again for work that already landed. An idempotency key, an upsert, or a watermark written after the work makes that harmless. AtMostOnce() trades a possible repeat for an explicitly ambiguous outcome.
Because a workflow is a composition of jobs and their durable state, not a second runtime. A parent, its children, a long wait, a permanent controller: the same claim, lease, recovery, retry budget, dashboard, CLI, and SQL views serve all of them. There is nothing new to deploy or operate, and no determinism contract on your code.
The crash, in rows
Kill the worker after a batch. The next execution reads the same watermark and continues.
The synchronization lab arms a crash after the next batch, kills its own process, and is restarted. What the rows said before and after, on embedded SQLite, from a fresh clone.
-- after the kill: the job the dead worker left behind, and the watermark it wrote
job_ref job_status execution_number leased_by_worker_host_name lease_expires_at_utc
01a18cba-b966-7a5a-aaf2-83061ba0af99 executing 7 DESKTOP-MVKQH5M 2026-10-30 12:14:08.857Z
sys.firing pending {"firingId":"01a18cbb-422e-7ac2-9e3…
watermark variable 160
feed head: change 180; local orders: 40 (newest change 160)
-- after the restart: the reclaim, the same firing finished under execution 8, the next admission
19 job.execution-orphaned 7 {"reason":"lease-expired","to":"ready"}
20 job.execution-succeeded 8
21 schedule.skipped 8 {"schedule":"every-5s","from":"2026…
22 schedule.admitted 8 {"schedule":"every-5s","occurrence"…
watermark variable 180
feed head: change 180; local orders: 40 (newest change 180)
No admission sits between the reclaim and execution 8, so execution 8 ran the firing execution 7 lost, not a new one: it read watermark 160 and applied the twenty changes the dead process never reached. The occurrences missed while no worker ran were skipped by the misfire policy, and event 22 admits the next firing with an id of its own. The destination holds forty orders at change 180, one row per order. The lab README has every row in full, and its tests prove each claim on the real runtime without a sleep.
Also: how one worker runs a job·order, concurrency, and rate·many workers, no coordinator