Upgrade to 1.6.0
This page is for operators upgrading an existing Orion deployment from 1.5.x to 1.6.0. It covers only what changes behaviour.
The two new capabilities are described in full in the CHANGELOG. They are plugins, custom task functions in a WebAssembly sandbox, and cron channels, a workflow that runs on a schedule instead of on a request.
1.6.0 is a minor release and behaves like one. No config key was renamed or removed, no API path moved, and no metric was renamed. Nine things can reach you. Four of them can change what an existing deployment already does — §4, §5, §6 and §7 — and the rest are additive.
The version-independent procedure — back up, preflight, validate config, migrate, roll — is on Upgrades.
Before you start
| # | Check | Applies to you if |
|---|---|---|
| 1 | Run the migrations as a deploy step | You run a cluster with auto_migrate = false — everyone else gets them at startup |
| 2 | Nothing — plugins are off | Every deployment; read it to know what turning them on will mean |
| 3 | Decide whether this node runs schedules | Every deployment, and especially a cluster: every node must agree |
| 4 | Check your db_read statements are reads | You use db_read — a statement that writes now fails |
| 5 | Re-run validate-config | You run single-node — a check that was skipped now runs |
| 6 | Move trace reads to the header | Anything reads a trace with ?token= |
| 7 | Re-check numeric columns | You read PostgreSQL numeric, binary or previously failing column types |
| 8 | Allow for a ~6 MB larger binary and image | You pin image sizes, or build from source on a constrained host |
| 9 | Note the MSRV policy | You build from source |
orion-server preflight gained two things for this release. It now reads the active plugins. A stored workflow calling a plugin function is checked against the plugin’s manifest, rather than reported as naming an unknown function. Its report also has two sections now, of which only the first gates the exit code. The engine’s advisories are reported with their own check id so a pipeline can grandfather one without silencing the rest. A clean run on 1.5.x is a clean run on 1.6.0.
1. Nine migrations, expand-only
What changed. Three new migrations per backend. plugins adds two tables, plugins and plugin_artifacts. plugin_signatures adds one nullable column on plugins. cron_scheduling adds cron_schedule_state, cron_occurrences and cron_singletons. They add schema and touch nothing that exists, so a 1.5.x binary keeps working against a migrated database and a rollback needs no schema work.
What to do. Nothing, unless storage.auto_migrate = false: then
orion-server migrate is the deploy step, as it always is in cluster mode. On MySQL, the artifact table’s bytes column is a LONGBLOB. If you later enable plugins, make sure the server’s max_allowed_packet exceeds plugins.max_component_bytes (16 MiB by default), or an upload fails at write.
orion-server migrate --dry-run -c config.toml # names all nine by backend
The plugin and cron tables stay empty until a plugin or a cron channel is activated.
2. Plugins are off by default
What changed. The plugin sandbox exists in every binary, and
plugins.enabled defaults to false. With it off, no Wasmtime engine is constructed, no epoch ticker runs, POST /api/v1/admin/plugins answers 400. A plugin row can still reach this node’s database, through a cluster peer’s activation or an import. It becomes a disabled load issue that quarantines the workflows naming its functions, never an abort.
What to do. Nothing. When you turn it on, read the production checklist row. The pooling allocator reserves max_live_instances × max_memory_bytes of virtual address space at startup, 16 GiB by default. Signing keys go in [plugins.trust].
3. [cron] is on by default, and that is deliberate
What changed. The new [cron] section defaults to enabled = true with a
one-second poll. On an instance with no cron channels that costs one indexed query per second and nothing else. The reconciler short-circuits when there is nothing scheduled.
Turning it off is a real choice with a visible consequence:
[cron]
enabled = false
An active cron channel on a node with the scheduler off is
quarantined: refused at load, listed under channels.quarantined on
/health, and reported as components.cron: degraded. Activating one is refused outright. That is on purpose — a stored, active schedule that silently never fires is the one failure an operator has no way to notice.
Drafts, imports, exports and reads are unaffected, so an instance with the scheduler off is still a place to author and promote schedules.
What to do. Nothing to run. Decide whether this deployment should run schedules, and make every node in a cluster agree. A mixed cluster quarantines the channel on the nodes that have it off and runs it on the rest. That works, but is not what anyone meant. See Scheduled Channels for every setting.
Terminal occurrences age out with traces, on the trace_queue.retention_hours schedule — one retention decision, not two. You never read an occurrence whose trace is gone. Occurrences still pending are never deleted, however old: that is a backlog, not history.
4. db_read refuses a statement that is not a read
What changed. db_read gated on the connector’s read operation and then
handed the statement to the driver, which executes whatever it is given. DELETE FROM audit_log RETURNING id therefore ran on PostgreSQL and SQLite. A bare DELETE, UPDATE or INSERT ran on all three, with raw_write: false and delete: false set on the connector. The gates advertised more than they enforced.
A db_read statement must now open with SELECT, WITH, VALUES or TABLE. EXPLAIN is not admitted (EXPLAIN ANALYZE DELETE … executes the delete) and neither is PRAGMA, which writes on SQLite. A WITH carrying a data-modifying CTE is refused by shape. Comments and quoted strings are stripped before the check, so WHERE note = 'delete me' is an ordinary read and SELECT … FOR UPDATE is an ordinary locking read.
What to do. Grep your stored workflows for db_read tasks whose statement is not a read. Move them to db_write or data_write: they now fail at first traffic instead of quietly writing.
orion-cli workflows export --status active \
| jq -r '.. | objects | select(.name? == "db_read") | .input.statement'
The served field description was wrong on the same line and is now fixed. It gave PostgreSQL’s $1, $2 as the placeholder spelling for all three backends, where SQLite and MySQL use ?. If your tooling reads GET /api/v1/admin/functions, it was being told the wrong thing.
5. validate-config checks the storage URL scheme everywhere
What changed. The check that storage.url carries a supported scheme sat inside a condition that also required cluster mode. && therefore short-circuited it away for every single-node deployment. validate-config exited 0 on a URL the server then died at boot for.
The check now belongs to [storage] and runs unconditionally.
What to do. Run orion-server validate-config -c config.toml before
rolling. A config that validated on 1.5.x and can never boot will now say so at the point you can still fix it.
Related, and additive: config values may now be env://NAME references, so [storage] url = "env://ORION_STATE_DB_URL" resolves at load and keeps its type. ${VAR} text substitution still works as before. An unset variable is a hard error naming both the variable and the field.
6. The ?token= query parameter is deprecated
What changed. A trace read returns the submission’s full result. The capability that authorises it can travel in the x-trace-token header or in the URL. The query parameter leaks: it reaches browser history, reverse proxy and CDN access logs, analytics, and the Referer of whatever the page loads next. The header reaches none of them.
Removing it would break a documented surface on a 1.x server. It is deprecated rather than removed: still accepted, answered with Deprecation: true, and counted by orion_trace_token_query_reads_total. Trace responses on both lanes now also answer Cache-Control: no-store — nothing previously stopped a shared cache from storing the body.
What to do. Move callers to the header, and alert on the counter reaching zero. That is what makes the eventual removal safe to schedule.
curl -H "x-trace-token: $TOKEN" "$ORION/api/v1/data/traces/$TRACE_ID"
7. SQL connectors decode on the real driver
What changed. Connector queries ran on sqlx::AnyPool, whose type layer
has nine variants and errors on anything it cannot spell. Ten PostgreSQL types failed the task with a 500 before Orion’s decoder ran: uuid, numeric, timestamptz, timestamp, date, json, jsonb, arrays, enums and inet. The failure was per row: a query passed every test against an empty table and failed the first time production had data. db_read, data_query and data_write’s returning share the decoder and all three inherited it. Four more arms (including MySQL’s BOOLEAN/TINYINT(1)) were keyed on type names sqlx does not agree with, and answered 400 on every row.
Connector pools now dispatch to the concrete PostgreSQL, MySQL or SQLite driver, the way Orion’s own database has since 1.0.
What to do. Two representation choices are worth checking if you read these columns:
numericnow decodes to a JSON number by default. Arbitrary precision has no JSON equivalent and JSONLogic computes inf64, so setnumeric_as: "string"on a money column to keep every digit.- Binary columns (
bytea,blob,varbinary) had their shape decided by the value: text when the bytes happened to be valid UTF-8, lowercase hex when they were not, with nothing distinguishing the two.binary_asnow names the shape explicitly. A workflow that hex-decodes such a column should declare it.
8. The binary carries Wasmtime
What changed. Wasmtime and Cranelift are compiled into every target,
adding roughly 6 MB to the release binary and to the container images. They are inert until plugins.enabled = true.
What to do. Nothing, unless a size budget is pinned somewhere. cargo deny check now allows Apache-2.0 WITH LLVM-exception, which Wasmtime and Cranelift carry.
9. The MSRV now tracks Wasmtime’s
What changed. The minimum supported Rust version is still 1.98, but the rule behind it changed. It now also follows Wasmtime’s policy, stable minus two, so a Wasmtime upgrade in a future minor may move it. Support & Compatibility states the policy.
What to do. Nothing for the released binaries and images. Building from
source, keep the toolchain at the rust-version the checked-out release declares.
New surface, all additive
Nothing here is removed and no existing field changes meaning.
| Added | Notes |
|---|---|
protocol: "cron" | A fourth ChannelProtocol. Older clients that pattern-match on the enum should already tolerate unknown values; orion-api deserialization is tolerant by design |
mode: "cron" on traces | An open string, like kafka. Filter with ?mode=cron |
GET/POST /api/v1/admin/cron/... | The occurrence ledger. See Cron occurrences |
POST /api/v1/admin/channels/{id}/trigger | A manual run of a cron channel, through the same claim and singleton |
/api/v1/admin/plugins | The plugin entity: upload, validate, activate, archive, export |
orion-cli cron, orion-cli channels trigger, orion-cli plugins | The CLI side of all three |
components.cron on /health | Present only when the node has something to say about schedules |
Seven orion_cron_* metrics | Scheduling |
"count": true on data_query | A total for a paginated endpoint, on all five backends |
last_insert_id from db_write | On MySQL and SQLite, for an INSERT/REPLACE, as data_write already reported |
halt_on on a task | Ends the workflow when that task failed — the outcome axis to terminal’s position axis |
config.oauth2_login on a channel | Inbound OAuth2 / OIDC sign-in: the redirect, the state cookie, PKCE and the code exchange, with the grant at metadata.oauth |
retry_safety on GET /api/v1/admin/functions | Whether a task function is safe to run twice |
If you deploy in a cluster
Every node runs its own cron reconciler and workers. Coordination is entirely through the three cron tables: occurrence identity is (channel_id, scheduled_for), claims are leased with the database clock. A forbid singleton is a row that one occurrence holds at a time. There is no leader to elect and nothing new to configure — but every node must agree on cron.enabled (§3) and on plugins.enabled (§2).
Related
- Plugins, Build a Plugin and the Plugins reference.
- Run work on a schedule: authoring a cron channel and reading its occurrences. and the cron transport.
- Deploy a cluster: the migrate step, and what a mixed fleet does to a cron channel..
- Upgrades: the version-independent procedure.
Last verified 14 September 2026