# Acta for coding agents Acta is a durable job execution engine for .NET backed by the application's own SQL database (PostgreSQL, SQL Server, or embedded SQLite). 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 durable slots — steps, variables, timers, 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. Other schedulers also run work more than once; redelivery is not what makes Acta different. What is different: the durable slots 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.AspNetCore --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.AspNetCore; using Acta.Sqlite; var builder = WebApplication.CreateBuilder(args); builder.Services.UseActa(j => { j.UseSqlite(sqlite => { sqlite.ConnectionString = "Data Source=acta-local.db"; sqlite.ApplyMigrationsOnStartup = true; // local development only; apply from a deploy step in production }); j.Run("shipping"); }); var app = builder.Build(); app.MapActa("/acta"); // dashboard + JSON API; local-only by default, controls disabled await app.StartAsync(); await app.Services.GetRequiredService().EnqueueAsync(new ShipOrder(1042)); Console.WriteLine($"Enqueued. Dashboard: {app.Urls.First()}/acta"); await app.WaitForShutdownAsync(); 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`, watch the job complete, open the dashboard. Rules this sample carries: - `[Job("ship-order")]` is required and kebab-case, `[a-z][a-z0-9]*(-[a-z0-9]+)*`: no dots, no underscores, no doubled or trailing dash, at most 128 chars. The `sys.` prefix is 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. The host that enqueues can also execute; there is no separate worker process unless you want one. - Handlers resolve through DI: an instance class's constructor dependencies must be registered. ## Wiring rules by situation - New app: ASP.NET Core empty host + `Acta.Sqlite` + `Acta.AspNetCore` 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 `ApplyMigrationsOnStartup` outside an explicitly local or development configuration. Production schema comes from a deploy step. - Rolling deploys need no special handling: apply migrations, start the new build, stop the old one. A worker hands back a job whose handler it lacks and stops claiming that definition; registration never retires a definition the new build omits, so a rollback just works. Retiring a dropped definition and cancelling its queue is an operator action on the definition. - Schedules follow the last build that registered, so an old worker starting mid-rollout can leave a schedule the new build added Orphaned until a new worker starts, and orphaning drops a schedule pause. To hold a recurring job across a rollback, pause the job, not the schedule. ## Steps `await ctx.RunStepAsync("name", ct => ...)` is a named run-once slot. On re-entry a completed step returns its recorded result instead of running the body. Two honest halves of that promise: - 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 — 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 failing step retries on its own backoff without charging the job's retry budget. A step that exhausts its budget stays exhausted: every later attempt throws `StepExhaustedException` for it without running the body, so left uncaught it fails every retry at once and, in a recurring job, every fire. Catch it where the job can go on without the step; an operator restart gives the step a fresh budget. 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. ## 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); return (int)response.StatusCode; }, ct); await ctx.RunStepAsync("record-response", _ => Task.FromResult(status), ct); } } ``` Register `HttpClient` in DI (`builder.Services.AddHttpClient();`). The receiver can deduplicate on `Idempotency-Key` because it never changes across retries; a crash between `post` and `record-response` re-sends with the same key, which is the at-least-once contract working. ## 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)` (or with a timeout), 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 `SignalWaitResult`; 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 `job.wait-timed-out` instead of returning; use it only when a missed deadline should stop the job. Both are typed too: `TryWaitSignalAsync` returns `SignalWaitResult` 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. ## 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 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. Names follow the ConcurrencyKey rule: up to 128 key characters, case-insensitive. 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")]` caps how OFTEN attempts start, which is a different gate from how many run at once, and a job passes both. `RateKey` 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 `job.rate-limited`, never a failure and never an alert), and a booked turn stays valid for a second, so a backlog drains in arrival order at the rate with at most one re-arm per job. ## 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 with a `job.result-oversized` event while the job still succeeds. Big artifacts belong in blob storage with the job carrying a reference: URI, checksum, size. ## 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 are trimmed and lowercased: two ids that differ only by case are one key, so hash a case-sensitive external id first. - 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 recurring 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 a failing step never exhausts and an `AtMostOnce` body can run twice. ## 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 with a README. The demo apps under `demos/` are complete deployable applications built only from the published packages. Deeper docs: the `docs/` tree in the repository; start at useacta.net.