Changelog
All notable changes to Assay. Each release is available on GitHub Releases as pre-built binaries, Docker image, and crates.io package.
sysops 0.2.0 — 2026-05-20
Added
- Auth gateway — sysops can now front the engine as a BFF that terminates OIDC, holds the admin
bearer server-side, and injects it into proxied requests. Opt in via four new
mountopts:oidc(issuer, client_id, redirect_uri, scopes),session(signing_key, ttl_seconds, cookie_name),gateway(engine_upstream, admin_bearer), andauthz(require_zanzibar_admin, bootstrap_first_admin). Consumers that don't passoidcare bit-for-bit unchanged. - New page handlers:
/auth/login,/auth/callback,/auth/logout(OIDC Authorization Code + PKCE; HttpOnly session cookie; ephemeral in-process store for refresh tokens). gateway.whoamiinterceptsGET /api/v1/engine/auth/whoamiand answers from the session cookie — defuses the assay-dashboard auth/engine SPA token banners without modifying SPA code.gateway.proxyis a dual-mode reverse proxy on/api/v1/engine/*+ dashboard SPA asset paths. Caller'sAuthorization: Beareris passed through unchanged (preserves SSH+curl, CI scripts, customer-IdP JWT direct calls); session-only callers get the configured admin bearer + X-User-Id injected. Hop-by-hop headers (Connection, Transfer-Encoding, Cookie, …) stripped both directions.- First-user-wins admin bootstrap: the first OIDC login on a fresh deployment auto-grants the
engine:core#adminZanzibar tuple if no admins exist. Opt out viaauthz.bootstrap_first_admin = false. require_sessionmiddleware for gating sysops's own/auth/*and/vault/*pages on the OIDC session cookie. No-op pass-through when the auth gateway isn't wired.libs/sysops/codec.lua— sharedb64url/hex_to_bytes/must/consteqhelpers.libs/sysops/session.lua— HMAC-SHA256-signed cookies (compact JWT-like format, sincecrypto.jwt_signis RSA-only) + in-memory session store with one-shot pending-state GC.
Changed
ctx.luaextended withoidc_client,session_signer,session_store,gateway_admin_bearer,authz_require_admin,authz_bootstrap_first_admin,zanzibar_check. All nil-defaulted; backward-compatible.
assay 0.16.5 — 2026-05-21
Fixed
- Empty-array defaults in SDK payloads now serialise as
[](were{}, which upstreams reject as type mismatch). Wrapped injson.array():ory.hydraconsent:acceptgrant_access_token_audience,tailscalemint_keytags,engine.authpasskey:start_authpasskeys.
assay 0.16.4 — 2026-05-19
Fixed
assay.ory.ketofilter keys forsubject_setnow use dotted notation (subject_set.namespace, …) to match Keto's API. The previous underscored keys (subject_set_namespace, …) were silently ignored on GET and rejected with HTTP 400 on DELETE, breakingtuples:upsertfor any subject_set tuples (e.g. parent edges in HRBAC seeds).
assay 0.16.3 — 2026-05-19
Added
c.tuples:upsert(tuple)— idempotent ensure-exactly-one. Returns"noop"/"created"/"repaired", N. Use for seed scripts;tuples:createis non-idempotent.
Changed
c.tuples:createdocstring flags non-idempotency, points atupsert.
sysops 0.1.6 — 2026-05-18
Added
- Host Services now shows a compact per-
.servicestats table with sortable memory and CPU-usage columns. Clicking a service expands a systemd detail panel with unit file state/path, main PID, exec command, restart policy, and related accounting fields. - Host Services adds
.service-only start, stop, and restart actions. POST handlers validate the unit name, call the allow-listed systemd lifecycle path, and redirect back to the current filtered services view with a success or error banner. libs/sysops:smokenow runs a focused service-unit helper contract test before the broader page smoke and vault tests.
assay 0.16.2 — 2026-05-12
Fixed
:<version>-shimage: ship the full busybox applet set, not just/bin/sh; moveassayto/usr/local/bin/assayso it resolves on$PATH.
assay 0.16.1 — 2026-05-11
Fixed
rauthy.client_presets.immich: registerchallenges = ["S256"]. Current Immich web sends PKCE on every authorize; Rauthy was rejecting it as a spec mismatch.
Container image
- New
ghcr.io/developerinlondon/assay:<version>-shtag — same scratch image as the plain:<version>tag, plus a 1 MB static busybox at/bin/sh. Use in environments that wrap the launch insh -c(notably GitLab CI's docker executor). K8s consumers that already usecommand: ["/assay", ...]should keep the plain:<version>tag.
assay 0.16.0 — 2026-05-11
🏗️ Container Registry (lite crane replacement)
oci.copy(src, dst, opts?)— copy images between registriesoci.tag(src, tag)— tag an existing imageoci.mutate(src, dst, files)— add files as new OCI layers- All use
oci-distributionunder the hood, no external binary needed
📦 Tar archive support
tar.create(output, files, opts?)— create tar/tar.gz from{path = content}tar.extract(archive, dest)— extract to directorytar.list(archive)— list contents
☁️ AWS ECR support
assay.aws.ecr—client():get_authorization_token()returns ECR auth tokensassay.aws.sigv4— reusable AWS Signature V4 signing for any service
Internal
- Added
oci-distributioncrate dependency - Binary size remains ~8.7MB (shared deps with existing reqwest/tokio)
[assay-vault 0.3.0] — 2026-05-11
- Pin
assay-auth = "0.4"(was"0.3"); republish to unblockassay-enginepublish. assay-engineassay-vaultdep"0.2"→"0.3"to match.- Same pattern as
assay-vault 0.1.0 → 0.2.0in #99.
assay 0.15.11 — 2026-05-11
Added
assay.rauthy— Rauthy IdP admin client.c.sys:health(),c.discovery:{config,jwks}, fullc.clientslifecycle (list/get/create/put/delete/rebuild/rotate_secret) plus idempotentc.clients:reconcile(payload).reconcilerotates secrets only on real drift: 404 → create+rotate;challengesmissing → rebuild+rotate (workaround for a Rauthy 0.35 cache quirk); other drift → put-only; no drift → noop.rauthy.client_presets.openbao({host})/.argocd({host})— ready-made client payloads with each consumer's OIDC verifier quirks baked in.
Known limits
- Provider config (e.g. Google federation) not exposed — Rauthy 0.35's
/providers/*is admin-session-only, no API-key path. Upstream feature request to follow.
sysops 0.1.5 — 2026-05-08
Added
- Opt-in
active_modulesmount option. Passactive_modules = { "auth", "vault" }tosysops.mount(routes, opts)to enable the new in-sysops auth + vault pages. Without the opt-in, sysops 0.1.5 is byte-identical in behaviour to 0.1.4 — same sidebar, same routes. sysops.vaultSDK aggregator.local v = require("sysops.vault").new(engine)returns a table with sub-clients for KV, transit, sealing, dynamic credentials, share tokens, collections, and the bitwarden-compatible personal vault — pure-Lua HTTP wrappers around/api/v1/vault/*. Existingvault.secret_store(opts)keeps working for legacy 0.1.4 callers.sysops.authSDK aggregator.local a = require("sysops.auth").new(engine)returns sub-clients for session (login / logout / whoami / passkey), users, sessions, OIDC clients, upstreams, JWKS, biscuit, audit, and zanzibar (check / expand / tuples).- Auth + Zanzibar dashboard pages under
/auth/users,/auth/sessions,/auth/oidc-clients,/auth/upstreams,/auth/jwks,/auth/biscuit,/auth/audit,/zanzibar,/zanzibar/tuples,/zanzibar/check— Lua-rendered, mirror the knowhere-pkg layout (page-header eyebrow + in-page tab strip + cards). Gated onactive_modulescontaining"auth". - Vault dashboard pages under
/vault(overview),/vault/kv,/vault/transit,/vault/sealing,/vault/dynamic,/vault/share,/vault/collections,/vault/me. Gated onactive_modulescontaining"vault". - New optional mount opt:
engine_admin_key(bearer token used by the SDK for admin-scoped engine calls).
Changed
templates/layout.htmladds two conditional<nav>blocks for Auth and Vault. Existing Host / Networks / Engine / Admin sidebar entries are unchanged in text, position, and CSS.
References
- Plan:
.claude/plans/25-v0.1.5-sysops-auth-vault-pages.md - Revisits plan 22's "Engine link only, no Lua port" decision — opt-in only, same default behaviour.
sysops 0.1.4 — 2026-05-07
Added
sysops.vaultshared Assay Engine Vault integration for host-manager apps. Thevault.secret_store(opts)factory returns theread/write/delete/availableservice shape that sysops backup flows consume, stores host/app operational secrets in engine KV v2, and preserves read-only fallback from existing rustic/local secret files.
Changed
libs/sysops:smokenow runs the vault adapter contract test alongside the host dashboard smoke test.
0.15.10 — 2026-05-05
- Fix:
json.encode({})now returns"{}"(was"[]"). Same fix applies to every empty Lua table passed as a JSON body via the http builtin. Closes #129. - Add:
json.array(t?)/json.object(t?)to pin a table's encoded shape. - Migration: callers that need
"[]"for an empty table must usejson.array(t).
hostops 0.1.3 — 2026-05-04
Added
hostops.mountextra_sidebar_linksaccepts a grouped entry shape in addition to the flat one. A grouped entry ({label, children = { {href, label, nav_active}, ... }}) renders as a<details data-section><summary>label</summary>children</details>block in the sidebar. The existingapp.jsdisclosure script persists open state in localStorage; children sit one level indented and pick up the base.nav atypography.- Mutex sidebar highlight: clicking a group summary moves the active treatment (background + accent
left bar) onto the summary while clearing
.activefrom any other sidebar link, so only one entry highlights at a time. Reload re-syncs to whatever the URL is. - Smoke-test fixture in
tests-lua/smoke.test.luacovers both shapes — flat link plus grouped entry render assertions.
Changed
templates/audit.htmlpage-eyebrow now reads<a href="/audit">Admin</a> · audit logso the parent segment is a real link, matching howNetworksis linked from/tunnelsand the rest of hostops's eyebrow chrome.
0.15.9 — 2026-05-04
Changed
- Bundles
hostops 0.1.3(grouped sidebar entries + audit breadcrumb fix). No assay-lua source changes vs0.15.8— this release packages the merged hostops library updates asassay-lib-hostops-0.1.3.tar.gzalongside the runtime binary.
hostops 0.1.2 — 2026-05-03
Added
hostops.mountacceptsextra_sidebar_links = { {href, label, nav_active}, ... }. Each entry renders as a flat sidebar link below the lib's own nav. Plain pass-through — no plugin loader, no dispatch shim, noplugin.toml. Consumer apps register routes for thehrefthemselves and passnav_activeto highlight the active link.
Changed
templates/audit.htmlno longer renders a tab strip pointing at/inventory,/packages,/settings— those routes never existed. Eyebrow link to/inventoryswapped to plain text.
Removed
pages/stubs.luaandtemplates/stub.html. They rendered "coming soon" placeholders for features that aren't built. UI affordances now appear only when the underlying feature exists.
0.15.7 — 2026-05-03
Added
assay.rusticstdlib (require("assay.rustic")): rustic backup CLI wrapper —snapshots,snapshot_detail,init,check,backup,restore,forget. Repository URL + credentials travel as environment variables (RUSTIC_REPOSITORY,RUSTIC_PASSWORD,AWS_*) so secrets stay off/proc/<pid>/cmdline. The binary stays external — norustic_corecrate is linked. Seedocs/modules/rustic.md.assay.fs_snapshotstdlib (require("assay.fs_snapshot")): btrfs / zfs subvolume snapshot wrapper for crash-consistent backup capture.detect,take,release,with_snapshot. Auto-selects the backend (btrfs/zfs/none) by inspectingfindmntoutput. Seedocs/modules/fs_snapshot.md.libs/hostopslibrary — host-visibility dashboard for nspawn containers, systemd services, cron timers, journal logs, networks, audit, host shell, and backups. Mounts on a consumer app'sroutestable viarequire("hostops.mount"); ships as a tarball published alongside the assay binary (assay-lib-hostops-<version>.tar.gz).assay installsubcommand: reads aManifest.lua, fetches declared extension binaries + libs over HTTPS, verifies sha256, installs into the configured bin/lib paths, and writes aManifest.lockfor reproducibility. Plan 21.
0.15.5 — UNRELEASED
Added
aptbuiltin:apt.query,apt.list_installed,apt.list_upgradable,apt.add_source,apt.update,apt.install,apt.remove. Wrapsapt-getanddpkg-queryfor use by the package manager framework.http.download(url, path, opts): streams a URL to disk via temp-file + atomic rename, with optional headers and timeout.crypto.hash_file(path, algo): file-streamed hashing (sha2/sha3 family), avoids loading multi-MB binaries into Lua strings.compress.untar(archive_path, dest_path, opts): extracts a single named member from a tar archive (auto-detects gz/xz/zst from extension).systemd.machine_exec(name, cmd, opts): runs a command inside an nspawn machine viasystemd-run --machine=<name> --pipe --quiet --wait --collect. Returns the same{status, stdout, stderr, timed_out}shape asshell.exec.assay.pkgLua stdlib (require("assay.pkg")): catalog/template loaders with three-layer overlay (built-in / plugin / operator), strict-override on invalid entries, version comparator (semver/v-prefix/calver), host/machine target abstractions, deterministic plan generator.
[assay-engine 0.4.1] - 2026-04-29
- Re-publish so
assay_engine::embeddedis reachable from crates.io. PR #104 addedpub mod embedded(theembedded::build()API used to compose assay-engine into a parent binary's tokio runtime + axum router), but it landed afterassay-engine 0.4.0was tagged on commiteae2296. The published0.4.0therefore shipslib.rswithconfig / engine_api / init / server / stateand noembedded, so any consumer wanting a registry version pin against the embedded API was forced onto agit/revpin againstmain. Patch bump cuts a release that actually contains the module. No source changes vs. main HEAD.
[assay-domain 0.2.1] - 2026-04-28
- Add
EngineEventBus::prune_with(PruneOpts)for namespace-scoped pruning. The existingprune(before_ts)issuesDELETE WHERE ts < ?with no namespace filter, which deletes events from every namespace in the shared table. That's correct for the global cluster-wide cleanup loop inassay-workflow::events_cleanup, but it made tests (and tenant-scoped callers) racy: one bus instance'sprunewould silently delete another instance's rows. The non-#[serial]testappend_then_read_round_tripstarted losing its row whenprune_removes_older_than_cutoffran concurrently —serial_test::serialonly synchronises tagged tests, so a non-tagged test in the same suite races freely. Newprune_withtakes aPruneOptsstruct (#[non_exhaustive], so future filter fields likesubsystem,kind, ordry_runadd non-breakingly):namespace = Some(ns)scopes the delete;namespace = Nonematches the global semantic. The trait method has a default impl that forwards toprunefor theNonecase and errors otherwise — non-breaking for external implementors ofEngineEventBus.
[assay 0.15.4] - 2026-04-28
- Rename
assay.hashicorp_vault→assay.hashicorp.vault(closes #92). Establishes a properhashicorpnamespace mirroringassay.ory.*, leaving room for future submodules (consul, nomad, boundary, terraform, packer, waypoint). Newassay.hashicorpumbrella module re-exportsvault. Theassay.openbaoalias now loads through the renamed path. Breaking (no back-compat shim): scripts requiringassay.hashicorp_vaultmust update toassay.hashicorp.vault. - Fix
M.ensure_credentialsandM.assert_secretmount handling. Both helpers previously hardcoded the KV mount as"secrets", making them unusable against any other mount. Signatures now take an explicitmountarg:ensure_credentials(client, mount, path, check_key, generator)andassert_secret(client, mount, path, expected_keys). Breaking signature change for any existing callers (in practice none, since the hardcoded-mount limitation made the helpers unusable for non-secretsmounts).
[assay 0.15.2] - 2026-04-27
crypto.jwt_verify(token, key, opts?)— verify-side mirror ofjwt_sign. PEM (RS256/384/512) or JWKS table (dispatched bykid). Validatesaud/iss/exp/nbfwith optionalleeway. Lets pure-Lua services accept JWTs without anassay-engine.
[assay-engine 0.4.0 / assay-auth 0.3.0 / assay-vault 0.2.0] - 2026-04-27
| Crate | Bump |
|---|---|
assay-engine | 0.3.1 → 0.4.0 |
assay-auth | 0.2.2 → 0.3.0 |
assay-vault | 0.1.0 → 0.2.0 |
| (others) | unchanged |
Breaking change (pre-1.0 minor bump). Adding the external_issuers field to AuthCtx and
AuthConfig changes their shapes; per pre-1.0 semver convention the minor version is the
breaking-change bump. assay-vault 0.2.0 rides along — its dep declaration on assay-auth had to
update from "0.2" to "0.3" (the published assay-vault 0.1.0 couldn't be reused because its
baked-in manifest pinned the old assay-auth, and there's no way to mutate a published crate). While
doing it, every public config struct in all three crates is now marked #[non_exhaustive] so future
field additions are non-breaking — AuthCtx, AuthConfig, EngineConfig, BackendConfig,
ServerConfig, WorkflowConfig, AuthSessionConfig, AuthPasskeyConfig,
AuthOidcProviderConfig, DashboardConfig, LoggingConfig, ExternalIssuerConfig, plus all 51
public structs/enums in assay-vault are now #[non_exhaustive] and all require
Default::default() + field assignment for external construction. Pattern matches on
BackendConfig and on assay-vault's enums (SealingMethod, VaultError, ShareTarget,
ActiveKek, Parent) from outside the crate must include a wildcard arm.
Headline: JWT pass-through validation. The engine now accepts JWTs minted by an upstream
OIDC provider (Hydra, Keycloak, Auth0, …) on incoming Authorization: Bearer ... requests without
managing engine-side users. Each issuer's JWKS is discovered once, cached in memory, and refreshed
in the background — handles upstream key rotation transparently. Restores the v0.12.1
--auth-issuer / --auth-audience behavior in TOML config form, with multi-issuer support added.
This is the integration shape every operator who already runs an IdP wanted: keep your existing
identity stack, point assay-engine at it, accept JWTs forwarded by a trusted edge — no engine user
table, no engine sessions, no schema migrations on the auth side, no double-auth in front of the
engine. See site/pages/auth-pass-through.html for the architecture write-up.
Added
[[auth.external_issuers]]config block — list of trusted upstream OIDC issuers, each withissuer_url,audience, andjwks_refresh_secs. The engine discovers each issuer at boot via<issuer_url>/.well-known/openid-configuration, caches the JWKS, and verifies incoming JWTs against the matching key set. Tokens are routed byissclaim — no unnecessary cryptography on tokens for issuers we don't trust.assay_auth::external_jwt::ExternalJwtIssuer— public verifier type with full doc comments; usable directly by embedders who compose their own auth gate.- Boot-time exemption — when
external_issuersis non-empty the engine no longer requires operator users /admin_api_keysto be configured. The upstream IdP is the source of truth. - 9 unit tests covering happy path, wrong issuer, wrong audience, unknown kid, expired token,
audience opt-out, multi-issuer routing by
iss, unknown-issuer fall-through, and empty-list short-circuit.
Changed
- The "no operator users" boot-error message now mentions
[[auth.external_issuers]]as a third valid satisfying condition. Operators who configure pass-through don't need to also setadmin_api_keys.
Why this matters
Every comparable auth stack — Ory Hydra, Keycloak, Auth0 SDKs — assumes you bring your own edge that validates JWTs and forwards. Few of them ship a single binary that does the validation natively, with JWKS caching + refresh, multi-issuer routing, and a sensible default config — without making you stand up the IdP itself. That's what this release adds. Combined with the engine's existing OIDC provider mode (assay-engine ALSO ships as an IdP), operators can mix and match: act as the IdP for some traffic, accept upstream JWTs for the rest, all behind one binary.
[assay-engine 0.3.1] - 2026-04-27
| Crate | Bump |
|---|---|
assay-engine | 0.3.0 → 0.3.1 |
| (others) | unchanged |
Headline: engine.toml now expands ${VAR} and ${VAR:-default} env-var references at load
time, so credentials and per-environment URLs can stay out of config files. Operators wiring the
engine into Kubernetes Secret env vars, systemd EnvironmentFile=, or Compose environment: blocks
no longer need an external rendering step.
Added
engine.tomlenv-var substitution —${VAR}(required, errors if unset) and${VAR:-default}(optional, falls back to the inline default) work in any string field of the config:[backend].url,[server].public_url,[auth].admin_api_keys,[auth].issuer, etc. Bracket-less$VARis left untouched so passwords / paths containing literal$are safe. Sequences whose contents aren't a valid identifier (e.g.${1NOT_VALID},${has space}) pass through verbatim. README quick-start andexamples/postgres.tomlshow the typical Kubernetes Secret-env pattern.
Changed
crates/assay-engine/examples/postgres.tomlandsqlite.tomlupdated to demonstrate the new${DATABASE_URL}/${DATA_DIR:-./data}patterns.- README quick-start now shows env-var-driven configuration.
Internal
- 14 unit tests added covering set-var, unset-var-with-default, unset-var-no-default error, multiple
substitutions per line, bracket-less
$VARpass-through, invalid identifier pass-through, unclosed${, plus a from-file integration test.
[assay 0.15.1] - 2026-04-27
| Crate | Bump |
|---|---|
assay | 0.15.0 → 0.15.1 |
Headline: native Linux observability and systemd control for assay scripts, plus native
browser-shell capability. Three new Rust builtins (linux, cgroup, systemd), two new Lua stdlib
modules (assay.cron, assay.system), one new PTY primitive (process.spawn_pty), one new
http.serve response shape ({ws = function(conn) ... end} for server-side WebSocket upgrades),
and an assay.shell umbrella that bridges the two. Operator dashboards, health-check scripts,
host-introspection automation, and "Open Shell" buttons over xterm.js no longer fork a subprocess
per refresh cycle or sit behind a separate websocket sidecar.
All additions are purely additive — no breaking changes, no migration shim needed. Closes #88.
Added — linux Rust builtin (/proc + /sys/... readers)
Linux-only. Backed by the procfs crate (0.17). Empty table on non-Linux.
linux.kernel() -- {version, hostname, os_release, btime}
linux.uptime() -- {uptime_secs, idle_secs}
linux.loadavg() -- {one, five, fifteen, running, total, last_pid}
linux.cpu_stat() -- /proc/stat aggregate row, jiffies
linux.cpu_stat_per_core() -- per-CPU rows
linux.cpu_percent(prev, curr)
-- Lua-side delta math, no kernel call
linux.meminfo() -- /proc/meminfo as bytes (procfs reports kB)
linux.netdev() -- /proc/net/dev
linux.diskstats() -- /proc/diskstats
linux.proc_stat(pid) -- /proc/<pid>/stat
linux.proc_status(pid) -- /proc/<pid>/status
Added — cgroup Rust builtin (cgroup v2 unified hierarchy)
Linux-only. Pure std::fs + small parsers; no new crate dep. Path canonicalisation +
/sys/fs/cgroup/ prefix check before every read.
cgroup.version() -- "v2" | "v1" | "hybrid"
cgroup.list(slice_path) -- child cgroup directories
cgroup.cpu_stat(path) -- cpu.stat parsed
cgroup.memory(path) -- memory.{current,max,swap.*,peak,low,high}
-- + memory.events (oom, oom_kill).
-- "max" sentinel maps to Lua nil.
cgroup.io(path) -- io.stat per device
cgroup.pids(path) -- pids.{current, max}
cgroup.procs(path) -- cgroup.procs (pid list)
Added — systemd Rust builtin (D-Bus + journal)
Linux-only. zbus 5 async client; one persistent system-bus connection per Lua VM. Stub table on
non-Linux returns "Linux only" runtime errors.
-- Units (org.freedesktop.systemd1)
systemd.list_units(filter?), unit_status(name), is_active(name)
systemd.list_timers()
systemd.start, stop, restart, reload -- return job object path
-- Machines (org.freedesktop.machine1)
systemd.list_machines(), machine_status(name)
systemd.machine_start, machine_poweroff, machine_reboot, machine_terminate
-- Journal
systemd.journal({unit?, machine?, since?, until?, lines?, priority?})
-- one-shot read via `journalctl --output=json`
systemd.journal_follow(opts, fn) -> handle
-- streaming follow via sd_journal_wait
-- (libsystemd.so.0 dlopened at runtime via
-- libloading; no libsystemd-dev needed).
-- handle:close() stops the stream; worst-case
-- shutdown latency 500 ms.
*UsecRealtime D-Bus values are exposed as integer microseconds since the epoch under *_realtime
keys.
Added — assay.cron Lua stdlib (scheduled-job inspector)
Pure Lua — file walks of /etc/crontab, /etc/cron.d/*,
/etc/cron.{hourly,daily,weekly,monthly}/*, /var/spool/cron/crontabs/*, plus a passthrough to
systemd.list_timers(). 5/6-field crontab parsing with @reboot / @daily / @hourly / @yearly
shorthand.
local cron = require("assay.cron")
cron.system_crontab() -- /etc/crontab + /etc/cron.d/*
cron.user_crontabs() -- per-user crontabs
cron.daily_dropins() -- /etc/cron.{hourly,daily,weekly,monthly}/
cron.timers() -- thin wrapper around systemd.list_timers()
cron.all() -- unified schedule list across every source
Added — assay.system Lua umbrella stdlib
Single require("assay.system") re-export of linux, cgroup, systemd, and assay.cron, plus
convenience aggregates that span sub-modules:
local sys = require("assay.system")
sys.linux.cpu_stat()
sys.cgroup.memory(path)
sys.systemd.list_machines()
sys.cron.all()
sys.host_snapshot() -- {cpu, mem, load, uptime, netdev, kernel}
sys.machine_snapshot(name) -- {info, cgroup={cpu,memory,io,pids}, journal_tail}
sys.machines() -- list_machines() with cgroup utilisation joined
Tests
15 new unit tests on Linux (5 in linux::tests, 10 in cgroup::tests) + 3 #[ignore]-gated
journal_follow live-fire tests. Plus 5 D-Bus / journal tests in systemd::tests gated #[ignore]
(require a running system bus); pass on a typical Linux box with --include-ignored.
Out of scope (reserved for v0.15.x follow-ups)
- macOS / Windows ports of these modules —
/procand the systemd D-Bus surface have no analogues, so the modules stay Linux-only by design.
[assay 0.15.0 / assay-vault 0.1.0 / assay-engine 0.3.0 / assay-dashboard 0.3.0] - 2026-04-26
| Crate | Bump |
|---|---|
assay | 0.14.2 → 0.15.0 |
assay-vault | NEW → 0.1.0 |
assay-engine | 0.2.2 → 0.3.0 |
assay-dashboard | 0.2.1 → 0.3.0 |
assay-auth | unchanged |
assay-workflow | unchanged |
Headline: assay-engine adds the vault module — KV v2, transit, dynamic credentials,
Bitwarden-aligned vaults + collections + items, biscuit-attenuated share links, sealing (Shamir +
Cloud KMS shape), audit forwarding, and the foundation for a Bitwarden-protocol compatibility shim.
One static binary now covers Vault (HashiCorp / OpenBao), 1Password / Bitwarden self-host, Ory
Kratos / Hydra / Keto, and Temporal — at +1.7 MB on the existing assay-engine binary.
See docs/migration-to-0.3.0.md for the full migration guide.
Added — assay-vault (new crate)
- KV v2 — versioned, server-decryptable secrets storage. AES-256-GCM-SIV per-record DEK wrapped by the master KEK; full lifecycle (PUT/GET/LIST/soft-delete/hard-destroy/undelete with version history); path-bound AAD rejects cross-row ciphertext substitution.
- Transit — encrypt/decrypt without exposing key material.
vault:vN:b64envelope wire format (Vault-style); rotation appends a new version, old ciphertexts stay decryptable; AAD binds key name + version so cross-key-name decrypt fails. - Personal vaults + shared collections + items + folders. E2E: collection key encrypted client-side via X25519 ECDH to each member's pubkey; server stores ciphertext + envelope blobs only.
- Biscuit-attenuated share links — mint, redeem (public), revoke. Per-block revocation IDs, time-bound caveats, content-addressed kid validation catches Shamir's silent-reconstruction attack on unseal too.
- Sealing — Shamir SSS init unseal, runtime SealState (sealed → every KV / transit / collection-key
op fails closed with 503). POST
/sys/initreturns shares once for operator distribution; POST/sys/unsealaccumulates threshold shares. - Audit forwarding — webhook sink + SinkRegistry that fans events out to every matching glob filter. Syslog + S3 sinks reserved in trait shape (land in v0.3.x).
- Dynamic credentials —
DynamicCredsProvidertrait + Postgres provider. Operators register a role + grants;issuerunsCREATE ROLE … LOGIN PASSWORD …,revokedrops the role. Lease tracking invault.leaseswith a sweepable expiry. AWS / GCP / Kubernetes providers reserved in trait shape.
Added — HTTP
All routes mounted under /api/v1/vault/*, admin-key gated except GET /share/{token} (public —
biscuit + revocation are the access controls). See docs/migration-to-0.3.0.md for the full route
table.
Added — Lua stdlib
assay.vault— full KV / transit client built on the engine's HTTP surface.- The pre-existing HashiCorp Vault / OpenBao client moved to
assay.hashicorp_vault. Theassay.openbaoalias still loads through the renamed module.
Added — assay-engine
- New
vaultCargo feature (default-on); composesVaultCtxintoEngineStateviaaxum::extract::FromRef.engine.modules.vault.enabledcontrols runtime activation. - New schema namespace
vault.*(PG) / attachedvault.db(SQLite). Migration runs automatically on boot.
Changed — HA failover (plan §S9)
engine.instances heartbeat tightened: 15s → 3s; stale cutoff: 60s → 10s. Worst-case failover
detection is now ~10s vs ~60s. No config changes required; takes effect on next boot.
Migration
See docs/migration-to-0.3.0.md.
Out of scope (reserved for v0.3.x follow-ups)
- Bitwarden-protocol compat shim — full BW client coverage. Phase 7 ships the BW HTTP shape
(identity, profile, sync, ciphers, folders, discovery probes); end-to-end mobile/browser/CLI
client coverage rides on a
bwCLI in CI per plan §"Test plan". - Cross-method KEK rotation (rotate plaintext → shamir or shamir → KMS in one op). Phase 2 ships in-method rewrap; cross-method needs a re-wrap-then-swap flow.
- Recovery delegate (offline admin envelope wrap for collection sharing) — plan §"Deferred" reserves this as a v0.4.x item.
- AWS IMDS-based credential fetch — currently the AWS provider + KMS unseal take explicit
AwsCredentials; IMDS / IRSA / EC2-instance-role fetch lands in v0.3.x.
[assay 0.14.2 / assay-auth 0.2.1 / assay-dashboard 0.2.1 / assay-engine 0.2.2] - 2026-04-26
| Crate | Bump |
|---|---|
assay | 0.14.1 → 0.14.2 |
assay-auth | 0.2.0 → 0.2.1 |
assay-dashboard | 0.2.0 → 0.2.1 |
assay-engine | 0.2.1 → 0.2.2 |
Fixed
assay run <script> -- <args>passes trailing positionals to Lua'sargglobal (arg[0]= script path,arg[1..]= user values).dofile,load,loadfileare usable again — old sandbox over-blocked them.string.dumpstays blocked (bytecode escape).- Zanzibar tuple writes no longer 500 (#82).
subject_relwas implicitly NOT NULL via the PK but every code path treated it as nullable. Schema stores''for direct subjects, the relation name for usersets; queries use plain equality. - Zanzibar namespace POST no longer rejects with
missing field 'wildcard'(#81). Field is#[serde(default)]onTypeRef. - Auth Console: removed leaked
phase 8bplanning shorthand (#83) from the Keys empty state and three header comments.
Added
ASSAY_BLOCK_GLOBALSenv var: comma-separated names to nil at VM creation. Supports dotted paths (os.execute,debug.getinfo). Typos silently skip.
Changed
Tuple/SubjectRefsubject_relis nowString(wasOption<String>). Empty string = direct subject, non-empty = userset. JSON callers can omit the field; serde defaults to"".- Schema migration is destructive — drop and recreate
auth.zanzibar_tuplesfor any existing install. (No production assay 0.14.x deployment on file at this writing.)
[assay 0.14.1 / assay-workflow 0.3.1 / assay-engine 0.2.1] - 2026-04-26
| Crate | Bump |
|---|---|
assay | 0.14.0 → 0.14.1 |
assay-workflow | 0.3.0 → 0.3.1 |
assay-engine | 0.2.0 → 0.2.1 |
Fixed
workflow.cancelno longer 400s on empty body (#66). Stdlib stops sending[], and the handler tolerates{}/[]/ no body /{"reason":"..."}.- Pinned the lua coroutine ctx-resume contract with a regression test (#40, fixed in v0.13.0).
Added — stdlib
assay.ansi— SGR → HTML + strip (#67).assay.url— RFC 3986 percent encoding + form bodies (#72 prereq).assay.tailscale— OAuth2 client + auth keys + device management + ACL preview (#72).assay.version— compare across semver / debian / rpm / numeric (#71 §3).assay.compress— gunzip / unxz / unzstd Rust builtin (#71 §4).assay.apt— Debian Packages index reader, sorted viaassay.version(#71 §2).assay.github— module-level Releases helpers (latest_release,find_asset,release_checksum, …) (#71 §1).
Added — Lua builtins
template.render_with_loader(dir, name, vars)—{% extends %}/{% include %}/{% import %}resolve sibling templates (#64).
Changed
httpresponse bodies are now raw bytes, notresp.text()— round-trips gzip/xz/zst payloads without UTF-8 corruption.cancel_workflowhandler reads raw bytes; see #66.
Migration
No breaking changes. See docs/migration-to-0.14.1.md.
Out of scope
- #75 (drop OpenSSL → RustCrypto for
webauthn-rs).
[assay-engine 0.2.0] - 2026-04-25
Headline: assay-engine becomes a full Ory replacement + IdP, on top of the Temporal-replacement workflow engine that already shipped in v0.13.x. One static binary now covers Kratos (identity), Hydra (OIDC provider), Keto (Zanzibar/ReBAC) and Temporal (workflows) — plus capability tokens (biscuit) which Ory has nothing equivalent for. PostgreSQL 18 + SQLite, both first-class.
The umbrella v0.2.0 release rolls together the v0.1.2 engine-schemas refactor with the entire auth
stack (plan 12 phases 4-8) and the docs/site refresh. It supersedes the v0.1.2 work that was
in-flight on feature/engine-0.1.2-schemas — that PR was closed; this is the consolidated drop.
Active-development release — consumers roll with each bump, no dedicated migration guide. SQLite
deployments delete ./data/ and let the new per-module-file layout populate from scratch. PG
deployments get idempotent ALTER TABLE … SET SCHEMA … migrations applied automatically on boot.
Per-crate bumps:
| Crate | Version | Notes |
|---|---|---|
assay | 0.13.1 → 0.14.0 | New Lua stdlib assay.auth.* wrappers (login/passkey/oidc/biscuit/zanzibar/users/sessions/oidc_clients) |
assay-engine | 0.1.1 → 0.2.0 | Headline release. AuthCtx composition, engine.modules-driven boot, dashboard auth panes mounted, full /auth/* HTTP surface. |
assay-workflow | 0.2.1 → 0.3.0 | All store queries schema-qualified (workflow.*); tracks assay-domain 0.2 |
assay-domain | 0.1.1 → 0.2.0 | New engine module hosts engine-core schema (events/lock/namespaces/modules/audit/instances/migrations) |
assay-auth | 0.1.0 → 0.2.0 | First real content release — full module set |
assay-dashboard | 0.1.0 → 0.2.0 | Auth admin SPA (Users / Sessions / OIDC clients / Upstream / Zanzibar / Keys / Audit) |
Schema/attach storage model
- PG: one database, three schemas (
engine,workflow,auth) per active module - SQLite: one file per module (
./data/engine.db,./data/workflow.db,./data/auth.db), attached to one connection on startup so query syntax matches PG (auth.users, etc.) - Cross-schema (PG) and cross-attached-DB (SQLite) transactions stay atomic under the default journal mode, preserving the v0.13.1 atomic publish-on-commit guarantee
- Module enablement driven by
engine.modulesrow at runtime; compile features control linking
Added — engine core (was the v0.1.2 scope)
engine.modules(boot manifest),engine.audit(ops log),engine.instances(multi-node visibility — 5 s heartbeat, 60 s stale TTL, graceful shutdown DELETE),engine.migrations(per-module schema-version tracker)assay_engine::init::EngineBoot— 8-step boot: open storage → migrate engine schema → read enabled modules → create/attach per-module → run module migrations → acquire leader → register instance → wire routes[backend].data_dirconfig field (default./data/); engine creates the dir on boot if missingfrom_pool/from_attached_poolfactory methods onPostgresStore/SqliteStoreGET /healthzreturns engine version + leader status + attached modulesGET /api/v1/modulesfor dashboard module-pane gating
Added — auth primitives (Kratos-equivalent identity)
- Sessions:
auth.sessions, opaquesess_…cookie, CSRF token, rotation on privilege change, HttpOnly + SameSite=Lax + Secure cookies, programmaticassay_session+ JS-readableassay_csrf - Passwords: Argon2id (m=64 MiB, t=3, p=4) with
hash/verify/needs_rehash - JWT: Ed25519 issue + verify with kid-based active+history lookup, JWKS rotation,
auth.jwks_keystable - Passkey (WebAuthn): registration + authentication via
webauthn-rs, state round-trip via session payload - Stores: PG + SQLite User/Session stores;
auth.users,auth.user_upstream,auth.passkeys,auth.audit
Added — biscuit (capability tokens — assay differentiator vs Ory)
- Ed25519 root keypair, persisted in
auth.biscuit_root_keyswith rotation support BiscuitConfig::issue/verify/attenuate(the last two work offline — no auth-server round-trip per check)- Datalog policy expressions for time-bound TTLs and scope assertions
- AuthCtx carries
BiscuitConfigas a non-optional field — same posture as session/JWT
Added — Zanzibar (Keto-equivalent ReBAC)
ZanzibarStoreasync trait + PG + SQLite recursive-CTE backends- SpiceDB-compatible schema parser (definitions, relations, permission expressions with
+/&/-/->arrows + wildcards) - Operations:
define_namespace,write_tuple,write_tuples,check,expand,lookup_resources,lookup_subjects - Depth bound 50 + cycle guard via path array
auth.zanzibar_namespaces,auth.zanzibar_tuples(with reverse index for lookup)Consistencyenum:Minimum/Exact(zookie)/AtLeastAsFresh(zookie)
Added — OIDC (client + provider; Hydra-equivalent IdP)
- OIDC client (federated SSO):
OidcRegistry+OidcClientper upstream provider; PKCE + nonce; userinfo fetch with graceful endpoint degradation - OIDC provider (third-party apps authenticate against assay-engine):
- Discovery doc at
/.well-known/openid-configuration - JWKS at
/.well-known/jwks.json /authorize(code flow + PKCE),/token(auth-code + refresh grants),/userinfo,/revoke(RFC 7009),/introspect(RFC 7662)- Federation routes (
/oidc/upstream/{slug}/start,/callback) - Consent screen (askama-rendered HTML)
- Admin CRUD over
auth.oidc_clients+auth.upstream_providers - Tables:
auth.oidc_clients,auth.upstream_providers,auth.oidc_authorization_codes,auth.oidc_refresh_tokens,auth.oidc_sessions
- Discovery doc at
Added — Dashboard auth admin SPA
Mounted at /auth/console when auth module is enabled, served from assay-dashboard static assets:
- Users — list, search, view (with linked passkeys + sessions + upstream links), enable/disable, delete, password reset
- Sessions — global + per-user, single + bulk revoke
- OIDC clients — CRUD + rotate-secret (display once)
- OIDC upstream providers — CRUD
- Zanzibar — namespace browser, tuple inspector, check evaluator, expand viewer
- JWKS / Biscuit keys — list active + history, rotation trigger
- Audit log — paginated
auth.auditviewer with filters
Conditional render: SPA reads /api/v1/modules and renders auth panes only when auth is enabled.
Added — Lua stdlib assay.auth.* (justifies assay 0.14.0 bump)
crates/assay/stdlib/auth.lua exposes the new auth surface to Lua scripts:
assay.auth.login(email, password) / logout() / whoami()
assay.auth.passkey.{start_register, finish_register, start_auth, finish_auth}
assay.auth.oidc.{start, complete}
assay.auth.biscuit.{issue, verify, attenuate} -- verify + attenuate are local
assay.auth.zanzibar.{check, expand, write}
assay.auth.users.{list, get, create, update, delete}
assay.auth.sessions.{list_for_user, revoke, revoke_all_for_user}
assay.auth.oidc_clients.{list, create, rotate_secret}
Schema rename (v0.13.1 → v0.2.0)
All v0.13.1 tables relocated into per-module schemas with the redundant prefix dropped:
v0.13.1 (public.*) | v0.2.0 (schema-qualified) |
|---|---|
engine_events | engine.events |
engine_lock | engine.lock (SQLite path; PG uses pg_advisory_lock) |
namespaces | workflow.namespaces |
api_keys | workflow.api_keys |
workflows | workflow.workflows |
workflow_events | workflow.events |
workflow_activities | workflow.activities |
workflow_timers | workflow.timers |
workflow_signals | workflow.signals |
workflow_snapshots | workflow.snapshots |
workflow_schedules | workflow.schedules |
workflow_workers | workflow.workers |
PG: idempotent migration block in assay-workflow runs ALTER TABLE … SET SCHEMA …; RENAME TO …
for any v0.13.1 tables found in public; safe on fresh installs and already-migrated databases.
SQLite: rebuild from scratch (per active-dev convention).
Preserved (carried forward from v0.13.1)
- Atomic publish-on-commit —
INSERT INTO engine.events ... RETURNING id; pg_notify(channel, id);in one transaction. Works on PG cross-schema and SQLite cross-attached-DB. - Multi-node PG coordination via
pg_try_advisory_lock(1)(leader election) +FOR UPDATE SKIP LOCKED(distributed task dequeue) +LISTEN/NOTIFY(cross-instance event propagation). - LISTEN channel naming (
assay_engine_events_<ns>per namespace) is configured in code, not derived from the table name — no rename needed despiteengine_events→engine.events.
Docs + website
- New
docs/migration-to-0.2.0.mdupgrade guide - Repositioned
README.md("One static binary that replaces Temporal + Kratos + Hydra + Keto") + comparison table + auth quick-start - 5 new
site/pages/auth-*.htmlpages (overview / passkey / OIDC quickstart / Zanzibar / biscuit)compare-vs-ory.html
- Site nav adds "Auth & IdP"; homepage banner highlights v0.2.0
- Crate-level rustdoc for
assay-authwith the Ory-replacement narrative + getting-started doctest
Binary sizes (measured)
assay(Lua runtime + workflow + dashboard): 11 MBassay-engine(workflow + auth + IdP + dashboard): 8.9 MB
vs Ory: kratos + hydra + keto = ~30-45 MB combined, plus a separate dashboard you build yourself.
[0.13.1] - 2026-04-24
Engine-events outbox. The PL/pgSQL LISTEN/NOTIFY triggers and the lossy in-memory SSE broadcast are
replaced by a Rust-managed CDC outbox (engine_events) that delivers durable realtime events to
dashboards and cross-node subscribers. All state-mutating workflow methods now emit typed
WorkflowBusEvent variants via the new EngineEventBus trait, which has PG + SQLite
implementations. Dashboards reconnecting after a laptop sleep replay up to 3 days of missed events
from a Last-Event-ID cursor; pre-retention gaps return HTTP 410 so the client can snapshot and
resync.
Active-development release — consumers roll with each bump, no dedicated migration guide.
Per-crate bumps:
| Crate | Version | Notes |
|---|---|---|
assay | 0.13.1 | Dep bump (assay-workflow 0.2.1) |
assay-engine | 0.1.1 | Wires PG/SQLite bus + cleanup loop into run_with_store |
assay-workflow | 0.2.1 | Typed WorkflowBusEvent emits; SSE rewrite; no triggers |
assay-domain | 0.1.1 | EngineEventBus trait + PG/SQLite impls + events table |
assay-auth, assay-dashboard, assay-lua unchanged.
Added
assay_domain::events::EngineEventBustrait +PgEngineEventBus+SqliteEngineEventBusimplementations.engine_eventstable (PG + SQLite) as the durable event outbox.WorkflowEventBus+WorkflowBusEventenum inassay-workflow.EngineConfig.engine_events_ttl_secs(default259200= 3 days) and an hourly cleanup task that prunesengine_eventsolder than the configured TTL.- SSE
/api/v1/events/streamnow supportsLast-Event-IDreplay, HTTP 410 Gone on pre-retention cursors, and?ns=&subsystem=&workflow_id=&kind=server-side filters.
Changed
- SSE payload shape is now
{id, ts, namespace, subsystem, kind, payload}. - Scheduler wake-up is cross-node capable without per-subscription PgListener connections — one
assay_engine_events_<ns>channel per namespace replaces the old per-workflow/per-queue channels. dispatch_recoverycadence bumped from 1s to 10min. Durable outbox + cursor replay is the correctness path; this loop is now a pure crash-safety net.assay-workflow::api::serve_with_busis the preferred engine entry point;serve/serve_with_versionstill exist for bus-less embedders (tests, theassay-luaruntime harness).
Removed
- PL/pgSQL triggers
assay_notify_runnable,assay_notify_task. The migrate path drops them if they survive from a v0.13.0 database. assay_runnable_<ns>/assay_task_<queue>NOTIFY channels (replaced by oneassay_engine_events_<ns>channel per namespace).WorkflowStore::subscribe_runnableandsubscribe_taskstrait methods — consumers subscribe to theEngineEventBusinstead.- In-memory
sse_tx/engine_txbroadcast channels +EngineEvent/BroadcastEventtypes onWorkflowCtx. crates/assay-workflow/tests/subscribe_trait_bounds.rs+ the twopush_*_fires_on_*tests insmoke_backends.rs— they exercised the removed surface.
Fixed
- Dashboard SSE clients no longer lose events when the laptop sleeps longer than the broadcast buffer; cursor-based replay refills the gap up to the retention window.
Known gaps
PgConnectOptionsin sqlx 0.8 doesn't expose TCP keepalive knobs, so the listener uses OS-default keepalives (Linux: ~2h idle). sqlx auto-reconnect + cursor replay cover silently-dead TCP once the nextrecv()errors. A future sqlx bump will let us tune this directly.
[0.13.0] - 2026-04-22
The monolithic assay binary is decomposed into six crates. assay-lua becomes a pure Lua runtime
and HTTP client; assay-engine becomes a standalone HTTP server that composes assay-workflow,
assay-dashboard, and (in v0.14.0) assay-auth behind one port. SurrealDB is dropped entirely in
favour of PostgreSQL 18 + SQLite, on the evidence of a measured 3× clean build time and 3× peak
compile RAM with no capability gain over PG18 + pgvector + recursive CTEs. Auth primitives, the
full OIDC provider, passkey, and Zanzibar ship in v0.14.0 — Phases 4–7 of
.claude/plans/12-v0.13.0-execution.md — so this release is deliberately narrower than the original
plan 12 scope. Full upgrade steps live in
docs/migration-to-0.13.0.md.
Six crates go out together:
| Crate | Version | New / Bumped |
|---|---|---|
assay-lua | 0.13.0 | Bumped (runtime only) |
assay-workflow | 0.2.0 | Bumped (breaking) |
assay-domain | 0.1.0 | New |
assay-auth | 0.1.0 | New (scaffold) |
assay-dashboard | 0.1.0 | New |
assay-engine | 0.1.0 | New |
assay-workflow is the only breaking bump — the trait moved to assay-domain, WorkflowCtx<S>
replaces Engine<S>, and backends are now feature-gated additive flags rather than unconditional
compile. assay-auth is a scaffold only in this release; its real content ships in v0.14.0.
The root workspace no longer has a [package]; it's workspace-only. What used to be the top-level
assay binary moved to crates/assay/ and publishes to crates.io as assay-lua. Every domain
concern lives under crates/<name>/. assay-domain holds the shared WorkflowStore trait and DTO
types so any crate can depend on the trait without pulling the whole workflow engine.
assay-dashboard holds the HTML/JS/CSS assets that used to live inside assay-workflow, exposed
through a thin axum router. The dashboard is now served only by assay-engine; the retired runtime
dashboard is gone for good.
The new assay-engine binary is the operational heart: assay-engine serve --config engine.toml
loads a TOML config, connects to PG18 or SQLite, runs migrations, and serves the workflow API plus
the dashboard on one port. Backend selection is runtime-configurable via
[backend] type = "postgres" | "sqlite", not a build-time feature switch — both drivers compile
into the same binary by default. Example configs live in crates/assay-engine/examples/ and the
crates/assay-engine/tests/engine_smoke.rs integration test proves the full pipeline end-to-end.
PostgreSQL 18 is the minimum supported version. Migrations use the PG18 uuidv7() built-in and the
schema is laid out to take advantage of PG18 skip-scan composite indexes (which the Zanzibar tuple
store in v0.14.0 will lean on). Consumers on older Postgres must upgrade before running 0.13.0
migrations.
The SurrealDB backend is removed everywhere — the backend-surrealdb Cargo feature, the surrealdb
crate dependency, roughly 2400 lines of src/store/surrealdb/* impls, and four SurrealQL migration
files are all gone. The measured cost was compile-time pain — 91 s → 281 s clean release build and
1.3 GB → 3.7 GB peak compile RAM — and no production value that PG18 plus pgvector plus recursive
CTEs doesn't cover. The full measurement and rationale live in plan 12's revision log. There is no
in-place data migration path from SurrealDB; move to PG18 or SQLite via a clean re-seed or a one-off
replay script.
Embedding the workflow engine inside assay-lua is also retired. The runtime binary no longer
depends on assay-workflow or sqlx, no longer accepts the assay serve command, and no longer
runs an internal scheduler. Scripts that need workflow functionality keep using the same HTTP
subcommands they used in 0.12 (assay workflow start / list / describe / …) — those commands were
always HTTP clients and they are unchanged. The only difference is that the HTTP endpoint now has to
be a separately deployed assay-engine instead of whatever assay serve was producing in-process.
Operators pick the engine URL via $ASSAY_ENGINE_URL or --engine URL as before.
Library consumers who embedded assay-workflow as a crate need to update their imports.
WorkflowStore lives in assay_domain now; DTOs like WorkflowRecord and WorkflowEvent live in
assay_domain::types. The Engine<S> generic is gone — its role is merged into WorkflowCtx<S>,
which is simultaneously the axum state and the orchestrator (Shape 2B from plan 12a Task 1.3
revised). Call sites go from Engine::<PostgresStore>::new(store) to
WorkflowCtx::start(Arc::new(store)). Features are now additive, not mutually exclusive:
backend-postgres and backend-sqlite can both compile into the same binary and the engine picks
one at startup.
The WorkflowStore::subscribe_runnable / subscribe_tasks methods are now async and return the
stream only after the underlying LISTEN has been registered on the server. The old shape returned
a lazy async_stream that issued LISTEN on first poll, which let a caller drop notifications by
calling pg_notify between constructing the stream and polling it. PostgresStore::from_pool(pool)
is a new constructor for when the engine owns the pool and hands a clone to the workflow module,
matching the plan-12 "shared pool" story.
The engine in 0.13.0 runs with AuthMode::no_auth() — there is no JWT or API-key gate on the
workflow API in this release. Do not expose a 0.13.0 engine on the public internet without a network
gatekeeper (Cloudflare Access, Tailscale, VPN, or similar). v0.14.0 wires the full IdP stack in and
flips the default AuthMode to JWT validation against the engine's own OIDC provider.
The runtime workflow feature and its embedded-sqlx surface are gone from assay-lua's Cargo
manifest. The redundant crates/assay/tests/workflow_store.rs test was deleted — the new
crates/assay-workflow/tests/smoke_backends.rs covers the same ground against both backends and
runs in CI. No behaviour loss.
Plan 12's revision log, written inline in the plan file, documents the SurrealDB drop decision with the measured evidence and documents the 0.13.0 → 0.14.0 scope split so future sessions don't relitigate either. A backend-parity matrix in the same plan spells out, for every workflow and authz capability, what differs between PG18 and SQLite so the trait-abstraction contract stays honest across the two backends.
[0.12.1] - 2026-04-19
Text-processing stdlib primitives + scratch-based image, so assay scripts don't need a shell in the container and the published image goes from ~25 MB back to ~10 MB.
Added
fs.lines(path)— streaming line iterator. Designed forfor line in fs.lines(path) do … end; reads from a buffered reader so multi-GB files don't land in memory. Each line is returned with the trailing\n(or\r\n) stripped. Equivalent towhile read line; do …; done < filein bash.fs.sub_in_file(path, pattern, repl)—sed -iequivalent, but portable (no BSD-vs-GNUsed -idance) and without the quoting traps of shell. Uses Lua patterns (same engine asstring.gsub);replaccepts a replacement string with%0-%9backreferences OR a function. Writes only when the substitution count is > 0, so repeated calls on an already-substituted file are no-ops on disk.string.split(s, sep?)— awk-style field split, extending Lua's built-instringlibrary. With nosep, splits on any run of whitespace and drops leading/trailing empty fields (matches awk default FS and Pythonstr.split()). With a literalsep, splits on that substring (not a Lua pattern — usestring.gmatchif you need pattern semantics). Pairs withfs.linessoawk '{print $2}'-style pipelines become three lines of Lua.
Changed
- Docker image runtime stage back to
FROM scratch(reverts the Feb-2026 regression to alpine:3.21). The publishedghcr.io/developerinlondon/assayimage is now the assay binary plus/etc/ssl/certs/ca-certificates.crt, nothing else. About 40% smaller (~10 MB vs ~25 MB with Alpine), zero Alpine CVE surface. Downstream images (anyoneFROM ghcr.io/developerinlondon/assay) inherit the slimming automatically on next rebuild. Thecommand: ["/bin/sh", "-c", …]wrapper that originally forced Alpine has been removed from every usage in the gitops repo; the new text-processing primitives above cover the shell-out cases that used to need sed/awk. - Regression guards on the Dockerfile (
tests/dockerfile.rs): asserts the runtime stage isFROM scratch, that the CA bundle is copied in, and that the ENTRYPOINT uses an absolute path (/assay, not bareassay— scratch has no$PATH). These fail CI loudly if anyone tries to flip the runtime back to Alpine without justification.
[0.12.0] - 2026-04-18
This release combines a major dashboard upgrade (Steps tab + step- action signal protocol + the AWE/consumer architectural-boundary documentation) with a substantial CI/CD overhaul (mise + moon + a checked-in Playwright e2e suite + the Rust 1.95 toolchain bump) and a new stdlib surface for orchestrating external processes.
Added
-
process.spawn(opts)andprocess.wait(pid, opts?)Lua builtins. Launch detached child processes from any assay script and reap them later, without dropping to bash.process.spawnacceptscmd,args,cwd,env,stdout,stderrand returns{ pid };process.waitblocks (or polls until atimeout) and returns{ status, exited, signaled, timed_out }. Pairs with the existingprocess.killfor full lifecycle control. The dashboard e2e runner atcrates/assay-workflow/tests-e2e/run.luais the canonical example — boots engine + worker, polls/version, seeds a workflow, drives Playwright, cleans up. Seedocs/modules/process.mdfor the full surface. -
Steps tab. Any workflow that exposes a
pipeline_statequery with asteps[]array now gets an automatic "Pipeline" tab in the dashboard's detail view. Renders each step as a circle with one of five canonical statuses —waiting ○,running ⟳,done ✓,failed ✕,cancelled —— and the connector lines between circles fill state-aware so a glance tells you how far through the pipeline the run is. The tab is added at the front and default-selected when present, hidden entirely otherwise. Seedocs/modules/workflow.md#pipeline-tab-conventionfor the schema. -
Live snapshot tail. While the Steps tab is open and the workflow is
RUNNING, the dashboard pollsGET /workflows/{id}/state/pipeline_stateevery 1s and diff-applies changes onto the existing DOM — circles and connectors update in place, log entries append at the bottom, and animations on the running step keep their state. Polling stops when the user switches away from the tab, the panel closes, or the workflow reaches a terminal status. Includes a scroll-lock toggle so operators reading mid-log don't get yanked back to the bottom every second. -
Per-step actions via signals. Each step in
steps[]may include anactions = { "approve", "reject", ... }array. Those render as buttons under the step's circle; clicking one POSTs astep_actionsignal to the engine with payload{ step, action, user }. The engine routes the signal; the workflow handler decides what each action means. AWE provides the plumbing, the consumer provides the semantics — same architectural boundary that keeps the engine domain-agnostic. -
Step log filter. Clicking a step circle filters the log below to just that step's entries (uses the optional
stepfield on each log entry). Click again to clear the filter.
Changed
-
Slim detail layout. Dropped the left "identity card" column from the inline detail expansion — every field it carried (id, type, status, queue, created) is already on the workflow row above or in the namespace selector. The only field that wasn't redundant (
completed_at) now renders as a single meta line above the action toolbar, only when the run is terminal. The action toolbar (Signal / Cancel / Terminate / Continue-as-new) sits full-width above the tabs, and the tabs themselves use the full horizontal width of the expansion. Net effect: tighter detail block, more room for the new Steps tab to breathe. -
Dropped Run ID from detail meta — it's a near-duplicate of the workflow id shown on the row directly above (run id is just the workflow id prefixed with
run-and suffixed with a timestamp). -
CI/CD overhaul: mise + moon + Playwright e2e + Rust 1.95.
.mise.tomlnow pins rust 1.95.0, node 25.9.0, and moon 2.2.1 — one source of truth for tool versions across local dev and CI. moon owns the workspace's project graph (assay-lua,assay-workflow,dashboard-e2e,site,openclaw-extension) and runs only the affected tasks on each PR viamoon ci. Shared task templates in.moon/tasks/tag-*.ymlkeep per-projectmoon.ymlminimal. Newcrates/assay-workflow/tests-e2e/directory holds the dashboard's Playwright suite, run automatically by CI whenever the workflow crate changes.
[0.11.15] - 2026-04-18
Changed
- Inline row-expansion hides the detail-header entirely. Previously the inline expansion still
rendered the
.detail-headerblock (with the h2 hidden but the✕close button visible on the right), leaving ~40px of whitespace above the actual content. The close button was redundant anyway — clicking the row itself toggles expand/collapse — so the whole header block is nowdisplay: nonein inline mode. The right-hand side panel still renders its header (no row-above context there, no row-click toggle). Detail-body and detail-grid top padding / margin also zeroed for inline mode so content sits flush with the top of the expansion.
Cuts another ~40-60px of vertical space per expanded row on top of v0.11.14's id-header hide.
[0.11.14] - 2026-04-18
Fixed
-
Worker resilience — don't crash on transient HTTP errors. The
workflow.listen()poll loop now wraps each heartbeat + task-poll call inpcalland backs off exponentially (1s → 2s → 4s → 8s → 16s, capped at 30s) on failure. Previously a single DNS blip, engine pod restart, or kube-proxy hiccup would propagate an error out of the loop, kill the worker, and leave the worker row stale until the consumer's pod was restarted — downstream effects included empty Queues view, empty Workers view (the registered worker had stopped heartbeating), and silently-dropped workflow tasks.First successful call after a failure resets the backoff to the baseline so recovery is instant once connectivity returns. Warn- level log on each failure includes the backoff duration and error message so operators can tell from logs whether a worker is cleanly weathering a blip vs. persistently broken.
Changed
-
Two-column detail layout. The workflow detail block is now a grid: left column is a fixed-width identity card (status badge, meta items stacked as
<dl>, actions at the bottom) that stays visible regardless of which tab is selected; right column gets the rest of the horizontal space for tab content. Previously meta + actions ran horizontally above the tabs, which left tab content cramped on any single run with more than a few events. Stacks to a single column on viewports narrower than 720px. -
Full workflow IDs in the list view. Removed the 32-char truncation on the workflow id column; long ids wrap at column boundaries via
word-break: break-all. Makes the id the first thing an operator can read and copy without opening the detail view. Since ids follow a consistent pattern (promo-{ts}-{version}-to-{env}), rows wrap to similar heights — the table doesn't become ragged. -
Inline detail hides its id header. When a row is expanded inline, the detail block no longer repeats the workflow id as an h2 — the row above already shows the full id, repeating it wastes vertical space. The right-hand side panel (used by child-workflow navigation) keeps the h2 because there's no row-above context there.
Together the two-column restructure and the header-hide cut ~60px of vertical noise per expanded row while making the list view more scannable in the collapsed state.
[0.11.13] - 2026-04-17
Changed
-
Full workflow IDs in the detail view. The detail-view header and Run ID meta field no longer truncate — the detail panel has the horizontal space for the full id, and operators consulting this panel are usually trying to read or copy the id anyway. Long ids wrap cleanly on column boundaries via
word-break: break-all. List-view and children-table truncation retained (density matters there). -
Smart truncate. The
truncate(str, len)helper now requires at least 4 chars of actual savings before it adds"...". Previously a 34-char id in a 32-char column showed"thirty-two-char-id-exactly-thi..."— lossy for barely any column gain. Now it just shows the full string when trimming wouldn't materially help. -
Row-click expansion. Clicking anywhere on a workflow row now toggles the inline detail — not just on the id link. Buttons (Signal / Cancel / Terminate) still have their own click behaviour and don't trigger expansion. Cursor pointer + hover feedback across the row so the affordance is obvious.
-
Modern link hover.
.data-table .clickable:hoverno longer underlines — it shifts to the accent-hover colour instead. Cleaner under monospace id strings where typographic underlines on numbers and dashes can look jagged. Row-hover still provides a strong visual affordance. -
Inline namespace switcher in the status bar. Replaced the button that opened the sidebar's dropdown with its own native
<select>, styled to look like plain text. The native dropdown now opens anchored at the status bar (where the user clicked), not at the top-left sidebar. Mirrors the sidebar select's options — switching either keeps both in sync. -
Modern select trigger. Both the sidebar namespace select and the new status-bar select got flat, OS-chrome-free styling with an inline SVG chevron, accent ring on focus, subtle border darken on hover. Native dropdown list still renders OS-default (no way to restyle that without a custom combobox).
Tests
- 32 lib + 40 orchestration tests still pass. Clippy clean with -D warnings.
[0.11.12] - 2026-04-17
Added
-
Per-run engine-version stamp.
start_workflownow auto-stamps the running engine's version into each workflow'ssearch_attributesasassay_engine_version. Triages "which engine started this run" without operators having to keep their own bookkeeping — searchable viaworkflow.list({ search_attrs = { assay_engine_version = "0.11.12" } }). Caller-suppliedassay_engine_versionwins on conflict (explicit override preserved for replay / testing scenarios). -
More whitelabel knobs:
Variable Purpose Default ASSAY_WHITELABEL_FAVICON_URLReplace the browser-tab icon Built-in SVG ASSAY_WHITELABEL_DEFAULT_NAMESPACENamespace the dashboard opens on main -
Tabbed detail view. The workflow detail block is now organised into tabs — Overview (input/result/error), State (register_query snapshot), Events, Children, Attributes. Variable-height sections live behind tabs so the meta grid + actions stay compact and scannable regardless of how much a run has accumulated. Empty tabs (no state snapshot, no children, no search attrs) dim rather than hide, so operators see a consistent shape across runs.
-
Inline row-expansion. Clicking a row in the workflows list toggles an inline detail block beneath it. Click again to collapse. Opening a new row auto-collapses the previous one. The right-hand detail panel is retained for child-workflow click-through navigation. Matches the "drill into one run while keeping context above/below visible" pattern.
Changed
-
Footer attribution wording — whitelabel mode now says "Powered by Assay" with a link to https://assay.rs, not "Powered by Assay Workflow Engine". Less redundant when the operator's own
_SUBTITLEalready includes "Workflow Engine" (e.g. CC embeds). -
Clickable namespace in the status bar. The footer's current namespace value is now a button that focuses / opens the sidebar's namespace dropdown — saves a trip to the top of the sidebar when the user's already looking at the footer.
-
Collapse-arrow SVGs replacing the ASCII
</>chars in the sidebar toggle. Same toggle behaviour, cleaner visual, aligned to the rest of assay's outlined-stroke icon set. -
Workflow IDs get a
title=tooltip everywhere they're truncated in the dashboard (workflows list, workers list, detail header, run ID, children table). Hover reveals the full ID without operators having to open the detail panel to see it. -
Pagination hides on single-page lists. The "Prev / Page 1 / Next" chrome used to render even when there was only one page; now it renders only when there's actually content to page through.
Fixed
- Undefined CSS custom properties
--surface-1,--surface-2, and--text-primaryreferenced by.btn-action:hover,.inline-form, and the toast component fell back totransparent/initial, which made buttons appear "completely white" on hover against a white page. All three references renamed to their defined counterparts (--surface,--surface-hover,--text) — 37 references now point at defined tokens, zero undefined references remaining.
Tests
- 5 new whitelabel render tests: favicon URL override, default-namespace data-attribute, "Powered by Assay" wording (not Workflow Engine), attribution link presence, favicon-only customisation flipping the footer. Total whitelabel unit coverage: 18 tests.
- 5 new
inject_engine_versionunit tests: default (no attrs), existing attrs gain the field, caller override wins, non-object JSON preserved, unparsable JSON preserved. - 40 orchestration + 32 lib tests all pass. Clippy clean with -D warnings.
[0.11.11] - 2026-04-17
Added
-
Whitelabel: subtitle + mark-badge + two-line brand layout. The dashboard sidebar header now renders a mark-badge (filled accent square with a single-letter glyph), a bold brand name, and an optional muted subtitle underneath — giving operators the canonical two-line brand block without needing a bespoke logo SVG. The mark-badge is now always visible (previously only when the sidebar was collapsed), so standalone and whitelabel dashboards alike get a proper brand block.
Two new env vars:
Variable Purpose Default ASSAY_WHITELABEL_SUBTITLESmall muted line under the brand name — (no subtitle rendered) ASSAY_WHITELABEL_MARKGlyph in the badge; override when NAME's first char isn't rightFirst char of NAMEuppercasedWhen
ASSAY_WHITELABEL_LOGO_URLis set, the supplied image replaces the badge glyph entirely via:has(.logo-img)targeting. -
Footer attribution: "Powered by" in whitelabel mode. Any customised identity (non-default
NAME, non-emptySUBTITLE, or aLOGO_URL/CSS_URLset) flips the status-bar engine line fromAssay Workflow Engine vX.Y.ZtoPowered by Assay Workflow Engine vX.Y.Z, with "Assay Workflow Engine" linked to https://assay.rs. Attribution without burying the engine. Non-whitelabel deployments see no change. -
ctx:cancel(reason)— workflows can land themselves in CANCELLED. Raises the internal cancellation sentinel the task runner already handles, so a workflow that decides it should stop early (human approver rejected, preconditions fail) transitions to engine-levelCANCELLEDinstead ofCOMPLETED. Previously the only way to reach that status was an externalPOST /workflows/{id}/cancel, which forced workflow authors to either return normally (wrong status surfaced in dashboards) or raise a generic error (status becameFAILED, also wrong). Distinct from an externally-requested cancel — same terminal state.workflow.define("ApproveAndDeploy", function(ctx, input) local d = ctx:wait_for_signal("decision") if d.action == "reject" then state.rejected_by = d.user ctx:cancel("rejected by " .. d.user) end return ctx:execute_activity("deploy", input) end)
Tests
- 5 new whitelabel render tests: subtitle rendering (set + unset), mark override, "Powered
by"-footer variant, and the
is_customised()detection that drives it. Total whitelabel coverage: 15 tests. - New orchestration test
lua_workflow_ctx_cancel_lands_in_cancelled_statusverifies a workflow callingctx:cancel("reason")ends withstatus = "CANCELLED"and no result payload.
[0.11.10] - 2026-04-17
Added
-
Dashboard whitelabel support — six optional env vars let operators rebrand the embedded
/workflowdashboard per-deployment, so a platform team can surface assay inside their own admin UI under their own company name, logo, and browser title without forking the binary. Every knob defaults to assay's built-in identity, so an unset env keeps the standalone experience unchanged.Variable Purpose Default ASSAY_WHITELABEL_NAMEText in the sidebar header AssayASSAY_WHITELABEL_LOGO_URLImage URL rendered before the brand text — (no image) ASSAY_WHITELABEL_PAGE_TITLEBrowser tab title Assay Workflow DashboardASSAY_WHITELABEL_PARENT_URLBack-link URL in the sidebar footer — (hidden) ASSAY_WHITELABEL_PARENT_NAMELabel for the back-link BackASSAY_WHITELABEL_API_DOCS_URLOverride / hide the sidebar API Docs link /api/v1/docsASSAY_WHITELABEL_CSS_URLExtra stylesheet loaded after assay's own CSS — (no extra sheet) ASSAY_WHITELABEL_API_DOCS_URL=""(empty string) hides the link entirely — useful when the embedding app's ingress doesn't route the OpenAPI path or the docs are provided elsewhere. Any other value redirects the link to that URL.ASSAY_WHITELABEL_CSS_URLlets operators re-skin the dashboard without forking. The extra stylesheet loads at the end of<head>, after assay'stheme.css+style.css, so source-order specificity lets it override any CSS custom property (e.g.--accent,--bg,--text) or specific selector. Full design-token list indocs/modules/workflow.md#dashboard-whitelabel. Asset-version is appended automatically so a redeploy that changes the stylesheet forces a browser re-fetch.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.svgloads from the host app with no CORS plumbing. -
workflow.start({namespace, search_attributes})— full engine parity.workflow.start()now passesopts.namespaceandopts.search_attributesthrough to the engine, so Lua callers can scope workflows to a non-default namespace and seed indexed metadata at start time. Previously these fields were accepted byPOST /api/v1/workflowsbut silently dropped by the Lua stdlib client, forcing callers to hit the REST API directly for any multi-tenant deployment. -
workflow.listen({namespace})— namespace-scoped workers. Workers register intoopts.namespace(default"main") onPOST /workers/register, so a worker pool in one namespace no longer accidentally picks up tasks from a sibling namespace that happens to share its queue name. The startup log line now carries the namespace alongside the queue for easykubectl logstriage.
Both changes close a gap surfaced by consumers building multi-tenant deployment pipelines on top of the engine (e.g. a platform-engineering namespace for promotions, a data-engineering namespace for backfills, both sharing one assay-serve instance). No engine changes — the engine already supported namespace on these endpoints; only the stdlib was missing.
Tests
- New orchestration test (
orchestration.rs):lua_workflow_namespace_scoping_end_to_end— creates a non-default namespace via the engine API, starts a worker withnamespace="deployments", starts a workflow in the same namespace, and asserts the completed record carriesnamespace: "deployments"and the expected result.
[0.11.9] - 2026-04-17
Added
-
ctx:wait_for_signal(name, { timeout = seconds })— bounded signal wait. Returns the signal's JSON payload when a matching signal arrives within the timeout, ornilwhen the timer expires first. Enables approval gates, external-callback waits, and any workflow that must abandon its wait after a deadline — without a side-channel timer or manual race logic in user code.The call yields a batch of two commands (
ScheduleTimer+WaitForSignal); on replay the winner is decided by comparing history event seqs of the next unconsumedSignalReceivedagainst the pairedTimerFired. Determinism matchesctx:sleepandctx:execute_parallel.Backward compatible:
ctx:wait_for_signal(name)without opts is unchanged.
Changed
WaitForSignalengine command accepts an optionaltimer_seq. When present, it is recorded in theWorkflowAwaitingSignalevent payload so the dashboard can show which timer is racing the wait.
Tests
- Two new orchestration tests (
orchestration.rs):lua_workflow_wait_for_signal_timeout_signal_wins— signal arrives before the 30s timer; workflow completes with the payload.lua_workflow_wait_for_signal_timeout_timer_wins— no signal sent; the 1s timer fires and the workflow completes with the timeout branch.
[0.11.8] - 2026-04-17
Changed
-
GET /api/v1/healthandGET /api/v1/versionare now always unauthenticated, regardless of whether--auth-issueror--auth-api-keyis set. Standard practice for liveness/readiness probes and version discovery — Kubernetes kubelet, load balancers, third-party monitors, and the CLI can now reach these endpoints without a bearer token.Previously both endpoints lived inside the auth-gated
/api/v1/*surface, which forcedworkflow.connect()'s connectivity probe and kubelet probes to carry a valid credential. That blocked legitimate first-boot bootstrap flows (e.g. the gitops reconcile script trying toPOST /api/v1/api-keysthrough the unauth bootstrap window had to sidestepworkflow.connectentirely).All other
/api/v1/*endpoints remain authenticated when auth is enabled.
Internal
api/public.rs— new module that owns the public (unauth) sub-router at/api/v1/*. Holdshealth_check+version.api/meta.rsdeleted — its single/versionroute moved toapi/public.rs. TheVersionInfostruct moved with it.api/workers.rsno longer registers/health. Its single responsibility is/workersnow.api/mod.rsrouter()grew a third tier alongside "authenticated /api/v1/" and "dashboard + openapi": "public /api/v1/", merged outside the auth middleware layer by construction.
Tests
- Five new auth tests (
auth_test.rs) verify/api/v1/healthreturns 200 unauth in api-key / jwt / combined modes, that/api/v1/versionis unauth in api-key mode, and that other/api/v1/*paths still require auth (regression guard against accidentally opening up more of the surface).
[0.11.7] - 2026-04-17
Added
-
POST /api/v1/api-keysendpoint — REST alternative to theassay serve --generate-api-keyCLI subcommand. Accepts{ label?, idempotent? }. Withidempotent=trueand a key matching the label already exists, returns200 OKwith the existing record's metadata (no plaintext). Otherwise mints a fresh key and returns201 Createdwith the plaintext.Bootstrap window: when the
api_keystable is empty,POST /api/v1/api-keysis callable without authentication. This is the only way a freshly deployed server running in API-key or combined mode can receive its first credential without operator shell access. The window closes the moment any key exists. -
GET /api/v1/api-keysandDELETE /api/v1/api-keys/{prefix}— list and revoke. -
workflow.api_keys.{generate, list, delete}Lua stdlib helpers wrapping the above endpoints. Example:local resp = workflow.api_keys.generate("cc_api_key", { idempotent = true }) if resp.plaintext then -- fresh mint; persist plaintext somewhere (e.g. a k8s Secret) else -- already exists; plaintext was issued on first call end
Store
-
New
WorkflowStoretrait methods:api_keys_empty()(used by the bootstrap-window gate) andget_api_key_by_label(label)(used by the idempotent-mode lookup). Implemented for both SQLite and Postgres. -
ApiKeyRecordnow derivesutoipa::ToSchemaso the OpenAPI spec includes it.
Changed
assay-workflowcrate bumped to0.1.5(from0.1.4). Additive API changes; downstream consumers onversion = "0.1"continue to work.
[0.11.6] - 2026-04-17
Fixed
-
Postgres schema migration crash on startup.
PostgresStore::migrate()split the embeddedSCHEMAstring by;and executed each fragment as SQL. A semicolon inside an SQL line comment (-- Idempotent across startups; fresh installs pick the column up from the…) produced a phantom fragment starting with naked prose, and Postgres rejected it withsyntax error at or near "fresh"— which crashedassay serveon every boot against a Postgres backend, regardless of whether the target database was fresh or already populated. Affects v0.11.3 through v0.11.5.Fix: extract the split into a
sanitise_schemahelper that drops pure-comment lines (leading whitespace then--) before splitting on;. Inline---after-code and string-literal contents are left untouched, so the filter is conservative enough to stay correct as the SCHEMA grows more prose.
Changed
assay-workflowcrate bumped to0.1.4(from0.1.3). No public API changes. Downstream consumers onversion = "0.1"continue to work.
Tests
- Added five pure-Rust unit tests for
sanitise_schemaundersrc/store/postgres.rsthat run on all platforms — no Docker required. Includes a regression test (sanitise_schema_real_constant_produces_only_ddl) that asserts the liveSCHEMAconstant never produces a statement whose first token isn't a recognised SQL keyword. This would have caught the v0.11.3 bug at CI time; the existing integration tests undertests/postgres_store.rsskip when Docker is unavailable (macOS default), which is why this class of bug slipped through.
[0.11.5] - 2026-04-17
Changed
assay-workflowcrate version bumped to0.1.3(from0.1.2) — carries the v0.11.4AuthModerefactor from enum to struct. Per assay's pre-1.0 policy of patch-bumps-by-default, both crates stay in their current minor tracks until there's a deliberate decision to signal API instability to downstream consumers.
Fixed
- crates.io publish. v0.11.4 shipped the binary (GHCR, npm, Linux/macOS artefacts, GitHub
release) but its crates.io publish failed because
assay-workflowwas still pinned to0.1.2— the same version already published for v0.11.3. v0.11.5 is a re-release of v0.11.4's code with both crates' versions bumped so the publish actually lands on crates.io.
Docs
AGENTS.md"Release docs checklist" gains an explicit note aboutcrates/*/Cargo.tomland the independent-versioning policy for sub-crates — the gap that caused the v0.11.4 crates.io failure.
[0.11.4] - 2026-04-17
Added
-
Combined JWT + API-key authentication for
assay serve.--auth-issuerand--auth-api-keycan now be set on the same invocation. When both are enabled, the auth middleware dispatches on token shape:- Bearer tokens that parse as a JWS header are validated against the OIDC issuer's JWKS.
- Bearer tokens that are not JWT-shaped are hashed and looked up in the API-key store.
A semantically-invalid JWT (expired, wrong issuer / audience, forged signature) is rejected on the JWT path and is not retried as an API key — a token that looks like a JWT is treated as a JWT. This lets a single server accept short-lived OIDC user tokens from a browser session and long-lived machine API keys from a CI job without the caller picking a mode up front.
Changed
-
AuthModeis now a struct (api_key: bool,jwt: Option<JwtConfig>) instead of an enum with three variants. Library constructors are unchanged in shape —AuthMode::no_auth(),AuthMode::api_key(),AuthMode::jwt(issuer, audience)— and a newAuthMode::combined(issuer, audience)enables both paths.AuthMode::is_enabled()replaces!matches!(.., NoAuth)call sites.Breaking for downstream Rust consumers that matched on
AuthMode::NoAuth | ApiKey | Jwt { .. }. Theassaybinary and REST / dashboard users are unaffected.
Docs
docs/modules/workflow.mdauth table adds the combined-mode row and documents the token-shape dispatch rule.
[0.11.3] - 2026-04-16
Added
-
ctx:register_query— Lua workflows can expose live application-level state to external callers via named query handlers. After every worker replay the engine persists a snapshot of every handler's result; two new REST endpoints surface it:GET /api/v1/workflows/{id}/state → latest full snapshot GET /api/v1/workflows/{id}/state/{name} → one handler's valueWorkflows that don't call
register_querypay nothing — the worker skips the snapshot command entirely when no handlers are registered. A handler that raises is dropped from the snapshot rather than crashing the workflow (queries are best-effort read-through). -
ctx:continue_as_new— Lua surface for the engine-levelcontinue_as_newREST endpoint that already existed. Workflows yield aContinueAsNewcommand and the engine closes out the current run, starts a fresh one with the same type / namespace / task_queue under{id}-continued-{ts}with the caller-supplied input and empty event history. Standard pattern for unbounded-loop workflows (pollers, schedulers) whose event log would otherwise grow forever. -
ctx:execute_parallel— Run multiple activities concurrently from a single handler run. The worker yields a batch ofScheduleActivitycommands; the engine schedules them idempotently on(workflow_id, seq). Each completion re-dispatches the workflow, replay cache-hits for completed activities and re-yields the rest (no-op at the store layer). The handler proceeds past the call only when every activity has a terminal event. Per-activity retry / timeout opts matchctx:execute_activity.local results = ctx:execute_parallel({ { name = "check_a", input = { id = 1 } }, { name = "check_b", input = { id = 2 }, opts = { max_attempts = 5 } }, { name = "check_c", input = { id = 3 } }, }) -- results[1], [2], [3] in input order; raises if any fail after retries. -
ctx:upsert_search_attributes+ search attributes on workflows — Workflows gain asearch_attributesJSON object settable at start (POST /workflowsbody) and updatable at runtime (ctx:upsert_search_attributes({ … })). The list endpoint accepts a URL-encoded JSON filter that matches workflows whose attributes contain every listed key at the given value:GET /api/v1/workflows?search_attrs=%7B%22env%22%3A%22prod%22%7DSQLite uses
json_extract; Postgres uses(search_attributes::jsonb)->>'key'. Filters AND-join. Unchanged keys are preserved across upserts. -
Schedule
PATCH/pause/resume— Schedules can be updated in place without a delete-and-recreate. Only fields present on the patch are touched; unchanged fields keep their existing values.PATCH /api/v1/schedules/{name} body: { cron_expr?, timezone?, input?, task_queue?, overlap_policy? } POST /api/v1/schedules/{name}/pause POST /api/v1/schedules/{name}/resumePaused schedules are skipped by the scheduler; resume recomputes
next_run_atfrom now and does not backfill missed fires. Updates take effect within a scheduler tick (≤15s). -
Cron timezone — Schedules gain a
timezonefield (IANA name, e.g."Europe/Berlin","America/New_York"). Default"UTC"preserves v0.11.2 behaviour. The scheduler parses the timezone viachrono-tzand evaluates the cron expression in that zone, then persists the UTC epoch asnext_run_at. Invalid names are rejected at create / patch time. -
Optional S3 archival for completed workflows — Behind the
s3-archivalcargo feature (default-off). When compiled in andASSAY_ARCHIVE_S3_BUCKETis set at runtime, a background task periodically finds workflows in terminal states older thanASSAY_ARCHIVE_RETENTION_DAYS(default 30), bundles{workflow_record, events}as JSON, uploads tos3://bucket/prefix/<namespace>/<workflow_id>.json, and purges dependent rows (events, activities, timers, signals, snapshots). The workflow row itself is retained witharchived_at+archive_uriset soGET /workflows/{id}still resolves with a pointer to the cold-storage bundle.Credentials resolve via the AWS SDK's default chain — env vars, shared config, or IRSA / pod-identity via web-identity token. Other env vars:
ASSAY_ARCHIVE_S3_PREFIX(defaultassay/),ASSAY_ARCHIVE_POLL_SECS(default 3600),ASSAY_ARCHIVE_BATCH_SIZE(default 50). -
assay.workflowLua stdlib — full management surface. The stdlib now covers every REST endpoint the engine exposes, so Lua scripts (including CC and Kubernetes Jobs runningassay run seed.lua) can manage workflows, schedules, namespaces, workers, and queues without hand-rolling HTTP calls. New top-level functions:workflow.list(opts) workflow.list_children(id) workflow.terminate(id, reason) workflow.continue_as_new(id, input) workflow.get_events(id) workflow.get_state(id, name?)New sub-tables (each exposes
create / list / describe / patch / pause / resume / deleteas applicable):workflow.schedules workflow.namespaces workflow.workers workflow.queuesEvery function is a thin HTTP wrapper returning the parsed JSON response (or
nilon a 404 fordescribe/get_state), raising on other non-2xx responses. -
Full CLI for the workflow engine. The clap-registered
assay workflow …/assay schedule …subcommands that through v0.11.2 printed "not yet implemented" and exited 1 are replaced with real REST-client implementations, plus a considerable expansion. Everything visible inassay --helpactually runs.Subcommand trees:
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-as-json-or-@file-or--] cancel <id> terminate <id> [--reason R] continue-as-new <id> [--input JSON] # client-side wait <id> [--timeout SECS] [--target STATUS] # exit 0/1/2 for scripts 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>Global options (all flag-backed, env-backed, and config-file-backed, resolved in that precedence order):
--engine-url/ASSAY_ENGINE_URL(defaulthttp://127.0.0.1:8080)--api-key/ASSAY_API_KEY(bearer token, forwarded asAuthorization: Bearer <value>)--namespace/ASSAY_NAMESPACE(defaultmain)--output/ASSAY_OUTPUT—table|json|jsonl|yaml; TTY-adaptive default (tableon a terminal,jsonwhen stdout is piped)--config/ASSAY_CONFIG_FILE— YAML config file, discovered in this order: flag → env →$XDG_CONFIG_HOME/assay/config.yaml→~/.config/assay/config.yaml→/etc/assay/config.yaml
Config file (every field optional):
engine_url: https://assay.example.com api_key_file: /run/secrets/assay-api-key # preferred over `api_key:` namespace: main output: tableapi_key_filereads the file contents, trims whitespace, and uses that as the bearer token. Lets the config live in a ConfigMap with the credential in a separate Secret.JSON input indirection.
--input,--search-attrs, and signal payload args accept:- a literal JSON string (
'{"n":1}') @PATH— read the file and parse-— read stdin and parse
Exit codes: 0 success, 1 HTTP error / unreachable / not-found, 2
workflow waittimeout, 64 usage error (bad JSON input).Shell completion.
assay completion <shell> > /etc/bash_completion.d/assay(or the equivalent for your shell). Buffered and graceful on SIGPIPE so piping toheaddoesn't panic. Adds one new crate dep:clap_complete. -
Tier-1 dashboard mutations. The built-in dashboard at
/workflow/was read-only through v0.11.2; every existing view now pairs with its matching operator control:- Workflows view — new
+ Start workflowinline form (type / id / task_queue / input JSON / search_attributes JSON); per-row Signal / Cancel / Terminate; search-attributes filter in the toolbar (debounced, with client-side JSON validation). - Workflow detail panel — Signal, Cancel, Terminate, and Continue-as-new buttons, all with
toast feedback. "Live state" card renders the latest snapshot written by
ctx:register_queryhandlers (with the event seq and timestamp the snapshot was taken at). - Schedules view — per-row Edit (PATCH form pre-filled with the schedule's values), Pause / Resume toggle, Delete. Create form picks up a Timezone field.
- Settings view — Engine Info card shows the engine version + build profile, fetched from
/api/v1/version. Namespace create / delete upgraded to toast feedback and refreshes the sidebar namespace switcher. - Shared
toast()+apiFetchRaw()helpers exposed via the component context for consistent success/error feedback across every mutation.
Explicitly tier 1 — no in-browser workflow authoring, no batch operations, no reset-to-event, no in-browser RBAC. Those are tier 2 / tier 3 and deferred to later releases.
- Workflows view — new
-
GET /api/v1/versionendpoint. Returns{ version, build_profile }. The CLI passes its ownCARGO_PKG_VERSIONtoassay_workflow::api::serve_with_version, so the field reflects the user-facing binary (e.g.0.11.3) and not the internalassay-workflowcrate version. Embedders using plainserveget the crate version as a fallback.AppStategains abinary_version: Option<&'static str>field.
Changed
-
Engine::start_workflowsignature gains asearch_attributes: Option<&str>parameter (for embedders using the crate directly). REST callers are unaffected; the field is optional onStartWorkflowRequest. -
WorkflowStore::list_workflowssignature gains asearch_attrs_filter: Option<&str>parameter (for embedders). -
WorkflowSchedulestruct gains atimezone: Stringfield. Deserialisers that accept the type from an older v0.11.2 engine will need to tolerate the missing field (default "UTC"). -
WorkflowRecordstruct gainssearch_attributes,archived_at,archive_urifields.
Fixed
- Removed three pre-existing
clippy::map_identitywarnings in orchestration test helpers socargo clippy --tests -- -D warningsstays clean under rust 1.92 / clippy 1.91.
Notes
- No migrations from v0.11.2. The engine is pre-1.0 and no v0.11.x release has been deployed
against a real workload yet, so all v0.11.3 columns (
search_attributes,archived_at,archive_urionworkflows;timezoneonworkflow_schedules) live in the baselineCREATE TABLEstatements only. A fresh DB picks them up automatically; an existing v0.11.2 DB needs to be recreated. The migration plumbing is kept in place for post-v0.11.3 additive migrations — Postgres doesALTER TABLE ... ADD COLUMN IF NOT EXISTSnatively, SQLite has a dormantadd_column_if_missinghelper that pragma-checks before ALTER. The pattern is documented at the bottom of each store'sSCHEMAconstant /migrate()fn. - Parallel activities are still best-effort in the sense that each completion triggers a replay;
deeply parallel fan-outs generate O(N²) idempotent
schedule_activitycalls. The store-level idempotency makes this correct but not minimal; a follow-up can short-circuit re-yields for already-scheduled seqs.
[0.11.2] - 2026-04-16
Fixed
- Docker image build —
DockerfilenowCOPY crates/so theassay-workflowworkspace member's manifest is in the build context. Without this, the v0.11.1 release.yml docker job failed withfailed to read /app/crates/assay-workflow/Cargo.tomland noghcr.io/developerinlondon/assay:v0.11.1image was published. v0.11.2 republishes everything (binaries / crates.io / npm / docker) so:latestpoints at a working image again.
Notes
- No source-level changes versus v0.11.1 —
assay-luaandassay-workflowcrates are byte-identical to v0.11.1 except for the version bumps. Existing v0.11.1 binaries, crates.io packages, and npm packages remain valid; only the GHCR image was missing.
[0.11.1] - 2026-04-16
Added
-
assay serve— Native durable workflow engine built into assay. One binary, multiple modes:assay serveruns the engine;assay run worker.luaruns a worker;assay workflow/assay schedulemanage from the shell. Replaces the need for external workflow infrastructure (Temporal, Celery, Inngest). -
Deterministic-replay runtime — Workflow code is plain Lua run as a coroutine; each
ctx:call gets a per-execution sequence number and the engine persists every completed command (ActivityCompleted,TimerFired,SignalReceived,SideEffectRecorded,ChildWorkflowCompleted, …). On replay,ctx:calls short-circuit to cached values for everything in history; only the next unfulfilled step actually runs. This is how worker crashes don't lose work and side effects don't duplicate. -
Crash safety — Three independent recovery layers:
- Activity worker dies →
last_heartbeatages out per-activity; engine re-queues per retry policy. - Workflow worker dies →
dispatch_last_heartbeatages out (ASSAY_WF_DISPATCH_TIMEOUT_SECS, default 30s); any worker on the queue picks up and replays from the event log. - Engine dies → all state is in the DB; in-flight tasks become claimable again as heartbeats age out. Verified by an end-to-end SIGKILL test in the orchestration suite.
- Activity worker dies →
-
Workflow handler context (
ctx) —ctx:execute_activity(sync, returns result, raises on failure after retries),ctx:sleep(seconds)(durable timer; survives worker bouncing),ctx:wait_for_signal(name)(block until matching signal arrives, returns its payload),ctx:start_child_workflow(type, opts)(sync, parent waits for child),ctx:side_effect(name, fn)(run non-deterministic op exactly once, cache in event log). -
REST API (~25 endpoints) — Workflow lifecycle (
start,list,describe,signal,cancel,terminate,continue-as-new,events,children); workflow-task dispatch (/workflow-tasks/poll,/workflow-tasks/:id/commands); activity scheduling (/workflows/:id/activities,/activities/:id); worker registration & polling; schedule CRUD; namespace CRUD; queue stats. All documented in the served OpenAPI spec. -
OpenAPI spec — Machine-readable spec at
/api/v1/openapi.json. Interactive docs at/api/v1/docs(Scalar). Enables auto-generation of typed client SDKs in any language viaopenapi-generator. -
Built-in dashboard — Real-time workflow monitoring at
/workflow/, brand-aligned with assay.rs. Light/dark theme, foldable sidebar, favicon. Six views: Workflows (list with status filter, drill-in to event timeline + children), Schedules (list + create), Workers (live status + active task count), Queues (pending/running stats- warnings when no worker is registered), Namespaces, Settings. Live updates via SSE. Cache-busted asset URLs (per-process startup stamp) so a deploy is reflected immediately.
-
Provider-agnostic auth — Three modes: no-auth (default), API keys (SHA256-hashed in DB), JWT/OIDC (validates against any OIDC provider via JWKS with caching, e.g. Cloudflare Access, Auth0, Okta, Dex, Keycloak). CLI:
--generate-api-key,--list-api-keys,--auth-issuer,--auth-audience,--auth-api-key. -
Multi-namespace — Logical-tenant isolation. Workflows / schedules / workers in one namespace are invisible to others. Default
main. CRUD via REST + dashboard. -
Postgres + multi-instance — Same engine, swap the backend with
--backend postgres://...orDATABASE_URL=.... Cron scheduler usespg_try_advisory_lockfor leader election so only one instance fires schedules. Activity- workflow-task claiming uses
FOR UPDATE SKIP LOCKEDso multiple engine instances don't race. SQLite is single-instance only (engine takes anengine_lockrow at startup).
- workflow-task claiming uses
-
assay.workflowLua stdlib module —workflow.connect(),workflow.define(),workflow.activity(),workflow.listen(), plusworkflow.start()/signal()/describe()/cancel()for client-side control. The samelisten()loop drives both workflow handlers and activity handlers — one process, both roles. -
examples/workflows/— Three runnable examples with READMEs:hello-workflow/(smallest case),approval-pipeline/(signal-based pause/resume),nightly-report/(cron + side_effect + child workflows). -
assay-workflowcrate — The workflow engine is also publishable as a standalone Rust crate (assay-workflow = "0.1") for embedding in non-Lua Rust applications. Zero Lua dependency. -
SSE client in
http.get— Auto-detectstext/event-streamresponses and streams events to anon_eventcallback. Backwards compatible with existinghttp.getusage.
Tests
-
17 end-to-end orchestration tests (
crates/assay-workflow/tests/orchestration.rs) including 9 that boot a real assay subprocess and verify a full workflow runs to a real result. Highlights:lua_workflow_runs_to_completion— two sequential activities, real result.lua_workflow_with_durable_timer—ctx:sleep(1)actually pauses ~1s and resumes.lua_workflow_with_signal— workflow blocks, test sends signal, workflow completes with the payload bubbled into the result.lua_workflow_cancellation_stops_work— cancel mid-sleep; activity that was about to run is never scheduled.lua_workflow_side_effect_is_recorded_once— side-effect counter file shows fn ran exactly once across all replays.lua_child_workflow_completes_before_parent— parent + child each run as proper workflows, parent picks up child's result.lua_cron_schedule_fires_real_workflow— schedule fires within the scheduler tick, workflow completes, result lands in DB.lua_worker_crash_resumes_workflow— SIGKILL worker A mid-flight; worker B takes over via heartbeat-timeout release; workflow completes; side-effect counter still shows exactly one execution.
-
11 REST-level tests (no Lua subprocess) covering scheduling, completion, retries, workflow-task dispatch, command processing.
-
10 Postgres tests (testcontainers-backed) verifying store CRUD parity against a real Postgres instance.
Notes
- The cron crate (
cron = "0.16") requires 6- or 7-field cron expressions (with seconds). The 5-field form fails to parse — use0 * * * * *for "every minute on the zero second" or* * * * * *for "every second." - The whole engine is gated behind the
workflowcargo feature (default-on). To build assay without it:cargo install assay-lua --no-default-features --features cli,db,server. - Parallel activities (Promise.all-style) are not yet supported; tracked as a follow-up. Sequential
ctx:execute_activitycalls and child workflows cover most patterns today.
[0.11.0] - 2026-04-15
Removed
- Temporal integration — The
temporalfeature flag and all Temporal SDK dependencies (temporalio-client,temporalio-sdk,temporalio-sdk-core,temporalio-common,prost-wkt-types) have been removed. The gRPC client (temporal.connect(),temporal.start()), worker runtime (temporal.worker()), and HTTP REST stdlib module (require("assay.temporal")) are no longer available. The Temporal integration never reached production stability and required an external Temporal cluster plusprotocat build time. A native workflow engine (assay serve) is planned for v0.11.1.
Changed
- Binary size — 16MB → 11MB (-5MB) with Temporal dependencies removed.
- Build time — ~90s → ~34s.
protocis no longer required at build time. - Stdlib module count — 35 → 34 (temporal module removed).
[0.10.4] - 2026-04-12
Added
os.date(format?, time?)— Standard Lua time formatting. Supports strftime patterns (%Y,%m,%d,%H,%M,%S,%c), the!prefix for UTC, and*ttable output. Previously missing from the sandboxed environment.os.time()— Returns current UTC epoch as integer (standard Lua).os.clock()— Returns CPU time in seconds (standard Lua).
[0.10.3] - 2026-04-12
Added
-
ctx:register_query(name, handler)— Register query handlers in Temporal workflows. The handler function is called when Temporal dispatches a QueryWorkflow activation, and the result is returned as a JSON payload. Enables dashboard-style apps to read workflow state in real-time without signals. -
kratos.flows:get_login_admin(flow_id)— Fetch a login flow via the Kratos admin API (no CSRF cookie required). Server-side components like hydra-auth should use this instead ofget_login()which requires browser cookies that may not be available across different cookie domains.
[0.10.1] - 2026-04-12
Fixed
- Temporal worker identity —
temporal.worker()andtemporal.connect()now set a non-emptyidentityonConnectionOptions. The Temporal SDK v0.2.0 requires this field; without it,init_workerfails with "Client identity cannot be empty". Identity is set toassay-worker@{task_queue}for workers andassay-client@{namespace}for clients.
[0.10.0] - 2026-04-11
Added
assay.gitlab— GitLab REST API v4 client. Full coverage of projects, repository files, atomic multi-file commits, branches, tags, merge requests, pipelines, jobs, releases, issues, groups, container registry, webhooks, environments, deploy tokens, and user endpoints. Supports both private access token and OAuth2 bearer authentication. Enables GitOps automation scripts to read/write repository content, trigger pipelines, manage merge requests, and interact with container registries without external CLI dependencies.
Changed
-
Sub-object OO convention across all 35 stdlib modules. Methods are now grouped by resource into sub-objects instead of flat on the client:
-- Before (flat) c:merge_requests(project, opts) c:create_merge_request(project, opts) -- After (sub-objects) c.merge_requests:list(project, opts) c.merge_requests:create(project, opts)Standard CRUD verbs (
list,get,create,update,delete) are consistent across all resources. This makes the API more intuitive and self-documenting. Modules refactored: gitlab, github, argocd, vault, s3, unleash, grafana, keto, kratos, hydra, rbac, prometheus, alertmanager, traefik, loki, k8s, harbor, temporal, dex, flux, certmanager, eso, crossplane, velero, kargo, gcal, gmail, openclaw, zitadel, postgres. Modules unchanged (no client pattern): healthcheck, oauth2, email_triage, openbao (alias).
[0.9.0] - 2026-04-11
Added
-
Temporal workflow engine — full workflow execution via Lua coroutines.
temporal.worker()now supports both activities and workflows. Each workflow runs as a coroutine with a deterministicctxobject:ctx:execute_activity(name, input, opts?)— schedule activity, block until complete. Supports retry policies, timeouts, heartbeats. On replay, returns cached results without re-executing.ctx:wait_signal(name, opts?)— block until external signal or timeout. Signals are buffered (safe to call after signal arrives).ctx:sleep(seconds)— deterministic timer via Temporal, not wall clock.ctx:side_effect(fn)— run non-deterministic function (IDs, timestamps).ctx:workflow_info()— workflow metadata (id, type, namespace, attempt).
Activities and workflows can be registered together in one worker:
temporal.worker({ url = "temporal-frontend:7233", task_queue = "promotions", activities = { update_gitops = function(input) ... end }, workflows = { PromotionWorkflow = function(ctx, input) local approval = ctx:wait_signal("approve", { timeout = 86400 }) local commit = ctx:execute_activity("update_gitops", input) return { status = "done", commit_id = commit.id } end, }, }) -
markdown.to_html(source)— new builtin for Markdown to HTML conversion via pulldown-cmark. Supports tables, strikethrough, and task lists. Zero binary size overhead (pulldown-cmark was already in the dependency tree via temporalio crates). -
http.serve()wildcard routes — routes ending with/*match any path with that prefix. More specific wildcards take priority:http.serve(8080, { GET = { ["/api/*"] = function(req) ... end, -- matches /api/users/123 ["/*"] = function(req) ... end, -- catches everything else }, }) -
Assay builds its own documentation site.
site/build.luareplaces the bash/awk/npx pipeline. Module count (54) is computed automatically fromsrc/lua/builtins/mod.rsandstdlib/**/*.lua. Site source lives undersite/, build output goes tobuild/site/(gitignored). -
Per-module documentation pages. 36 markdown source files under
docs/modules/are the single source of truth.build.luagenerates individual HTML pages, a module index, andllms-full.txtfor LLM agents. -
site/serve.lua— assay serves its own docs site using wildcard routes. 40 lines of Lua, zero external dependencies. -
fs.read_bytes(path)/fs.write_bytes(path, data)— binary-safe file I/O. Lua strings can hold arbitrary bytes, so these work for images, WASM, protobuf, compressed data, etc. -
Pagefind search — full-text search across all docs pages via Ctrl+K modal. Indexed at build time (~100KB client bundle), runs entirely in the browser.
Changed
-
http.serve()binary response body — responsebodyfield now preserves raw bytes (read viamlua::String) instead of forcing UTF-8 conversion. Binary assets (WASM, images) serve correctly. -
Version bump to 0.9.0 (from 0.8.4).
-
Site source consolidated under
site/(was split acrosssite/,site-partials/,site-static/). -
Nav redesign: no underlines, subtle active page pill, frosted glass header, theme toggle persistence across pages.
-
deploy.ymlupdated:cargo build→assay site/build.lua→ wrangler deploysbuild/site/.
[0.8.4] - 2026-04-11
Added
-
assay.ory.keto— OPL permit support and table-style check().k:check()now accepts a table argument in addition to positional args, making OPL permit checks natural:k:check({ namespace = "command_center", object = "cc", relation = "trigger", subject_id = "user:uuid" })Keto evaluates the OPL rewrite rules and returns true/false — no Lua-side capability mapping needed.
-
k:batch_check(tuples)— check multiple permission tuples in a single call. Returns a list of booleans in the same order. Each entry uses the same table format ascheck(). -
assay.ory.kratos— complete self-service flow coverage. Three flow families that were missing are now implemented:- Registration:
c:submit_registration_flow(flow_id, payload, cookie?)was missing entirely, making the registration API unusable. - Recovery (password reset):
c:create_recovery_flow(opts?),c:get_recovery_flow(id, cookie?),c:submit_recovery_flow(flow_id, payload, cookie?). - Settings (profile/password change):
c:create_settings_flow(cookie),c:get_settings_flow(id, cookie?),c:submit_settings_flow(flow_id, payload, cookie?).
- Registration:
Fixed
-
assay.ory.keto:k:delete()now supports subject_set tuples. Previously onlysubject_idwas passed to the query string, silently ignoring subject_set-based tuples. -
assay.ory.keto:build_query()now URL-encodes parameter values. Previously special characters in subject IDs (e.g.@in email addresses) were passed raw, potentially corrupting the query string. -
assay.ory.kratos:public_post()now handles HTTP 422 responses (Kratos returns 422 for browser flows that need a redirect after successful submission).
[0.8.3] - 2026-04-07
Added
-
assay.ory.rbac— capability-based RBAC engine layered on top of Ory Keto. Define a policy once (role → capability set) and get user lookups, capability checks, and membership management for free. Users can hold multiple roles and the effective capability set is the union, which means proper separation of duties is enforceable at the authorization layer (e.g. an "approver" role can haveapprovewithout also gettingtrigger, even if it's listed above an "operator" role withtrigger).Public surface:
rbac.policy({namespace, keto, roles, default_role?})p:user_roles(user_id)— sorted by rank, highest firstp:user_primary_role(user_id)— for compact UI badgesp:user_capabilities(user_id)— union setp:user_has_capability(user_id, cap)— single checkp:add(user_id, role)/p:remove(user_id, role)— idempotentp:list_members(role)/p:list_all_memberships()p:reset_role(role)— for bootstrap/seed scriptsp:require_capability(cap, handler)— http.serve middleware
-
crypto.jwt_decode(token)— decode a JWT WITHOUT verifying its signature. Returns{header, claims}parsed from the base64url segments. Useful when the JWT travels through a trusted channel (your own session cookie set over TLS) and you just need to read the claims rather than verify them. For untrusted JWTs, verify the signature with a JWKS-aware verifier instead. -
Nested stdlib module loading:
require("assay.ory.kratos")now resolves tostdlib/ory/kratos.lua. The stdlib and filesystem loaders translate dotted module paths into directory paths and try both<path>.luaand<path>/init.lua, matching standard Lua package loading conventions.
Changed
-
BREAKING: Ory stack modules moved under
assay.ory.*. The flat top-levelassay.kratos,assay.hydra, andassay.ketomodules are nowassay.ory.kratos,assay.ory.hydra, andassay.ory.keto. The convenience wrapperrequire("assay.ory")is unchanged and still returns{kratos, hydra, keto, rbac}.Migration: replace
require("assay.kratos")→require("assay.ory.kratos")require("assay.hydra")→require("assay.ory.hydra")require("assay.keto")→require("assay.ory.keto")This is the right architectural shape: Ory-specific modules sit under the
assay.ory.*umbrella alongside the newassay.ory.rbac, leaving room forassay.<other-vendor>.*later without polluting the top-level namespace.
[0.8.2] - 2026-04-07
Added
-
assay.hydralogout challenge methods: completes the OIDC challenge trio (login, consent, logout). When an app calls Hydra's/oauth2/sessions/logoutendpoint withid_token_hintandpost_logout_redirect_uri, Hydra creates a logout request and redirects the browser to the configuredurls.logoutendpoint with alogout_challengequery param. The handler now has SDK methods to process these requests:c:get_logout_request(challenge)— fetch the pending logout request (subject, sid, client, rp_initiated flag)c:accept_logout(challenge)— invalidate the Hydra and Kratos sessions and get back theredirect_toURL pointing at the app'spost_logout_redirect_uric:reject_logout(challenge)— for "stay signed in" UIs that let the user cancel the logout
Symmetric with the existing login/consent challenge methods.
[0.8.1] - 2026-04-07
Fixed
req.paramsnow URL-decodes query string values inhttp.serve. Previously?challenge=abc%3Dproducedreq.params.challenge == "abc%3D", so consumers that re-encoded the value (such asassay.hydra:get_login_request) ended up double-encoding it toabc%253Dand getting a 404 from the upstream service. Values are now decoded withform_urlencoded::parse, so+becomes a space and percent-escapes are decoded correctly. The raw query string remains available asreq.queryfor handlers that need the verbatim form.
[0.8.0] - 2026-04-07
Added
-
Ory stack stdlib modules — full Lua SDK for the Ory identity/authorization stack:
assay.kratos— Identity management. Login/registration/recovery/settings flows, identity CRUD via admin API, session introspection (whoami), schema management.assay.hydra— OAuth2 and OpenID Connect. Client CRUD, authorize URL builder, token exchange (authorization_code grant), accept/reject login and consent challenges, token introspection, JWK endpoint.assay.keto— Relationship-based access control. Relation-tuple CRUD, permission checks (Zanzibar-style), role/group membership queries, expand API for role inheritance.assay.ory— Convenience wrapper that re-exports all three modules, withory.connect(opts)to build all three clients from one options table.
Pure Lua wrappers over the Ory REST APIs. Zero new Rust dependencies — binary size unchanged. Each module follows the standard
M.client(url, opts)pattern with comprehensive@quickrefmetadata forassay contextdiscovery. -
Multi-value response headers in
http.serve: Header values can now be a Lua array of strings, emitting the same header name multiple times. Required forSet-Cookiewhen setting multiple cookies in one response, and for other headers that legitimately repeat (e.g.,Link,Vary,Cache-Control).return { status = 200, headers = { ["Set-Cookie"] = { "session=abc; Path=/", "csrf=xyz; Path=/", }, }, }String values continue to work as before.
Theme
This is the identity and auth stack release. Assay now ships with a complete SDK for building OIDC-integrated apps on Ory: one app can handle Hydra login/consent challenges, query Keto permissions, and manage Kratos identities — all in idiomatic Lua with zero external dependencies beyond the existing assay binary.
[0.7.2] - 2026-04-07
Added
req.paramsinhttp.serve: Query string parameters are now automatically parsed into aparamstable on incoming requests. For example,?login_challenge=abc&foo=barbecomesreq.params.login_challenge == "abc"andreq.params.foo == "bar". The raw query string remains available asreq.query.
[0.7.1] - 2026-04-06
Changed
- Temporal included by default: The
temporalfeature is now part of the default build. The standard Docker image and binary include native gRPC workflow support out of the box. - CI/Release/Docker: Added
protocinstallation to all build environments for gRPC proto compilation.
[0.7.0] - 2026-04-06
Added
- Temporal gRPC client (optional
temporalfeature): Native gRPC bridge for Temporal workflow engine viatemporalio-clientv0.2.0. Thetemporalglobal providesconnect()for persistent clients andstart()for one-shot workflow execution. Client methods:start_workflow,signal_workflow,query_workflow,describe_workflow,get_result,cancel_workflow,terminate_workflow. All methods are async and use JSON payload encoding. Build withcargo build --features temporal— requiresprotoc(install viamise install protoc). - 8 new tests for temporal gRPC registration, error handling, and stdlib compatibility.
Dependencies (temporal feature only)
temporalio-client0.2.0temporalio-sdk0.2.0temporalio-common0.2.0url2.x
[0.6.1] - 2026-04-06
Fixed
- http.serve async handlers: Route handlers are now async (
call_async), allowing them to callhttp.get,sleep, and any other async builtins. Previously, calling an async function from a route handler would crash with "attempt to yield from outside a coroutine". This was the only remaining sync call site for user Lua functions.
Added
npx skills add developerinlondon/assay— install Assay's SKILL.md into your AI agent project via the skills CLI.- Dark/light theme toggle on assay.rs with localStorage persistence.
- Version stamp in site footer — shows git tag or SHA from deploy pipeline.
- Infrastructure Testing highlighted as core capability on the homepage.
Changed
- Site overhaul — compact hero, service grid above the fold with SVG icons, side-by-side size & speed comparison charts, consistent nav across all pages, accurate module coverage (removed misleading "Coming Soon" features).
- Comparison page — renamed from "MCP Comparison", removed out-of-scope entries, shows only domains Assay actually covers.
- README — full size & speed comparison table with all 10 runtimes and cold start times.
[0.6.0] - 2026-04-05
Added
- 6 new stdlib modules (23 -> 29 total):
- assay.openclaw — OpenClaw AI agent platform integration. Invoke tools, send messages, manage
persistent state with JSON files, diff detection, approval gates, cron jobs, sub-agent spawning,
and LLM task execution. Auto-discovers
$OPENCLAW_URL/$CLAWD_URL. - assay.github — GitHub REST API client (no
ghCLI dependency). Pull requests (view, list, reviews, merge), issues (list, get, create, comment), repositories, Actions workflow runs, and GraphQL queries. Bearer token auth via$GITHUB_TOKEN. - assay.gmail — Gmail REST API client with OAuth2 token auto-refresh. Search, read, reply, send emails, and list labels. Uses Google OAuth2 credentials and token files.
- assay.gcal — Google Calendar REST API client with OAuth2 token auto-refresh. Events CRUD (list, get, create, update, delete) and calendar list. Same auth pattern as gmail.
- assay.oauth2 — Google OAuth2 token management. File-based credentials loading, automatic
access token refresh via refresh_token grant, token persistence, and auth header generation.
Used internally by gmail and gcal modules. Default paths:
~/.config/gog/credentials.jsonand~/.config/gog/token.json. - assay.email_triage — Email classification and triage. Deterministic rule-based categorization of emails into needs_reply, needs_action, and fyi buckets. Optional LLM-assisted triage via OpenClaw for smarter classification. Subject and sender pattern matching for automated mail detection.
- assay.openclaw — OpenClaw AI agent platform integration. Invoke tools, send messages, manage
persistent state with JSON files, diff detection, approval gates, cron jobs, sub-agent spawning,
and LLM task execution. Auto-discovers
- Tool mode:
assay run --mode toolfor OpenClaw integration. Runs Lua scripts as deterministic tools invoked by AI agents, with structured JSON output. - Resume mechanism:
assay resume --token <token> --approve yes|nofor resuming paused workflows after human approval gates. - OpenClaw extension:
@developerinlondon/assay-openclaw-extensionpackage (GitHub Packages). Registers Assay as an OpenClaw agent tool with configurable script directory, timeout, output size limits, and approval-based resume flow. Install viaopenclaw plugins install @developerinlondon/assay-openclaw-extension.
Architecture
- Shell-free design: All 6 new modules use native HTTP APIs exclusively. No shell commands, no
CLI dependencies (no
gh, nogcloud, nooauth2l). Pure Lua over Assay HTTP builtins.
[0.5.6] - 2026-04-03
Added
- SSE streaming for
http.servevia{ sse = function(send) ... end }return shape. SSE handler runs async sosleep()and other async builtins work inside the producer.sendcallback uses async channel send with proper backpressure handling. Custom headers take precedence over SSE defaults (Content-Type, Cache-Control, Connection). - assert.ne(a, b, msg?) — inequality assertion for the test framework.
Fixed
- Content-Type precedence: User-provided
Content-Typeheader no longer overwritten by defaults (text/plain/application/json) inhttp.serveresponses. - SSE newline validation:
eventandidfields reject values containing newlines or carriage returns to prevent SSE field injection.
[0.5.5] - 2026-03-13
Added
- follow_redirects option for YAML HTTP checks. Set
follow_redirects: falseto disable automatic redirect following, allowing verification of auth-protected endpoints that return 302 redirects to identity providers. Defaults totruefor backward compatibility. - follow_redirects option for Lua
http.client()builder. Create clients withhttp.client({ follow_redirects = false })for the same no-redirect behavior in scripts.
[0.5.4] - 2026-03-12
Fixed
- unleash.ensure_token: Send
tokenNameinstead ofusernamein create token API payload. The Unleash API expectstokenName— sendingusernamecaused HTTP 400 (BadDataError). Function now accepts bothopts.tokenNameandopts.usernamefor backward compatibility. Existing token matching also checkst.tokenNamewith fallback tot.username.
[0.5.3] - 2026-03-12
Added
- disk builtins:
disk.usage(path)anddisk.mounts()for filesystem disk information - os builtins:
os.info()returning name, version, arch, hostname, uptime - Expanded fs builtins:
fs.exists,fs.is_dir,fs.is_file,fs.list,fs.mkdir,fs.remove,fs.rename,fs.copy,fs.stat,fs.glob,fs.temp_dir - Expanded env builtins:
env.set,env.unset,env.list,env.home,env.cwd
Fixed
- Cross-platform casts in
disk.rs(u32on macOS,u64on Linux)
[0.5.2] - 2026-03-11
Added
- shell builtins:
shell.run(cmd),shell.output(cmd),shell.which(name),shell.pipe(cmds) - process builtins:
process.spawn(cmd, opts),process.kill(pid),process.pid(),process.list(),process.sleep(secs) - Expanded fs builtins:
fs.read_bytes,fs.write_bytes,fs.append,fs.symlink,fs.readlink,fs.canonicalize,fs.metadata
Fixed
http.serveport race condition — use ephemeral ports with_SERVER_PORTglobal- Symlink safety, timeout validation, pipe drain, PID validation hardening
[0.5.1] - 2026-02-23
Added
- Website: Static site at assay.rs on Cloudflare Pages with homepage, module reference, AI agent integration guides, and MCP comparison page mapping 42 servers
- llms.txt: LLM agent context traversal files (
llms.txtandllms-full.txt) - Enriched search keywords: All 23 stdlib modules and builtins enriched with
@keywordsmetadata for improved discovery
Changed
- Updated README with website links
- Updated SKILL.md with MCP comparison and agent integration guidance
[0.5.0] - 2026-02-23
Added
- CLI subcommands:
assay execfor inline Lua execution,assay contextfor prompt-ready module output,assay modulesfor listing all available modules - Module discovery: LDoc metadata parser with auto-function extraction from all 23 stdlib modules
- Search engine: Zero-dependency BM25 search with FTS5 backend for
dbfeature - Filesystem module loader: Project/global/builtin priority for
require()resolution - LDoc metadata headers: All 23 stdlib modules annotated with
@module,@description,@keywords,@quickref
Changed
- CLI restructured to clap subcommands with backward compatibility
- Feature flags added for optional
db,server, andclidependencies
[0.4.4] - 2026-02-20
Added
- Unleash stdlib module (
assay.unleash): Feature flag management client for Unleash. Projects (CRUD, list), environments (enable/disable per project), features (CRUD, archive, toggle on/off), strategies (list, add), API tokens (CRUD). Idempotent helpers:ensure_project,ensure_environment,ensure_token.
[0.4.3] - 2026-02-13
Added
- crypto.hmac: HMAC builtin supporting all 8 hash algorithms (SHA-224/256/384/512,
SHA3-224/256/384/512). Binary-safe key/data via
mlua::String. Supportsrawoutput mode for key chaining (required by AWS Sig V4). Manual RFC 2104 implementation using existing sha2/sha3 crates — zero new dependencies. - S3 stdlib module (
assay.s3): Pure Lua S3 client with AWS Signature V4 request signing. Works with any S3-compatible endpoint (AWS, iDrive e2, Cloudflare R2, MinIO). Operations: create/delete bucket, list buckets, put/get/delete/list/head/copy objects, bucket_exists. Path-style URLs default. Epoch-to-UTC date math (no os.date dependency). Simple XML response parsing via Lua patterns. - 15 new tests (7 HMAC + 8 S3 stdlib)
Changed
- Modular builtins: Split monolithic
builtins.rs(1788 lines) intosrc/lua/builtins/directory with 10 focused modules: http, json, serialization, assert, crypto, db, ws, template, core, mod. Zero behavior change — pure refactoring for maintainability.
[0.4.2] - 2026-02-13
Fixed
- zitadel.find_app: Improved with name query filter and resilient 409 conflict handling
[0.4.1] - 2026-02-13
Fixed
- zitadel.create_oidc_app: Handle 409 conflict responses gracefully
[0.4.0] - 2026-02-13
Added
- Zitadel stdlib module (
assay.zitadel): OIDC identity management with JWT machine auth - Postgres stdlib module (
assay.postgres): Postgres-specific helpers - Vault enhancements: Additional vault helper functions
- healthcheck.wait: Wait helper for health check polling
Fixed
- Use merge-patch content-type in
k8s.patch
[0.3.3] - 2026-02-12
Added
- Filesystem require fallback: External Lua libraries can be loaded via filesystem
require()
Fixed
- Load K8s CA cert for in-cluster HTTPS API calls
[0.3.2] - 2026-02-11
Added
- crypto.jwt_sign:
kid(Key ID) header support for JWT signing
Fixed
- Release workflow: Filter artifact download to exclude Docker metadata
[0.3.1] - 2026-02-11
- Publish crate as
assay-luaon crates.io (binary still installs asassay) - Add release pipeline: pre-built binaries (Linux x86_64 static, macOS Apple Silicon), Docker, crates.io
- Add prerequisite docs to K8s-dependent examples
- Fix flaky sleep timing test
[0.3.0] - 2026-02-11
First feature-complete release. Assay is now a general-purpose Lua runtime for Kubernetes — covering verification, scripting, automation, and lightweight web services in a single ~9 MB binary.
Added
- Direct Lua execution:
assay script.luawith auto-detection by file extension - Shebang support:
#!/usr/bin/assayfor executable Lua scripts - HTTP server:
http.serve(port, routes)— Lua scripts become web services - Database access:
db.connect/query/execute— PostgreSQL, MySQL/MariaDB, SQLite via sqlx - WebSocket client:
ws.connect/send/recv/closevia tokio-tungstenite - Template engine:
template.render/render_stringvia minijinja (Jinja2-compatible) - Filesystem write:
fs.write(path, content)complements existingfs.read - YAML builtins:
yaml.parse/encodefor YAML processing in Lua scripts - TOML builtins:
toml.parse/encodefor TOML processing in Lua scripts - Async primitives:
async.spawn(fn)andasync.spawn_interval(ms, fn)with handles - Crypto hash:
crypto.hash(algo, data)— SHA-256, SHA-384, SHA-512, SHA3-256, SHA3-512 - Crypto random:
crypto.random(length)— cryptographically secure random hex strings - JWT signing:
crypto.jwt_sign(claims, key, algo)— RS256/RS384/RS512 - Regex:
regex.match/find/find_all/replacevia regex-lite - Base64:
base64.encode/decode - 19 stdlib modules: prometheus, alertmanager, loki, grafana, k8s, argocd, kargo, flux, traefik, vault, openbao, certmanager, eso, dex, crossplane, velero, temporal, harbor, healthcheck
- E2E dogfood tests: Assay testing itself via YAML check mode
- CI: GitHub Actions with clippy + tests on Linux (x86_64) and macOS (Apple Silicon)
- 491 tests, 0 clippy warnings
Changed
- CLI changed from
assay --config file.yamltoassay <file>(positional arg, auto-detect) - Lua upgraded from 5.4 to 5.5 (global declarations, incremental major GC, compact arrays)
- HTTP builtins DRYed (collapsed 4x duplicated method registrations into generic loop)
[0.0.1] - 2026-02-09
Initial release. YAML-based check orchestration for ArgoCD PostSync verification.
Added
- YAML config with timeout, retries, backoff, parallel execution
- Check types:
type: http,type: prometheus,type: script(Lua) - Built-in retry with exponential backoff
- Structured JSON output with pass/fail per check
- K8s-native exit codes (0 = all passed, 1 = any failed)
- HTTP client builtins:
http.get/post/put/patch - JSON builtins:
json.parse/encode - Assert builtins:
assert.eq/gt/lt/contains/not_nil/matches - Logging builtins:
log.info/warn/error - Environment:
env.get,sleep,time - Prometheus stdlib module
- Docker image: Alpine 3.21 + ~5 MB binary