assay-lua 0.11.11

General-purpose enhanced Lua runtime. Batteries-included scripting, automation, and web services.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
## workflow

Durable workflow engine + Lua client. The engine runs in `assay serve`; any assay Lua app becomes a
worker via `require("assay.workflow")`. Workflow code runs deterministically and replays from a
persisted event log, so worker crashes don't lose work and side effects don't duplicate.

Four pieces, one binary:

```
┌──────────────────────────────────────────────────────────────────────┐
│ assay serve              the engine (REST + SSE + dashboard)         │
│ assay <subcommand>       CLI (workflow / schedule / namespace /      │
│                                worker / queue / completion)          │
│ require("assay.workflow") Lua client: handlers + management surface  │
│ REST API + OpenAPI spec  any-language workers via openapi-generator  │
└──────────────────────────────────────────────────────────────────────┘
```

The engine and clients communicate over HTTP — any language with an HTTP client can implement a
worker or management script, not just Lua.

### Engine — `assay serve`

Start the workflow server.

```sh
assay serve                                           # SQLite, port 8080, no auth (dev)
assay serve --port 8085                               # different port
assay serve --backend sqlite:///var/lib/assay/w.db    # explicit SQLite path
assay serve --backend postgres://u:p@h:5432/assay     # Postgres (multi-instance)
DATABASE_URL=postgres://... assay serve               # backend from env (keeps creds out of argv)
```

Auth modes:

| Flag                                        | Behaviour                                                                                                                                         |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--no-auth` (default)                       | Open access. Use only behind a trusted gateway.                                                                                                   |
| `--auth-api-key`                            | Clients send `Authorization: Bearer <key>`. Manage keys with `--generate-api-key` / `--list-api-keys`. Keys are SHA256-hashed at rest.            |
| `--auth-issuer <url> --auth-audience <aud>` | JWT/OIDC. Fetches and caches the issuer's JWKS to validate signatures. Works with Auth0, Okta, Dex, Keycloak, Cloudflare Access, any OIDC issuer. |
| `--auth-api-key` + `--auth-issuer …`        | Combined. Tokens that parse as a JWS header take the JWT path; everything else takes the API-key path. Same server accepts both token types on `Authorization: Bearer`. |

**Combined mode dispatch** (when both `--auth-issuer` and `--auth-api-key` are set):

- `Authorization: Bearer <jwt>` — validated against JWKS. Rejected if expired / wrong
  issuer / wrong audience / bad signature. A semantically-invalid JWT is **not** silently
  retried as an API key.
- `Authorization: Bearer <api-key>` — hashed and looked up in the store.
- `Authorization: Bearer <garbage>` — 401.

Combined mode lets the same server serve short-lived OIDC user tokens (from a browser
session) alongside long-lived machine API keys (from a CI job) without the caller picking
a mode up front.

**Multi-instance deployment.** SQLite is single-instance only (engine takes an `engine_lock` row at
startup). For Kubernetes / Docker Swarm, use Postgres: the cron scheduler picks a leader via
`pg_advisory_lock` so only one instance fires; workflow + activity task claiming uses
`FOR UPDATE SKIP LOCKED` so multiple instances don't race.

**Optional S3 archival** (cargo feature `s3-archival`, default-off). When compiled in and
`ASSAY_ARCHIVE_S3_BUCKET` is set at runtime, a background task periodically finds workflows in
terminal states older than `ASSAY_ARCHIVE_RETENTION_DAYS` (default 30), uploads `{record, events}`
to `s3://bucket/prefix/<namespace>/<workflow_id>.json`, and purges dependent rows. The workflow stub
stays with `archived_at` + `archive_uri` set so `GET /workflows/{id}` still resolves. Credentials
resolve via the AWS SDK default chain (env / shared config / IRSA).

| Env var                          | Default  | Meaning                                                       |
| -------------------------------- | -------- | ------------------------------------------------------------- |
| `ASSAY_ARCHIVE_S3_BUCKET`        | (unset)  | Enables archival when set                                     |
| `ASSAY_ARCHIVE_S3_PREFIX`        | `assay/` | S3 key prefix                                                 |
| `ASSAY_ARCHIVE_RETENTION_DAYS`   | 30       | Min age before archiving                                      |
| `ASSAY_ARCHIVE_POLL_SECS`        | 3600     | How often the archiver runs                                   |
| `ASSAY_ARCHIVE_BATCH_SIZE`       | 50       | Max workflows archived / tick                                 |
| `ASSAY_WF_DISPATCH_TIMEOUT_SECS` | 30       | Worker silent-timeout for dispatch lease (see "crash safety") |

### Dashboard whitelabel (v0.11.10+)

The embedded `/workflow` dashboard can be rebranded per-deployment via six
optional env vars. Every knob defaults to assay's built-in identity so an
unset env keeps the standalone experience unchanged. Intended for platform
teams who front assay inside their own admin UI and want the dashboard to
read as part of that product.

| Env var                         | Default                    | Meaning                                                         |
| ------------------------------- | -------------------------- | --------------------------------------------------------------- |
| `ASSAY_WHITELABEL_NAME`         | `Assay`                    | Sidebar header text                                             |
| `ASSAY_WHITELABEL_LOGO_URL`     | (unset, no image)          | Image URL rendered before the brand text                        |
| `ASSAY_WHITELABEL_PAGE_TITLE`   | `Assay Workflow Dashboard` | Browser tab title                                               |
| `ASSAY_WHITELABEL_PARENT_URL`   | (unset, link hidden)       | If set, adds a back-link in the sidebar footer to the parent app |
| `ASSAY_WHITELABEL_PARENT_NAME`  | `Back`                     | Label for the back-link                                         |
| `ASSAY_WHITELABEL_API_DOCS_URL` | `/api/v1/docs`             | Override or hide the API Docs sidebar link                      |
| `ASSAY_WHITELABEL_CSS_URL`      | (unset, no extra sheet)    | Extra stylesheet loaded after assay's own CSS                   |
| `ASSAY_WHITELABEL_SUBTITLE`     | (unset, no subtitle)       | Small muted line rendered under the brand name                  |
| `ASSAY_WHITELABEL_MARK`         | First char of `NAME` (uppercased) | Glyph in the always-visible brand badge square           |

`ASSAY_WHITELABEL_API_DOCS_URL=""` (empty string) hides the link entirely.
Any other value redirects the link to that URL. Setting the variable
explicitly to empty is distinct from leaving it unset — unset keeps the
default `/api/v1/docs` link, empty hides it.

**Theming via CSS custom properties.** Every colour, radius, and shadow
on the dashboard is a CSS variable on `:root`. An extra stylesheet
loaded after assay's own CSS can re-declare any of them without
forking. The full token list:

```
--bg          --surface      --surface-hover  --border
--text        --text-muted
--accent      --accent-hover
--green       --red          --yellow         --orange
--shadow      --code-bg
```

Minimal example — re-skin the dashboard to match your host app's
primary colour:

```css
/* served at /static/my-theme.css by your host app */
:root {
  --accent:       #009999;
  --accent-hover: #007a7a;
  --bg:           hsl(0 0% 98%);
  --surface:      hsl(0 0% 100%);
  --text:         hsl(222 84% 5%);
  --border:       hsl(214 32% 91%);
}
```

Then point assay at it:

```yaml
- name: ASSAY_WHITELABEL_CSS_URL
  value: "/static/my-theme.css"
```

Operators can also override any specific selector in the same file —
source-order specificity ensures the extra sheet wins. The URL is
loaded as a plain `<link rel="stylesheet">` at the end of `<head>`; an
asset-version query-string is appended automatically so redeploys
invalidate browser caches.

**Hosting the logo.** If assay is mounted on the same origin as the
embedding app (e.g. behind a reverse proxy at `/workflow/*`), a
path-absolute URL like `/static/my-logo.svg` loads from the host app
with no CORS plumbing. For cross-origin setups, use a full `https://…`
URL — `<img>` loads don't require CORS headers.

**Example (Kubernetes Deployment):**

```yaml
env:
  - name: ASSAY_WHITELABEL_NAME
    value: "Acme Workflows"
  - name: ASSAY_WHITELABEL_LOGO_URL
    value: "/static/acme-logo.svg"
  - name: ASSAY_WHITELABEL_PAGE_TITLE
    value: "Acme Workflows"
  - name: ASSAY_WHITELABEL_PARENT_URL
    value: "/"
  - name: ASSAY_WHITELABEL_PARENT_NAME
    value: "Acme Console"
  - name: ASSAY_WHITELABEL_API_DOCS_URL
    value: ""   # hide; docs are provided by the parent console
```

The engine serves:

| Path                        | Purpose                                     |
| --------------------------- | ------------------------------------------- |
| `GET /api/v1/health`        | Liveness probe                              |
| `GET /api/v1/version`       | `{ version, build_profile }` — CLI + UI use |
| `GET /api/v1/openapi.json`  | Full OpenAPI 3 spec (all ~30 endpoints)     |
| `GET /api/v1/docs`          | Interactive API docs (Scalar)               |
| `GET /workflow/`            | Built-in dashboard (see "Dashboard" below)  |
| `GET /api/v1/events/stream` | SSE event stream                            |

Full endpoint list in the OpenAPI spec — workflow lifecycle, state queries, events, children,
continue-as-new, signals, schedules (CRUD + patch/pause/resume), namespaces, workers, queues, worker
task polling and dispatch.

### CLI

Talk to a running engine from a shell. Lua stdlib (below) is the preferred path for automation; CLI
is for operators at a terminal and one-shot shell scripts.

**Global options** — flag / env / config file / default precedence:

| Flag               | Env var              | Config key     | Default                           |
| ------------------ | -------------------- | -------------- | --------------------------------- |
| `--engine-url URL` | `ASSAY_ENGINE_URL`   | `engine_url`   | `http://127.0.0.1:8080`           |
| `--api-key KEY`    | `ASSAY_API_KEY`      | `api_key`      | (none)                            |
| (via config only)  | `ASSAY_API_KEY_FILE` | `api_key_file` | (none; read + trim file contents) |
| `--namespace NS`   | `ASSAY_NAMESPACE`    | `namespace`    | `main`                            |
| `--output FORMAT`  | `ASSAY_OUTPUT`       | `output`       | `table` on TTY, `json` when piped |
| `--config PATH`    | `ASSAY_CONFIG_FILE`  | (n/a)          | see discovery order               |

**Config file** — YAML, auto-discovered at (first match wins):

1. `--config PATH` (explicit)
2. `$ASSAY_CONFIG_FILE`
3. `$XDG_CONFIG_HOME/assay/config.yaml`
4. `~/.config/assay/config.yaml`
5. `/etc/assay/config.yaml`

```yaml
engine_url: https://assay.example.com
api_key_file: /run/secrets/assay-api-key # preferred — keeps the secret out of env / argv
namespace: main
output: table
```

`api_key_file` wins over `api_key`. Missing file is not an error — callers fall through to flag /
env / default precedence.

**JSON input indirection.** `--input`, `--search-attrs`, and signal payloads all accept:

```
'{"key":"value"}'       # literal
@/path/to/file.json     # file contents
-                       # read stdin
```

**Subcommand surface:**

```
assay workflow
  start     --type T [--id ID] [--input JSON] [--queue Q] [--search-attrs JSON]
  list      [--status S] [--type T] [--search-attrs JSON] [--limit N]
  describe  <id>
  state     <id> [<query-name>]                 # register_query reader
  events    <id> [--follow]                     # log, or poll-stream until terminal
  children  <id>
  signal    <id> <name> [payload]
  cancel    <id>
  terminate <id> [--reason R]
  continue-as-new <id> [--input JSON]           # client-side (distinct from ctx:)
  wait      <id> [--timeout SECS] [--target STATUS]   # scripting-friendly blocking

assay schedule
  list  |  describe <name>
  create <name> --type T --cron EXPR [--timezone TZ] [--input JSON] [--queue Q]
  patch  <name> [--cron EXPR] [--timezone TZ] [--input JSON] [--queue Q] [--overlap POLICY]
  pause  <name>  |  resume <name>  |  delete <name>

assay namespace  create | list | describe | delete
assay worker     list
assay queue      stats
assay completion  <bash|zsh|fish|powershell|elvish>
```

**Exit codes:** 0 success · 1 HTTP / unreachable / not-found · 2 `workflow wait` timeout · 64 usage
error (bad JSON).

**Shell completion:**

```sh
assay completion bash > /etc/bash_completion.d/assay
assay completion zsh  > "${fpath[1]}/_assay"
assay completion fish > ~/.config/fish/completions/assay.fish
```

### Lua client — `require("assay.workflow")`

Two roles in one module: **worker** (register handlers and block polling for tasks) and
**management** (inspect / mutate the engine from anywhere, same as the CLI).

#### Worker role

- `workflow.connect(url, opts?)` → nil — `opts`: `{ token = "<api-key-or-jwt>" }`
- `workflow.define(name, handler)` → nil — register a workflow type
- `workflow.activity(name, handler)` → nil — register an activity
- `workflow.listen(opts)` → blocks — poll workflow + activity tasks on a queue
  - `opts.queue` (default `"default"`), `opts.identity`, `opts.max_concurrent_workflows` (10),
    `opts.max_concurrent_activities` (20)
  - **v0.11.10:** `opts.namespace` (default `"main"`) scopes the worker. A worker registered
    in one namespace is never dispatched tasks from a sibling namespace even if queue names collide.

#### Management role (new in v0.11.3 — parity with REST)

**Workflows:**

| Function                               | REST                                   |
| -------------------------------------- | -------------------------------------- |
| `workflow.start(opts)`                 | `POST /workflows`                      |
| `workflow.list(opts?)`                 | `GET  /workflows?...`                  |
| `workflow.describe(id)`                | `GET  /workflows/{id}`                 |
| `workflow.get_events(id)`              | `GET  /workflows/{id}/events`          |
| `workflow.get_state(id, name?)`        | `GET  /workflows/{id}/state[/{name}]`  |
| `workflow.list_children(id)`           | `GET  /workflows/{id}/children`        |
| `workflow.signal(id, name, payload)`   | `POST /workflows/{id}/signal/{name}`   |
| `workflow.cancel(id)`                  | `POST /workflows/{id}/cancel`          |
| `workflow.terminate(id, reason?)`      | `POST /workflows/{id}/terminate`       |
| `workflow.continue_as_new(id, input?)` | `POST /workflows/{id}/continue-as-new` |

`workflow.list(opts)` accepts `{ namespace?, status?, type?, search_attrs?, limit?, offset? }`.
`search_attrs` is a table; the CLI URL-encodes it as the `search_attrs=` query param.

**Sub-tables** (one per REST resource):

- `workflow.schedules.{create, list, describe, patch, pause, resume, delete}`
- `workflow.namespaces.{create, list, describe, stats, delete}`
- `workflow.workers.list(opts?)`
- `workflow.queues.stats(opts?)`

Every function returns the parsed JSON response on success, `nil` on a 404 for
`describe`/`get_state`, or raises `error()` with an HTTP status message otherwise — consistent with
the existing `workflow.start / signal / describe / cancel` behaviour.

### Workflow handler context (`ctx`)

Inside `workflow.define(name, function(ctx, input) ... end)`:

| Method                                          | Behaviour                                                                                                                                                              |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ctx:execute_activity(name, input, opts?)`      | Schedule an activity, block until complete, return result. Raises on final failure. `opts`: retry + timeout knobs (see below).                                         |
| `ctx:execute_parallel(activities)`              | **v0.11.3.** Schedule N activities concurrently, return results in input order. Raises if any fail. Handler resumes only when all have terminal events.                |
| `ctx:sleep(seconds)`                            | Durable timer. Survives worker bouncing; another worker resumes when due.                                                                                              |
| `ctx:wait_for_signal(name, opts?)` → payload    | Block until a matching signal arrives. Payload is the signal's JSON value (or nil if signaled with no payload). Multiple waits consume in order. **v0.11.9:** `opts.timeout = seconds` bounds the wait; returns `nil` if the timer fires before any matching signal. |
| `ctx:start_child_workflow(workflow_type, opts)` | Start a child, block until it completes. `opts.workflow_id` is required and **must be deterministic** (same id every replay).                                          |
| `ctx:side_effect(name, fn)`                     | Run a non-deterministic op exactly once. Value is cached in history; all replays return the cached value.                                                              |
| `ctx:register_query(name, fn)`                  | **v0.11.3.** Expose live workflow state to external callers via `GET /workflows/{id}/state[/{name}]`. Handler runs on every replay; result is persisted as a snapshot. |
| `ctx:upsert_search_attributes(patch)`           | **v0.11.3.** Merge a table into the workflow's `search_attributes` so callers can filter on it via `workflow.list({ search_attrs = ... })`.                            |
| `ctx:continue_as_new(input)`                    | **v0.11.3.** Close this run and start a fresh one with empty history (same type / namespace / queue). Standard pattern for unbounded-loop workflows.                   |
| `ctx:cancel(reason?)`                           | **v0.11.11.** Terminate this workflow with engine status `CANCELLED`. Use when the handler itself decides to stop early (human rejected, preconditions failed). Distinct from an externally-requested cancel; same terminal state. |

`opts` on `execute_activity` / `execute_parallel`:
`{ task_queue?, max_attempts?, initial_interval_secs?, backoff_coefficient?, start_to_close_secs?,
heartbeat_timeout_secs? }`.

Inside `workflow.activity(name, function(ctx, input) ... end)`:

- `ctx:heartbeat(details?)` — required for activities with `heartbeat_timeout_secs`; the engine
  reassigns the activity if heartbeats stop.

### Crash safety

Workflow code is **deterministic by replay**. Each `ctx:` call gets a per-execution sequence number
and the engine persists every completed command as an event:

```
ActivityScheduled / Completed / Failed
TimerScheduled / Fired
SignalReceived                            WorkflowStarted / Completed / Failed / Cancelled
SideEffectRecorded                        WorkflowAwaitingSignal / CancelRequested
ChildWorkflowStarted / Completed / Failed
```

When a worker picks up a workflow task it receives the full event history. `ctx:` calls
short-circuit to cached values for everything already in history, so the workflow always reaches the
same state and only the next unfulfilled step actually runs.

| Failure mode                       | Recovery                                                                                                                             |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Activity worker dies mid-execution | `last_heartbeat` ages out (per-activity `heartbeat_timeout_secs`); engine re-queues per retry policy.                                |
| Workflow worker dies mid-replay    | `dispatch_last_heartbeat` ages out (`ASSAY_WF_DISPATCH_TIMEOUT_SECS`, default 30s); any worker on the queue picks it up and replays. |
| Engine dies                        | All state in the DB. On restart, in-flight tasks become claimable again as heartbeats age out.                                       |

`ctx:side_effect` is the escape hatch for any operation that would produce different values across
replays (current time, random IDs, external HTTP). The result is recorded once on first execution
and returned from cache thereafter, even after a worker crash.

### Schedules (cron)

Declarative recurring workflow starts. Scheduler runs on the leader node under Postgres; fires once
across the cluster.

```sh
assay schedule create nightly \
  --type Report \
  --cron "0 0 2 * * *" \
  --timezone Europe/Berlin \
  --input '{"lookback_hours":24}'

assay schedule patch   nightly --cron "0 0 3 * * *"   # in-place update (v0.11.3)
assay schedule pause   nightly                        # scheduler skips paused (v0.11.3)
assay schedule resume  nightly                        # recomputes next_run_at from now
assay schedule delete  nightly
```

Cron uses the 6- or 7-field form (with seconds). `"0 * * * * *"` = every minute on the zero second.

**Timezone (v0.11.3).** IANA name via `--timezone`. Default is `UTC`. The scheduler evaluates the
cron in that zone; `next_run_at` is persisted as a UTC epoch.

### Search attributes

Indexed application-level metadata for filtering workflows. Set at `start`, updated at runtime via
`ctx:upsert_search_attributes`, filtered on `list`:

```lua
-- set at start
workflow.start({
  workflow_type = "Ingest",
  workflow_id   = "ing-42",
  search_attributes = { env = "prod", tenant = "acme" },
})

-- update inside a running workflow
ctx:upsert_search_attributes({ progress = 0.5, stage = "deploy" })

-- filter list results (URL-encoded JSON server-side)
workflow.list({ search_attrs = { env = "prod" } })
```

Postgres backs search with a `JSONB` column + `->>` operator; SQLite uses `json_extract`. Filters
AND-join; unchanged keys are preserved across upserts.

### Dashboard

`/workflow/` (or just `/` — redirects). Real-time monitoring + tier-1 operator controls.

| View      | Read                                                | Mutate                                                                            |
| --------- | --------------------------------------------------- | --------------------------------------------------------------------------------- |
| Workflows | List + filter (status, type, search_attrs)          | `+ Start workflow` form; per-row Signal / Cancel / Terminate                      |
| Detail    | Metadata, event timeline, children, live state      | Signal / Cancel / Terminate / Continue-as-new; live `ctx:register_query` snapshot |
| Schedules | List with timezone + paused state                   | Create (with timezone) / Edit (PATCH) / Pause / Resume / Delete                   |
| Workers   | Identity, queue, last heartbeat                     ||
| Queues    | Pending + running per queue                         ||
| Settings  | Engine version, build profile, namespaces, API docs | Namespace create / delete                                                         |

Status-bar footer always shows the engine version (fetched from `/api/v1/version`). Live list
updates via SSE. Cache-busted asset URLs per startup.

### Concepts

| Concept           | Meaning                                                                                                                               |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Activity          | A unit of concrete work with at-least-once semantics. Result is persisted before the workflow proceeds. Configurable retry + timeout. |
| Workflow          | Deterministic orchestration of activities, sleeps, signals, child workflows. Full event history persisted; crashed worker → replay.   |
| Task queue        | Named queue workers subscribe to. Workflows are routed to a queue; only workers on that queue claim them.                             |
| Namespace         | Logical tenant. Workflows / schedules / workers are namespace-scoped. Default `main`.                                                 |
| Signal            | Async message to a running workflow; consumed via `ctx:wait_for_signal`.                                                              |
| Schedule          | Cron expression that starts a workflow recurringly. Leader-elected under Postgres so only one instance fires.                         |
| Child workflow    | Workflow started by another workflow. Cancellation propagates parent → child recursively.                                             |
| Side effect       | Non-deterministic op captured in history on first call so replays see the same value.                                                 |
| Query handler     | `ctx:register_query` surface exposing live workflow state via `/state[/{name}]`. Snapshot written on every replay.                    |
| Search attributes | Indexed metadata (JSON object) for filtering workflows; updatable at runtime.                                                         |
| Archival stub     | Terminal workflow moved to S3 by the optional archiver; row stays in Postgres with `archive_uri` pointing at the bundle.              |

### Example — approval-gated deploy with live state

```lua
local workflow = require("assay.workflow")
workflow.connect("http://assay.example.com", { token = env.get("ASSAY_TOKEN") })

workflow.define("ApproveAndDeploy", function(ctx, input)
  local state = { stage = "build", progress = 0 }
  ctx:register_query("pipeline_state", function() return state end)

  local artifact = ctx:execute_activity("build", { ref = input.git_sha })
  state.stage = "awaiting_approval"; state.progress = 0.33

  local approval = ctx:wait_for_signal("approve")
  state.stage = "deploying"; state.progress = 0.66
  ctx:upsert_search_attributes({ approver = approval.by })

  local result = ctx:execute_activity("deploy", {
    image = artifact.image, env = input.target_env, approver = approval.by,
  })
  state.stage = "done"; state.progress = 1.0
  return result
end)

workflow.activity("build",  function(ctx, input) --[[ ... ]] end)
workflow.activity("deploy", function(ctx, input) --[[ ... ]] end)

workflow.listen({ queue = "deploys" })  -- blocks
```

Drive it from the shell:

```sh
assay workflow start --type ApproveAndDeploy --id deploy-1234 \
  --input '{"git_sha":"abc123","target_env":"staging"}'

assay workflow state deploy-1234 pipeline_state   # "awaiting_approval"

assay workflow signal deploy-1234 approve '{"by":"alice"}'

assay workflow wait deploy-1234 --timeout 300   # exit 0 on COMPLETED, 1 on failure, 2 on timeout
```

### Notes

- The whole engine + dashboard + Lua client is gated behind the `workflow` cargo feature (default
  on). To build assay without it:
  `cargo install assay-lua --no-default-features --features cli,db,server`.
- The cron crate requires **6- or 7-field** expressions. The common 5-field form fails to parse.
- The engine is also publishable as a standalone Rust crate (`assay-workflow`) for embedding in
  non-Lua Rust apps. The CLI injects its own `CARGO_PKG_VERSION` via
  `assay_workflow::api::serve_with_version` so `/api/v1/version` reflects the user-facing binary
  version, not the internal crate version.
- S3 archival is behind the `s3-archival` cargo feature (default off) and no-op at runtime unless
  `ASSAY_ARCHIVE_S3_BUCKET` is set.