# Acta for coding agents Describes Acta 1.0.0-rc.10: the packages Acta.Sqlite, Acta.Postgres, Acta.SqlServer, Acta.Dashboard, and Acta.AspNetCore at that version. Verified 2026-11-11. When this file and an installed package disagree, the package wins: the compiler and ` jobs help` outrank this file. In an existing app, read its resolved Acta version first and follow the docs at that tag, github.com/acta-dotnet/acta/tree/v; do not upgrade the app to match an example. Acta is a durable job and workflow engine for .NET backed by the application's own SQL database (PostgreSQL, SQL Server, or embedded SQLite). One handler can record steps and variables, start child jobs and fan out with Map, Parallel, and Join, and wait for signals and timers without holding a worker; lanes keep per-entity order, and concurrency and rate limits hold across every worker. Jobs, schedules, steps, waits, and alerts are ordinary rows. This file is the contract an agent needs to add Acta to a project correctly; the runnable proof lives in the repository's `concepts/` directory (compiled, one project per concept) and the complete demo apps under `demos/`. Start at useacta.net or github.com/acta-dotnet/acta. ## The one rule everything follows from Execution is at-least-once, and a handler re-enters from the top. After a crash, a lease expiry, a retry, or a resumed wait, Acta runs the handler method again from its first line. Completed steps, variables, timers, and signal waits return their recorded results instead of repeating their work; every bare line between them runs again on every entry. The model is checkpoints, not replay: there is no determinism requirement on your code, and there is no magic resume-at-line-N. Those recorded results and the ledger make re-entry something you can reason about and test, because everything a handler decided is a row you can read. Write handlers as if the method will be called twice with the same input, and put every external side effect behind a step. ## First run: one file, one job, the dashboard ```bash dotnet new web -n Shipping && cd Shipping dotnet add package Acta.Sqlite --prerelease dotnet add package Acta.Dashboard --prerelease ``` Replace `Program.cs` with this, all of it: ```csharp using Shipping; // the generated manifest lands in your project's root namespace using Acta; using Acta.Dashboard; using Acta.Sqlite; var builder = WebApplication.CreateBuilder(args); builder.Services.UseActa(j => { j.UseSqlite(sqlite => { sqlite.ConnectionString = "Data Source=acta-local.db"; sqlite.MigrateOnStartup = true; // local development only; in production a deploy step runs "jobs migrate" }); j.Run("shipping"); }); var app = builder.Build(); app.MapActa("/acta"); // dashboard + JSON API; local-only by default, controls disabled // Your own endpoint enqueues: the job is a durable row before the response goes out. app.MapPost("/orders/{orderId:int}", async (int orderId, IJobs jobs) => { var job = await jobs.EnqueueAsync(new ShipOrder(orderId)); return Results.Accepted("/acta", new { job.JobRef }); }); app.Run(); public sealed record ShipOrder(int OrderId); public static class ShippingHandlers { [Job("ship-order")] public static void Handle(ShipOrder input) => Console.WriteLine($"Shipping order {input.OrderId}"); } ``` `dotnet run`, then `curl -X POST http://localhost:/orders/1042` on the port it printed: the app prints `Shipping order 1042` and the dashboard lists the job. Rules this sample carries: - `[Job("ship-order")]` is required and a name: dotted kebab-case, `[a-z][a-z0-9-]*` segments joined by `.` (`billing.send-invoice`), no underscores, no trailing dash, at most 128 chars. The bare `sys` and the `sys.` prefix are reserved for Acta's own jobs. The name is the durable operator-facing contract in SQL, the dashboard, the CLI, and alerts. - `ShippingJobs` is the source-generated manifest, named from the LAST segment of the project's `RootNamespace` plus `Jobs` (`Users` → `UsersJobs`; a segment already ending in `Jobs` gets `Manifest` instead: `TestJobs` → `TestJobsManifest`). It is generated into that root namespace, so a `Program.cs` with a top-level namespace needs `using Shipping;` to see it. - Enqueue is type-driven: the input record's type routes the call, one `EnqueueAsync` for every job. It answers a `JobHandle`: `await job.WaitAsync()` follows the job to its outcome without stopping it. `ScheduleAsync(input, TimeSpan)` or `ScheduleAsync(input, DateTimeOffset)` runs one job later; a recurring schedule is `[Schedule]`. The host that enqueues can also execute; there is no separate worker process unless you want one. - A schedule's instants come from a cron, an interval, or a calendar bound by name (`Calendar = "pay-days"`), and any schedule fires only inside an availability calendar's windows when it names one (`Availability = "office-hours"`). A name is a catalog family, defined as JSON and published through `IActaOperations.Calendars`, the HTTP API, or the dashboard's Calendars page, in the namespace or shared in `sys`. Publish it before the deploy of the jobs that name it: a deploy whose schedule names a calendar with no published version fails and writes nothing. The page is `docs/scheduling.md`. - Each job's member on `ShippingJobs` is a factory taking the handler's input parameters and returning the job's call: `jobs.EnqueueAsync(ShippingJobs.ShipOrder(new ShipOrder(orderId)))` names the job outright, and a result job's call answers `JobHandle`. An enqueue that reads no result can pass the input alone, options included: `jobs.EnqueueAsync(new ShipOrder(orderId), o => o.DeduplicationKey(key))`. A handler may take several input parameters before `JobContext` and `CancellationToken`; they travel as one generated record, `ShippingJobs.{Job}Args`, whose parameter names are stored JSON keys. Under Native AOT, declare the input record yourself and take it as the one parameter. - The handle follows the job without another lookup: `await handle.WaitAsync()` answers its outcome, `await handle.GetResultAsync()` returns the stored result and throws when there is none, and `await handle.TryGetResultAsync()` answers `(Found, Value)` for a result that may not exist yet. From a ref the same reads are on `IJobs`: `jobs.GetResultAsync(jobRef)`. Neither read waits. - Handlers resolve through DI: an instance class's constructor dependencies must be registered. One DI cannot build fails its job on the first attempt with `HandlerActivationException`, never retried, and the message names the missing service. ## Wiring rules by situation - New app: ASP.NET Core empty host + `Acta.Sqlite` + `Acta.Dashboard` with `MapActa("/acta")` and one real job, exactly as above. - Existing app: inspect its hosting and database first and preserve both. Add the matching provider package (`Acta.Postgres` / `Acta.SqlServer` / `Acta.Sqlite`) and register with the existing connection string; do not change the host type to add Acta. - Producer and worker in the same assembly: typed `EnqueueAsync` plus `j.Run(...)`, as above. This is the default. - Split deployment (API enqueues, separate worker executes): the worker registers `Run`; the producer enqueues by the raw route contract instead of the typed call so it does not need the handler assembly. - Never enable `MigrateOnStartup` outside an explicitly local or development configuration. There, startup applies the schema and writes the app's jobs and schedules, so one `dotnet run` is enough. - Production runs ` jobs migrate` from a deploy step before the new build starts: it applies the schema, deploys the app's job definitions and schedules, and records the app's version. ` jobs deploy` does the deploy alone and needs no DDL right. A worker start writes no definition or schedule, and refuses a namespace its build's version has not deployed in. - Once `jobs migrate` or `jobs deploy` has deployed a namespace, a `MigrateOnStartup` start writes no definition or schedule there, on a local database too: a new or changed job then takes `jobs deploy`, or a fresh local database. - Give each build its own version: `JobsOptions.DeploymentVersion`, by default the entry assembly's informational version. An app that is `1.0.0` on every build is one version to Acta. - An external outbox table comes from the producer's own migrations (`CreateOutboxScript()`) or `ApplyOutboxAsync`; Acta never creates it on its own. - Rolling deploys are routine: run ` jobs migrate`, start the new build, stop the old one, with any overlap. Acta's migrations are forward-only and keep an old and a new build working on one schema; a worker hands back a job whose handler it lacks and stops claiming that definition; a deploy never retires a definition the new build omits, so a rollback keeps the queue. What the application keeps is its own, as in any rolling deploy: a queued input the new build still reads, and a step name a running job still finds. Retiring a dropped definition and cancelling its queue is an operator action on the definition. - Definitions and schedules are the current deployment's. An older build's workers write nothing, and its `jobs deploy` changes nothing unless run with `--force`, which is how a rollback restores the earlier definitions. A schedule a deploy drops is orphaned and loses its pause, so to hold a permanent job across a rollback, pause the job, not the schedule. ## Steps `await ctx.RunStepAsync("name", ct => ...)` is a named run-once step. On re-entry a completed step returns its recorded result instead of running the body. That promise has two conditions: - The outcome is durable only after it is recorded. A crash after the side effect ran but before the outcome landed re-runs the body on replay, so the body must be idempotent, usually via a stable key derived from the job, never from the attempt. - Only step-wrapped work is skipped. Bare handler code between steps re-runs on every entry. A step retries through its job: a body that throws fails the attempt, the job's `MaxAttempts` and `Backoff` retry it, and the retry replays the steps that succeeded and runs this one again. A wait inside a step body works: the resume runs the body up to the wait, which returns what it has, and charges nothing. To compensate before the last failure lands, catch on `ctx.IsLastAttempt`, which reads the budget the job has already spent (and a Strict deadline), never the execution number: `catch (Exception ex) when (ctx.IsLastAttempt || ex is StepFailedException) { await ctx.RunStepAsync("release-stock", ...); throw; }`. The filter takes `StepFailedException` too because a non-retryable exception ends the job on any attempt. That is best effort: a crash or a lost lease on the last attempt skips the catch, so the compensation is an idempotent step, and work whose compensation must happen runs as a child job the parent compensates when the child lands `Failed`. For side effects where a duplicate is worse than an ambiguous interruption (a payment capture), `AtMostOnce()` runs the body zero-or-one times and hands the handler an explicit interrupted state to reconcile. A body that throws fails the step for good: every later attempt throws `StepFailedException` for it without running the body, so left uncaught it fails the job at once; catch it where the job can go on, and an operator restart runs the step again. A wait inside an at-most-once body throws at the call, because its resume would run the body again. ## An external side effect, done right ```csharp using Acta; namespace Shipping; public sealed record DeliverWebhook(Guid DeliveryId, string Url, string Payload); public sealed class WebhookJobs(HttpClient http) { [Job("deliver-webhook", MaxAttempts = 5)] public async Task Handle(DeliverWebhook input, JobContext ctx, CancellationToken ct) { // The idempotency key comes from the JOB (stable across attempts), never the attempt. var signature = await ctx.RunStepAsync("sign-payload", _ => Task.FromResult($"{input.DeliveryId}:{input.Payload.Length}"), ct); var status = await ctx.RunStepAsync("post", async token => { using var request = new HttpRequestMessage(HttpMethod.Post, input.Url); request.Headers.Add("Idempotency-Key", input.DeliveryId.ToString()); request.Headers.Add("X-Signature", signature); request.Content = new StringContent(input.Payload); using var response = await http.SendAsync(request, token); // A failed delivery throws, so the step records no success and the attempt retries; which // statuses deserve a retry is this application's call, not a framework policy. response.EnsureSuccessStatusCode(); return (int)response.StatusCode; }, ct); await ctx.RunStepAsync("record-response", _ => Task.FromResult(status), ct); } } ``` Register `HttpClient` in DI (`builder.Services.AddHttpClient();`). A step that recorded its outcome is reused on re-entry, so a crash after `post` recorded its status never sends again; `record-response` replays from the row. The window for a duplicate is narrower: the receiver accepted the request, then the process died before Acta recorded the `post` outcome. The retry sends again with the same `Idempotency-Key`, because the key comes from the job's input and not from the attempt, and the receiver deduplicates on it. That is the at-least-once contract working: an effect can repeat only under its own key. ## Do this, not that | Instead of | Do | Because | | --- | --- | --- | | static/instance fields for progress | `SetVariableAsync` / `GetVariableOrDefaultAsync` | fields die with the process; variables are rows | | `Task.Delay` or `Thread.Sleep` | `await ctx.SleepAsync("cool-down", delay, ct: ct)` | delays hold a worker; durable sleep frees it and survives deploys | | calling an API directly in the body | `await ctx.RunStepAsync("charge", ct => ...)` | a step records its outcome, so a retry does not repeat recorded work | | `catch { }` around the body | let it throw, or `await ctx.FailAsync(reason)` | swallowing turns a retryable failure into a silent success | | polling a flag in a loop | `await ctx.TryWaitSignalAsync("approved", timeout)` | polling burns a worker; a signal parks the job, and the timeout keeps it from parking forever | | starting children and looping until done | `await ctx.WaitChildrenAsync(ids)` (`TryWaitChildrenAsync(ids, timeout)` with a deadline), or `MapAsync` when the children come from a list | the parent parks instead of occupying a worker | | `DateTime.Now` | `DateTime.UtcNow`, or a value from the input | the ledger is UTC throughout | ## Waiting for an outside event A handler parks on a named signal and something outside raises it: a webhook, an operator, another job. `var r = await ctx.TryWaitSignalAsync("shipped", TimeSpan.FromDays(14), ct);` returns a `SignalWaitOutcome`; branch on `r.TimedOut` to handle the deadline in code. The other overload, `await ctx.WaitSignalAsync("shipped", TimeSpan.FromDays(14), ct)`, returns nothing, and a timeout ends the job `Cancelled` with reason `wait-timed-out` instead of returning; use it only when a missed deadline should stop the job. Both are typed too: `TryWaitSignalAsync` returns `SignalWaitOutcome` with `Value`, and `WaitSignalAsync` returns the raised value. The raising side is `await jobs.RaiseSignalAsync(JobLookup.ByRef(jobRef), "shipped", new Shipped(trackingId))` on `IJobs`. The deadline is stored once, as database time plus the timeout, so restarts and downtime never lengthen it, and a raise that lands before the parked job re-enters still wins. ## Recurring work `[Schedule("every-5s", "PT5S")]` on a `[Job]` makes a permanent job: one durable job the schedule fires again and again. The job sits `Idle` between firings and holds no thread; the admission engine makes it `Ready` when a schedule is due, a worker claims it like any job, and the handler returns when its bounded work is done. Never loop forever inside the handler and never poll from it: a firing that finds nothing to do returns, and the next firing comes on schedule. Acta's progress is the schedule cursor (`next_occurrence_at_utc`); the handler's progress, such as a synchronization watermark, is a durable variable on the job (`ctx.GetVariableOrDefaultAsync` at the top of every execution, `ctx.SetVariableAsync` after the work it covers), carried across firings, retries, and workers, so a recovered firing continues where the lost one stopped. Write the work first and the variable after it, and make the work an upsert: an attempt that dies between the two replays one batch. A definition whose occurrences are each their own job, with their own retries, result, and retention, declares `ScheduledDelivery = ScheduledDeliveryCode.PerOccurrence`. A finite schedule declares `StartAtUtc` and `EndBeforeUtc` (UTC with a `Z`): its occurrences fall in [start, end) and it reads `exhausted` past the end. The runnable proof, with a killed worker, is `concepts/100-scheduling/109-continuous-synchronization`. ## Choosing between a lane, a key, a limit, and a rate One axis each: a lane decides ORDER, a concurrency key and its limit decide HOW MANY run at once, and a rate decides HOW OFTEN. For one job at a time per customer, account, or aggregate, use a lane: it adds order, and the jobs waiting behind its head cost the claim nothing, where a busy key's waiting jobs are claimed, bounced, and re-armed. Use a bare `ConcurrencyKey` only when order must not hold work up: a lane's failing head blocks the jobs behind it until it succeeds or exhausts MaxAttempts, and a key's does not. Use `ConcurrencyLimit` for N at once and `RateLimit` for pacing; they combine. ## Limiting how much runs at once `options.ConcurrencyKey("rebuild:acme")` on an enqueue admits one job of that key at a time, cluster-wide: the runner takes the key's lease after the claim and before the handler, and a loser re-arms Ready a couple of seconds out without spending retry budget. `[Job("rebuild-index", ConcurrencyLimit = 4)]` sizes that gate: the key gets four concurrency slots instead of one. When an enqueue supplies no key, the definition name is the key, so a limit alone throttles the whole definition. Two definitions on the same key share its slots and the key's capacity is the largest limit among them. It is mutual exclusion, never ordering: an older job can be overtaken while the key stays busy. ## Running jobs in order `options.Lane("customer-42")` on an enqueue, or `[Job("sync-orders", Lane = "orders")]` on a definition, puts the job in a lane: one job of the lane runs at a time, in enqueue order, like an SQS FIFO message group. The jobs behind the head are `Blocked` until it finishes (Succeeded, Failed on its last attempt, or Cancelled), and a failing head keeps retrying in place, holding the lane. Rows staged in sequence through the outbox with a lane reach it in staging order, a rejected row keeping its place. An enqueue lane overrides the definition's. A lane is a name: ASCII dotted kebab-case up to 128 characters, lowercased on input. Rules that bite: never enqueue a child into the lane of an unfinished ancestor (refused at enqueue), never put a lane on a scheduled definition (refused), give waits across lanes a timeout, and restart of a finished laned job runs that job again, right after the lane's current head. `[Job("send-invoice", RateLimit = "10/s")]` paces how OFTEN attempts are admitted, which is a different gate from how many run at once, and a job passes both. It paces admissions, not starts, and is no hard burst cap: a turn admitted late can land beside an on-time one. `RateName` names the meter when several definitions should share one; omitted, the meter is the definition name. A meter belongs to one namespace: definitions in different namespaces never share it, so a partner quota shared by two services needs its rate split between them. Definitions on one meter must declare the same rate. A job that arrives early is BOOKED the next free instant: under a quarter second away the executor sleeps it out and takes it, farther away the job re-arms Ready at exactly that instant without spending retry budget (reason `rate-limited`, never a failure and never an alert). A booked turn stays valid for a second, or for the rate's interval when that is longer, so a backlog drains in arrival order at the rate with one re-arm per job; a job picked up later than that is metered again. ## Payloads Inline payloads (inputs, step results, variables) are capped at 1 MiB (`MaxInlinePayloadBytes`). Caller-supplied oversized writes throw `PayloadTooLargeException`; an oversized handler RESULT is dropped while the job still succeeds, its success event carrying reason `result-oversized`, and an oversized step result fails the job for good, since a retry would run the step again. Big artifacts belong in blob storage with the job carrying a reference: URI, checksum, size. By default an input with no data uses the `none` format, `byte[]`/`ReadOnlyMemory` use `bytes`, and every other input, scalars included, is JSON (`"hello"`, `42`); an explicit or custom format overrides that. A raw or HTTP enqueue in the json format sends strict JSON (one value, no comments or trailing commas) or is refused. ## Things that are not what they sound like - A `Suspended` job is healthy, not stuck: it is waiting on a signal, a child, or a timer. A wait armed with a timeout resolves itself when the deadline passes; an unbounded wait parks until raised. - Deduplication keys keep their case (printable ASCII, no whitespace, surrounding whitespace trimmed): `Order-1` and `order-1` are two keys, so lowercase an id yourself when its source is case-insensitive. - The enqueue `deduplicationKey` prevents a second job ROW, not a second side effect; execution stays at-least-once either way. - Tags are for finding jobs, not for carrying data: input is typed and versioned, tags are searchable labels. - A permanent job keeps its step results and variables from one fire to the next: a step that succeeded on Monday returns Monday's result on Tuesday. Put the period in the step name (`$"invoice-{period}"`), or call `ctx.ResetStateAsync` as the handler's last action so the next fire starts clean. Never at the top: a reset there also runs on every retry and replay of the same fire, erasing its own progress, so succeeded steps run again and an `AtMostOnce` body can run twice. - Children a permanent job starts are never purged while its schedule lives, since retention keeps a child as long as its parent is unfinished. For per-occurrence fan-out, declare the definition `ScheduledDelivery = ScheduledDeliveryCode.PerOccurrence`: each occurrence is then a root job of its own, and the children it starts drain with it. ## Watch a job Every Acta host is also a control CLI: ` jobs ` runs against the app's own configured database and exits, starting no worker, so it must run with the same configuration and environment as the app. Keep the job ref an enqueue returns (`job_...`); it is the job's public identity everywhere. ```bash dotnet run --no-build -v q -- jobs status --json # {"jobRef":"job_...","status":"succeeded"} dotnet run --no-build -v q -- jobs explain --json # what happened and what to do next dotnet run --no-build -v q -- jobs events --json # the timeline, newest first, --take to bound it ``` - `-v q` keeps `dotnet run`'s own `Using launch settings from ...` line off stdout, so `--json` output pipes into `jq`; the launch profile, and with it the environment, still applies. - `explain` first for anything stuck, waiting, or failed: one read of the status, lease, worker, newest steps, checkpoints, and latest reason, with a headline and the next actions. - Exit codes: 0 found or applied, 1 rejected or failed, 2 job not found, 64 usage error, 130 cancelled. - `status`, `info`, `result`, `explain`, and `events` read. `cancel`, `pause`, `resume`, `restart`, `signal`, `debug` (runs the handler in this process), `deploy`, and `migrate` change things. - A `ready` job with a future due instant, or a `suspended` one, is healthy: it is waiting for its time or its signal, and `explain` names what it waits for. A caller's wait timeout stops only the wait; the job keeps running. - The dashboard at `/acta` shows the same job, and `GET /acta/api/v1/jobs/{jobRef}` answers it as JSON. ## The HTTP API `Acta.AspNetCore` serves Acta's operations over HTTP: `MapActaApi` maps the API alone, and `MapActa` from `Acta.Dashboard` maps it under `{pattern}/api/v1` beside the dashboard. Its contract is the OpenAPI document `docs/reference/openapi.json` in the repository. Generate a client from that document with standard OpenAPI tools; Acta ships no client package. ## When you are unsure Every pattern above exists as a compiled, runnable example under `concepts/` in the repository: numbered rungs from fundamentals to chaos recovery, each a small project for one idea. The demo apps under `demos/` are complete deployable applications built only from the published packages. The one complete walk from enqueue to the first test, on the consumer packages, is `docs/first-job.md`; the rest of the `docs/` tree is indexed by task in `docs/README.md`. Start at useacta.net.