Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Starf An activity monitor for Apple silicon that never invents a number etrans An SSH and SFTP client for macOS Elyra VM Virtual machines for macOS, Linux and Windows on your Mac Litr A small, native web browser for macOS Notr A notebook for macOS e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Refr Local-first PDF workspace for macOS Elyra Workspace A desktop workspace for coding agents Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Release notes
Changelog
Elyra

Changelog

All notable changes to Elyra Framework are documented here. The format is based on Keep a Changelog, and the project adheres to Semantic Versioning.

While Elyra is pre-1.0, minor versions may contain breaking changes; these are called out under Changed with a migration note.

Unreleased

0.11.0 — 2026-10-10

The resource templates release. A live command that takes arguments is now something an AI agent can watch, not only call. customers_show(id) becomes app://customers_show/{id}, and customers_index(query) becomes app://customers_index{?direction,page,per_page,search,sort}. The agent fills in the values, reads the result, and subscribes to it. It's then told when that customer, or that search, changes, the way a window is. See the guide and RFC 0004.

Upgrading: nothing to change. Templates appear on their own for live commands an agent is already granted. They add no reach: the agent could already call each one as a tool with any arguments.

Added

  • MCP resource templates (RFC 0004).
    • A granted #[command(live)] that takes arguments gets a template. The command must not need confirmation, and each argument must be a scalar, or a struct of scalars, one level deep.
    • The template is read off the tool's input schema. Required variables are path segments, in argument order: app://customers_show/{id}. Optional ones are the query, with a struct's fields in name order: app://customers_index{?direction,page,per_page,search,sort}.
    • resources/templates/list lists them. rata mcp inspect shows each template, and for a live command without one, why: a list, a nested object, two variables with one name.
    • An expanded URI reads and subscribes like a plain resource: resources/read, subscriptions/listen and resources/subscribe take app://customers_show/12 or app://customers_index?search=ada. A subscription is told only when what its arguments select changes.
    • The values are percent-decoded (+ is a plus) and converted by the variable's schema type: an integer, a number, true / false, or one of an enum's values.
    • These are -32602 "Invalid arguments", with what's wrong per variable in data.errors: a value that doesn't convert, a parameter the template doesn't name, a repeated one, the wrong number of path segments, or a URI over 2 KiB. These are caught before the command runs. A failure of the command's own validation is answered the same way, with its messages. A subscription to such a URI isn't acknowledged.
    • completion/complete suggests a template variable's values from its schema: an enum's values, or true / false, filtered by the typed prefix (case-insensitively). The server declares completions.
    • The example app's filter_todos(done) is a template, app://filter_todos{?done}. The CI Inspector check reads app://filter_todos?done=false, and is refused ?done=maybe. The guide has a resource templates section.

0.10.2 — 2026-10-10

A hardening release. Single-instance proves its per-install token instead of sending it, so a process that takes the endpoint first learns nothing, and unique / exists fail the field instead of panicking when the database can't be queried.

Upgrading: nothing to change in code. The single-instance protocol is new (ELYRA-SI/2), so a 0.10.2 build launched while an older one is open starts its own window instead of handing over; quit the old one first. Translation files gain one key, validation.unavailable; until it's translated, the English message is used.

Fixed

  • Single-instance no longer sends its token in the clear. The second launch sent the per-install token to whatever answered on the endpoint. On Windows that's a loopback port another process can take first. It could learn the token there, then inject deep links into the real app. The handshake is now a challenge-response: HMAC-SHA256 over fresh nonces from both sides, the primary first, with the payload covered by the client's proof. It's the scheme the MCP endpoint uses, now shared (proof).
    • An impostor sees neither the token nor the payload.
    • The protocol is ELYRA-SI/2. A launch of the new version while an older one is running doesn't reach it, and starts on its own; quit the old one first.
  • unique / exists no longer panic when their query fails (a locked database, a dropped connection, a missing table). The field fails with the new message key unavailable ("The email could not be checked right now. Try again."), and the reason is logged under elyra::validation. The input isn't let through. A count that couldn't be read used to pass unique silently; it now fails the same way.

0.10.1 — 2026-10-05

A fix release for migrations. rata migrate now runs the Rust migrations that make:resource writes, and a rollback no longer forgets a migration of the other kind. See migrations.

Upgrading: nothing to do for most apps. ELYRA_MIGRATE=up now applies the pending SQL files in migrations/ too, in the same batch, and prints its summary rather than logging it. elyra_db::Error has a new variant, UnknownMigration, which an exhaustive match on it needs an arm for. Migrator::rollback now refuses a batch that holds a Rust migration.

Fixed

  • rata migrate runs Rust migrations. The resources make:resource generates, and make:migration --rust, are compiled into the app, so rata migrate used to answer "Nothing to migrate." and create no tables. Now, when src/ has a RustMigration, rata migrate, migrate:status and migrate:rollback run the app in its migrate mode, which applies both kinds in one batch. A project with only SQL files is migrated directly, as before.
  • A rollback no longer forgets what it can't undo. SQL and Rust migrations share one history table and batches, but each rollback only knew its own kind. It dropped the other kind's records without running their down, leaving the tables in place and the next migrate failing on them. That was rata migrate:rollback with Rust migrations in the batch, or ELYRA_MIGRATE=down with SQL files in it. Now every migration in the batch runs its own down. A batch holding one that's neither a file nor registered isn't rolled back: the new elyra_db::Error::UnknownMigration.

Added

  • Migrator::run_all, rollback_all and status_all handle the SQL files and the registered Rust migrations together. ELYRA_MIGRATE=status lists them, and ELYRA_MIGRATIONS_DIR points the app's migrate mode at the SQL files (default migrations).

Changed

  • ELYRA_MIGRATE=up applies the pending SQL files too, in the same batch as the Rust migrations, and the mode prints what it did (as rata migrate does) instead of logging it. Migrator::rollback_rust is now rollback_all. Migrator::rollback refuses a batch with a Rust migration in it, since it can't run that migration's down.
  • elyra_db::Error has a new variant, UnknownMigration. An exhaustive match on it needs an arm.

0.10.0 — 2026-10-05

The AI agents release: every Elyra app can be an MCP server. You grant the abilities an agent may use, and the commands behind them become tools for Claude, Cursor or VS Code, with JSON Schemas built from their types. They run through the same middleware and validation as a frontend call. The client launches myapp --mcp. When the app is open, the agent works inside it, and the windows update as it goes. When it isn't, the app runs without a window. What can't be undone waits for the user to confirm it in the client. Live commands are resources an agent can watch. Every call is logged, dispatched as an event, and shown on a channel for an in-app indicator. See the guide and RFC 0003.

Upgrading: one break, small. CommandRequest has a new field, origin, so code that builds one by hand must set it: origin: Origin::Frontend. Middleware that only reads a request is unaffected. Everything else is new API: nothing is exposed to an agent until the app calls App::mcp(..).

Added

  • An MCP tool catalog — step 1 of RFC 0003 (every Elyra app is an MCP server).
    • App::mcp(Mcp::new().allow_abilities([..]).confirm(..)) chooses which commands an AI agent may call: those whose can ability it grants, in a grant separate from the frontend's. Commands without an ability never are.
    • elyra::mcp::catalog turns them into tool definitions. The doc comment becomes the description, and JSON Schemas (2020-12) for the arguments and the result are built from the specta types after serde's rewrites (renames, tagging, flattening). Option fields may be left out of an input.
    • rata mcp inspect [--json] lists the tools.
    • #[command] now records its doc comment (Command::description()).
  • An MCP server — step 2 of RFC 0003.
    • McpServer speaks JSON-RPC 2.0 over the stdio framing, one message per line, for both protocol eras:
      • 2026-07-28: per-request _meta, server/discover, resultType, and ttlMs / cacheScope on tools/list; an unsupported version gets -32022 with the supported list.
      • the initialize-based 2025-11-25 / 2025-06-18 / 2025-03-26, for the connections that open with it.
    • tools/call maps the named arguments onto the command's positional ones (a left-out Option is null) and dispatches through the full middleware pipeline. The value comes back as structuredContent plus text. A validation bag, a command error, a panic, an unknown argument or a type mismatch is a tool error (isError) the model can act on, with the field messages.
    • ELYRA_MCP=stdio serves it headless, without a window, using App::database or DATABASE_URL. (Since step 3, it's the same as --mcp.)
    • TestApp::mcp() is an in-process client, with list_tools, call, request and raw.
    • Checked with the official MCP Inspector CLI against a generated app: listing tools, and a call that wrote a row.
  • myapp --mcp, the MCP shim — step 3 of RFC 0003. It's what an MCP client launches.
    • When the app is running, the shim pipes MCP to it, so the agent works in the open app: the same database, live queries and events, and the windows update as it goes.
    • When it isn't, the shim serves MCP headless, as ELYRA_MCP=stdio did.
    • A running app with App::mcp serves a private endpoint for it: a 0600 Unix socket beside single-instance's, or loopback ports of its own on Windows.
    • A connection is served only after a challenge-response on the per-install token: HMAC-SHA256 over fresh nonces from both sides, with the server proving itself first. The token never crosses the wire, so a process that takes the socket path or a port first learns nothing, and the shim runs headless instead.
    • --mcp is checked before single-instance, which would otherwise hand the launch to the running app and exit.
  • Confirmation in the MCP client — step 4 of RFC 0003. A tool whose ability Mcp::confirm names runs only once the user says yes.
    • A 2026-07-28 client gets a multi round-trip: input_required with an elicitation/create and a requestState, and the call runs when the client retries with the user's accept.
    • The question names the client and the tool, and gives the first line of the command's doc comment and the arguments.
    • The state is signed with a key that lives only in the process (HMAC). It's bound to the tool, the arguments and the client, and expires after 5 minutes. It runs the call at most once, so an accepted delete can't be replayed; a state that doesn't fit is asked again.
    • A legacy client that declared elicitation (2025-06-18 or later) is asked with a server-initiated elicitation/create.
    • A client that can't elicit gets a tool error, and the tool doesn't run. So does a decline, a dismissal, or no answer within 10 minutes.
    • TestApp::mcp().confirming("accept" | "decline" | "cancel") answers as the user would. ToolResult::confirmation is the question that was asked.
  • Live commands as MCP resources — step 5 of RFC 0003.
    • A granted #[command(live)] that can run with no arguments given is a resource, app://<command>. That means every argument is an Option, or a struct whose fields all are (it's read with {}). A command that needs confirmation isn't one. resources/list lists them, and resources/read runs one as the agent, with ttlMs: 0.
    • subscriptions/listen with resourceSubscriptions follows them in the live registry. The client gets notifications/resources/updated, carrying the subscription's id, whenever the live registry would push a new result to a window: coalesced, only when it changed, only for what it read. The acknowledgement lists the URIs it honors. notifications/cancelled ends it, and so does the connection closing.
    • Legacy clients use resources/subscribe / resources/unsubscribe.
    • Through the shim, one agent sees another's writes, and the window's, because they share the running app's live registry. Two headless processes don't.
    • LiveRegistry re-runs carry the subscriber's Origin.
    • TestApp::mcp() gains read, listen and next_message, and Connection::outbox drives a connection by hand.
  • Seeing what an agent does, and holding it back — step 6 of RFC 0003.
    • Every tool call and resource read dispatches AgentCalled { tool, client, ok, millis } in the background, for an audit log (App::listen). It doesn't carry the arguments, which may be personal data.
    • The elyra:mcp channel carries a started and a finished (with ok) to every window. onAgent(handler) in @elyra/runtime subscribes to it, for a "Claude is adding a customer…" indicator.
    • Tools are rate limited per tool, across connections: 60 calls a minute by default (DEFAULT_RATE_LIMIT). Mcp::rate_limit(ability, max, per) changes that for an ability, a namespace or *; the last limit that matches wins. A throttled call is a tool error, refused before the user is asked to confirm it. Reads count; re-runs for a subscription don't.
  • Connecting a client, and the guide — the rest of step 6.
    • rata mcp install [--client claude|cursor|vscode] [--release] builds the app and prints the config that has the client launch it with --mcp (the claude mcp add line too). It edits nothing.
    • rata mcp inspect marks the tools that are resources with their URI.
    • The example app is an MCP server: its todos are tools, list_todos is a resource, deleting asks the user first, and the window toasts what an agent does.
    • CI drives it with the official MCP Inspector CLI (scripts/mcp-inspector.sh).
    • docs/mcp.md is the guide.
  • CommandRequest::origin — Origin::Frontend, or Origin::Agent { client } for an MCP call, so middleware can tell them apart. CommandRegistry::dispatch_from dispatches with one.

Changed

  • CommandRequest has a new field, origin. Code that builds a CommandRequest by hand needs to set it (origin: Origin::Frontend). Middleware that only reads name and args is unaffected.

0.9.1 — 2026-10-05

A fix release: the JSON cast now works with Postgres JSONB columns, so a cast = "json" field can use the schema builder's t.json() on every driver.

Upgrading: nothing breaks. An existing TEXT column for a JSON cast keeps working unchanged, and so do custom casts, since Cast's new methods have defaults. To move a Postgres column to JSONB, write a migration that alters it (ALTER TABLE … ALTER COLUMN … TYPE JSONB USING …::jsonb); nothing does it for you.

Changed

  • make:resource --generate makes a json field's column with t.json — JSONB on Postgres, TEXT elsewhere — instead of t.text, now that the JSON cast works with JSONB. Existing migrations are unchanged.

Fixed

  • The JSON cast works with Postgres JSONB columns. A cast = "json" field on a t.json() column (JSONB on Postgres) couldn't be written — "column is of type jsonb but expression is of type text" — or read: the Any driver rejects the whole row with "does not support the Postgres type Jsonb". On Postgres the cast now converts in SQL both ways, with CAST(CAST($n AS TEXT) AS JSON) when writing and CAST(col AS TEXT) when reading. That works for a TEXT column too, which keeps its JSON exactly as written.
    • Cast gains select and bind, with defaults, so a custom cast can do the same; existing casts compile unchanged.
    • Covered on Postgres 16, MySQL 8 and SQLite, against a mix of t.json and t.text columns: insert, find, update to NULL, get, paginate and first.

0.9.0 — 2026-10-05

The live queries release. A #[command(live)] is a command the frontend subscribes to. Elyra records the tables it reads, re-runs it when a write changes one, and pushes the new result to every window watching — written in that window, another one, or a background job. On the frontend it's one store, live.customers_index(query), typed by codegen. rata make:resource now generates live lists and detail views, so a customer added in one window shows up in the other without a reload. See the live queries guide and RFC 0002.

Upgrading: nothing breaks. Command::live() defaults to false, and the rest is new API. Resources generated with 0.8 keep their non-live views. To get live ones, regenerate the resource the way you made it (this overwrites its files):

  • made with --generate: rata make:resource <Model> --generate <the same fields> --force. The migration keeps its version.
  • built on your own model: rata make:resource <Model> --view --force.

Or mark <plural>_index / <plural>_show #[command(live)] and read them through live.* by hand.

Added

  • Change tracking on Database — step 1 of RFC 0002 (live queries).

    • db.changes().subscribe() receives the table of every model-layer write, once it's durable: the generated insert / update / delete, the bulk writes when a row changed, factories, and the pivot operations.
    • db.touch(key) reports raw SQL or any other key ("settings:theme").
    • Inside transaction, reports go out on commit, never on rollback.
    • elyra::db::live::track records the tables a future reads, including joins and relations; depends_on declares the rest. With a label, it refuses writes, which is what a live command's re-run will use.

    See docs/database.md.

  • Live commands — step 2 of RFC 0002. The frontend can subscribe to a #[command(live)] command, and Elyra re-runs it when what it read changes and pushes the new result to that window.

    • Subscribing: POST /__live/<command> (guarded like /__cmd/, including can) answers { id, value }. Updates arrive on elyra:live:<id>, and POST /__live-stop ends a subscription.
    • Re-runs: a write to a table the command read, or a ctx.invalidate(key) of a key it declared with ctx.depends_on, re-runs it through the middleware pipeline. Changes are coalesced over one frame (App::live_batch_window), and nothing is pushed when the result is unchanged.
    • Limits: a re-run that writes fails, and a window holds at most 64 subscriptions.
    • Also new: EventBus::emit_to, and TestApp::live / LiveHandle for testing it.
  • live.* on the frontend — step 3 of RFC 0002. Codegen emits a typed store per #[command(live)] (live.customers_index(query): LiveStore<Page<Customer>>), over live() in @elyra/runtime.

    • The store's value is Live<T>: { value, error, loading }. A failed re-run keeps the last value.
    • The subscription opens with the first listener and closes with the last. An answer that arrives after everyone has left is stopped rather than leaked.

    See docs/frontend-runtime.md.

  • make:resource generates live views — step 5 of RFC 0002.

    • Commands: <plural>_index and <plural>_show are #[command(live)].
    • Views: Index.svelte and Show.svelte read from live.*. A customer added, edited or deleted in another window shows up without a reload, and the list's manual reload bookkeeping is gone. The form still reads once, so it never overwrites what's being typed.
    • Tests: the generated tests check that the list and the record update after a write.
    • Smoke test: the browser harness's fake backend serves /__live, and checks that another window's write reaches the list and the detail view.
  • The live queries guide — step 6 of RFC 0002, which is now implemented. The example app's todo list is a live query: open a second window, add a todo in either, and both lists update.

Fixed

  • Regenerating a --generated resource without --generate broke it. make:resource <Model> --view --force (or --force alone) on a resource made with --generate rebuilt the Rust half from the struct. The new mod.rs stopped declaring the model, migration and seeder, and the field list's formats, unique and references were lost. It's now refused, with the command to use instead: the same --generate field list with --force.

0.8.0 — 2026-09-23

The resources release. rata make:resource writes the vertical slice a desktop app needs for each table — a searchable, paged list, a validated form and a detail view in Svelte, the ability-gated commands behind them, tests, and with --generate the model, migration, factory and seeder — typed end to end and using the framework's own safety rails. It stands on a new hash router in @elyra/runtime and a registry that keeps main.rs to a one-time edit. See the resources guide and RFC 0001.

Also: validation messages in the app's language, $t / $tc typed against the translation catalog, serde_json::Value through codegen, and single-instance fixed on Windows.

Upgrading: nothing breaks. rata new now scaffolds a router (Router.svelte, routes.js, pages/); existing projects keep their App.svelte and can adopt it from the frontend runtime docs. On Windows, single-instance uses new ports, so while updating, a 0.8 launch won't hand off to a 0.7 instance that is still running.

Added

  • rata make:resource <Model> — step 3 of RFC 0001. For an existing #[derive(Model)] it generates src/resources/<name>/: the five resource commands (<plural>_index with search, an allowlisted sort and a capped page; _show, _store, _update, _destroy, soft-deleting when the model does), each behind an ability; <Model>Input / <Model>Query; validation rules from the field types, on create and update, translated when the app has a Translator; Created / Updated / Deleted domain events; and TestApp tests for all of it. The model is read with syn; missing derives or dependencies stop it with the exact lines to add, before anything is written. --dry-run, --force, --no-abilities. See docs/cli.md.

  • rata make:resource <Model> --view — step 4 of RFC 0001: the Svelte screens for a resource in app/src/resources/<plural>/ — a list (debounced search, sortable headers, paging, delete behind confirm()), one form for create and edit (a control per field type, validation messages per field) and a detail view — plus routes and a nav entry through the frontend registry. Labels go through $t when the project has lang/en.json, and their English defaults are added to it as one key, leaving the rest of the file untouched. An existing Rust half is kept. See docs/cli.md.

  • rata make:resource <Model> --generate <fields> — step 5 of RFC 0001: the model (with timestamps and impl Factory), a reversible RustMigration and a seeder from a Rails-style field list (name:string email:email:unique 'phone:string?' active:bool=true team_id:references:Team), plus everything --view makes. references: adds belongs_to and an exists rule, and the seeder points rows at an existing parent. The generated tests run the real migrations, and cover unique (skipping the row itself on update) and exists. It refuses to shadow an existing model; --force regenerates one it made, keeping the migration's version. Checked by migrating, seeding and rolling back on SQLite, MySQL 8 and Postgres 16. See docs/cli.md.

  • Resource registries — step 2 of RFC 0001. A resource lives in src/resources/<name>/ and app/src/resources/<plural>/, and rata gathers them in two registries it owns and regenerates from the folder listing (src/resources/mod.rs: commands() / migrations() / abilities(); app/src/resources/index.js: routes / nav), so main.rs is wired once rather than per resource. rata prints the missing wiring and never edits main.rs, and refuses to overwrite a registry without its managed by rata marker. New rata resources:sync; rata new scaffolds the empty frontend registry, with routes.js and the nav reading it. See docs/cli.md.

  • The resource registry gains seeders(), run in migration order, so a parent is seeded before the resources that reference it.

  • The resources guide — from rata new to a working customer list with make:resource, and what each generated file is for. RFC 0001 is implemented.

  • scripts/smoke-resource.sh, run in CI on a fresh scaffold. It takes make:resource through the whole workflow:

    • the prerequisite refusal (nothing written);
    • --generate of a Team and a Customer referencing it, and the wiring hint;
    • the generated tests, clippy and fmt;
    • a migrate, seed and rollback on SQLite;
    • codegen and the frontend build;
    • the views in headless Chrome against a fake backend.

    A broken template now fails CI rather than a user. It runs locally too.

  • A router in @elyra/runtime — hash-based (#/customers/12): a route store, navigate (with replace), href, resolveRoute / matchRoute with specificity-based precedence (/customers/new beats /customers/:id; a * only wins when nothing exact matches), and deepLinkPath for elyra:deep-link URLs. Framework-agnostic; rata new now scaffolds Router.svelte, a routes.js table with lazy-loaded pages, and App.svelte as the layout. Step 1 of RFC 0001. See docs/frontend-runtime.md.

  • Page<M> is Serialize / Deserialize and — with the database feature — a specta::Type, so a command can return a paginated result directly and the frontend gets Promise<Page<User>> with a generic Page<M> in bindings.ts.

  • Query::or_where_like(&columns, pattern) — an OR group of LIKEs across several columns, ANDed with the rest of the query: a search box.

  • ? converts ValidationErrors and — with database — elyra::db::Error into elyra::Error, so an elyra::Result command can validate and query without map_err. The bag keeps its JSON, so the frontend still gets a ValidationError with per-field messages.

  • App::seeders(Vec<Box<dyn Seeder>>), for registering a list at once.

  • Table::nullable_foreign_id — a foreign key to an optional parent.

  • serde_json::Value in commands and models. specta's serde_json feature is on, and codegen renders the type as a declared JsonValue union. Before, it failed codegen: specta can only inline Value, it refuses to because Value is recursive, and the number policy never reached the i64 inside Number, which codegen now also coerces in any inline type.

  • Translated validation messages. Validator::translator(&t) renders each error from validation.<key> in the app's translation files (e.g. validation.min.string) and names fields from validation.attributes.<field> — by concrete path, then by wildcard pattern (items.*.name) — including :other in same / required_if. Untranslated keys fall back to the built-in English, which is byte-for-byte what it was (a 60-case golden diff against 0.7.0). validation::message_keys() lists all 59 keys; docs/validation.md has the full JSON skeleton.

  • Translator::has(key).

  • Typed translation keys. With an I18nProvider, rata codegen emits a Translations type — every key in the fallback locale with the parameters its :placeholders need — and t / tc / translate / choice narrowed to it, so $t("welcom") or $t("welcome") without { name } is a compile error. Checked against real generated bindings with tsc, in both directions. See docs/i18n.md.

  • Translator::codegen_keys() and codegen::generate_all (generate_with plus a translation catalog).

Fixed

  • Option<bool> model fields couldn't be read back. bool was mapped to an INTEGER 0/1 column, but Option<bool> went straight to the Any driver, which has no boolean type: find/get failed with "mismatched types … BIGINT". It now maps like bool, with NULL for None — covered on SQLite, MySQL and Postgres. Found by make:resource's generated tests.
  • Single-instance could silently fail on Windows. The primary instance listened on one loopback port derived from the app name, in the dynamic range where Hyper-V, WSL and Docker reserve blocks of ports (netsh int ipv4 show excludedportrange) that nothing can bind. An app whose name hashed into such a block never became primary on that machine — a second launch opened a second window. It now has eight candidate ports spread across the range, binds the first that's free, and a second launch tries them in the same order (the handshake tells it when a port belongs to someone else). The port is also derived with FNV-1a instead of DefaultHasher, whose output may change between Rust versions — so an update built with a newer toolchain can still find the instance already running. Found by a flaky CI test.

0.7.0 — 2026-09-23

The desktop-depth release: the rest of the Laravel gap, done the way a desktop app needs it. Commands take named middleware; the scheduler runs at clock times and survives the laptop sleeping through them; validation handles nested input, arrays and the database; and one set of translation files serves Rust and Svelte alike.

Upgrading: one change can affect existing code, under Changed below — an unknown validation rule now panics instead of being ignored (a misspelt "requried|email" used to accept every input), and the synchronous validate() panics on unique / exists instead of skipping them. Fix the rule name, or use validate_with(&db).await for database rules.

Added

  • Per-command middleware — #[command(middleware = ["auth", "audit"])], with App::middleware_alias(name, mw) and App::middleware_group(name, [..]). Laravel's middleware aliases and groups: named middleware runs only for the commands that ask for it, inside the global stack, in declared order; groups expand in place and a middleware reached twice runs once. An unknown name or a group cycle stops the app at startup, so a misspelt name never means a command runs without its middleware. See docs/middleware.md.
  • Clock-time scheduling — Scheduler::daily_at("09:00"), weekdays_at, weekly_on, monthly_on, hourly_at and five-field cron("*/15 9-17 * * mon-fri") expressions, in the system time zone. Built for a desktop: a machine that sleeps through a job's time runs it once on wake (clock jobs re-check the wall clock at least every 30 s rather than trusting one monotonic timer), DST gaps and folds are handled, and a job's runs never overlap. Time zones come from jiff (system tzdb; bundled on Windows). See docs/scheduler.md.
  • Validation: nested fields, arrays and 30 more rules. Fields may be paths (address.city, items.*.name, errors keyed items.1.name), with wildcard sibling references (required_if:items.*.kind,physical) and distinct across a wildcard. New rules: required_if / _with / _without, accepted, filled, array, uuid, ip, alpha / _num / _dash, digits, digits_between, between, gt / gte / lt / lte, not_in, starts_with, ends_with, regex (via regex-lite), date, before / after (+ _or_equal). unique / exists check the database through Validator::validate_with(&db).await, including unique:users,email,{id} for edit forms. Validator::rule_list takes rules as a list, for patterns with |. See docs/validation.md.
  • Translations (i18n) — lang/<locale>.json catalogs (nested or flat), embedded with I18nProvider::embedded::<Lang>(). In Rust, Translator::get / choice / set_locale; in Svelte, $t("welcome", { name }), $tc("files", n), $locale and setLocale, which re-render when the locale changes anywhere. Lookup runs nb-NO → nb → fallback, a missing key returns the key, placeholders follow :name / :Name / :NAME, and plurals use Laravel's {0} / [2,*] conditions and per-language rules (one form in Japanese, three in Russian and Polish) on both sides. The starting locale is the one the user last chose (saved in the Store), else the OS language (via sys-locale: $LANG is unset for a Dock-launched macOS app), else the fallback. See docs/i18n.md.
  • Store::at(path) and Store::fake() (in memory, never written) — so a test needn't write into the real app-data directory.

Changed

  • An unknown validation rule panics instead of being ignored, naming the rule and the field — "requried|email" used to pass every input. The synchronous validate() likewise panics on unique / exists rather than skipping them.

Fixed

  • 23 broken intra-doc links — public docs linking to private items (some from the 0.5.8 shell/ split), an ambiguous crate::error module/macro, and About <App> read as an HTML tag. cargo doc now runs with -D warnings in CI so new ones fail the build instead of piling up.

Testing

  • The durable-queue tests simulate real restarts. Two of them failed intermittently on the slower Linux and Windows runners after 0.6.0 (the pull request runs happened to pass): the "restart" never stopped the first app, so two instances worked one journal at once, and a delay test assumed startup took under 400 ms. Each launch now runs in its own tokio runtime that is shut down afterwards, and the delay test asserts the invariant rather than a deadline.

0.6.0 — 2026-09-22

The Laravel-depth release. The model layer gains the features that most often sent code back to raw SQL — many-to-many, casts, scopes, factories — the container binds traits and builds services from their dependencies, domain events connect one part of an app to another (and, with one call, to a typed frontend channel), the queue can survive a restart, and tests get Laravel's fakes.

Upgrading: two changes can affect existing code, both under Changed and Fixed below.

  • #[derive(Model)] now rejects #[model(..)] options it doesn't recognise (they used to be ignored silently), so a stray or misspelt option stops the build at that line.
  • Model::find / Model::all now respect #[model(soft_deletes)] and global scopes, like every other query. Code that relied on them returning trashed rows should use Model::query().with_trashed().

Added

  • belongs_to_many — many-to-many through a pivot table, with Laravel's naming defaults (role_user, user_id, role_id) and overrides (pivot, fk, related_fk, as). Generates roles(), roles_query() (a Query to keep narrowing, count or paginate), load_roles(), attach_roles(), detach_roles(), detach_all_roles(), sync_roles() and sync_roles_without_detaching(). sync runs in one transaction and returns the SyncChanges it made. Eager loading is one query — the pivot key is selected with the related columns — so the related model needn't be Clone. Also available on a field (with_<field> hydration). See docs/models.md.

  • Casts — #[model(cast = ..)]. "json" stores any serde type as JSON text (NULL ⇄ None), "text" any Display + FromStr type (enums), and a path names your own impl Cast<T>. A value that won't decode is an error naming the column.

  • Local scopes — Query::scope(f) for any fn(Query<M>) -> Query<M>, plus when(condition, f) and when_some(option, f) for optional filters.

  • Global scopes — #[model(global_scope = path)], repeatable. Applied to every read and bulk write for the model, including find, all and relation queries into it; Query::without_global_scopes() opts out explicitly.

  • Model factories — impl Factory for User { fn definition(n: u64) -> Self }, then User::factory().count(3).state(..).sequence(..).create(&db) / create_one / make / make_one. n is unique process-wide, so unique columns survive repeated builders. Factory is exported from elyra and the prelude.

  • Query::find(db, id), so Model::query().with_trashed().find(..) reaches a trashed row; Value converts from i16, f32, &String and Option<T>.

  • Trait-object bindings — bind_as::<dyn Mailer>(Arc::new(SmtpMailer)) on Container and App, resolved with ctx.get::<dyn Mailer>(). Laravel's bind(Interface::class, Impl::class); also what makes the substrate contracts bindable. get, try_get and TestApp::get accept trait objects.

  • Lazy singletons — bind_lazy(|ctx| ..), built on first resolution with a full Ctx, so a provider's register can bind a service that depends on another provider's binding. A dependency cycle panics with the chain of types rather than deadlocking. Plus Container::has / Ctx::has.

  • Domain events — Dispatcher. App::listen(|e: OrderShipped, ctx| ..) or ctx.get::<Dispatcher>().listen(..) from a provider, then ctx.dispatch(event).await. Listeners run in registration order; the first error stops the chain and is returned to the dispatcher. ctx.dispatch_background(event) fires and forgets, logging failures. See docs/events.md.

  • App::broadcast::<E>("channel") forwards a domain event to the frontend EventBus and declares the channel's type for codegen in one call — a typed path from ctx.dispatch(..) in Rust to channel(..) in Svelte.

  • Durable queues — QueueProvider::durable() (feature database). Jobs are kept in the app's database (elyra_jobs / elyra_failed_jobs, created on first use) until they succeed or finally fail — Laravel's database driver. Pending jobs, the waiting backlog, delays and retry backoff survive a restart and resume where they were; failed jobs are listed again on the next launch and can be retried. Delivery is at least once. See docs/queue.md.

  • Queue::push_confirmed(..).await — enqueue and return only once the job is durable (plain push still reports a full queue synchronously and writes the row a moment later). Queue::is_durable().

  • Test fakes — Queue::fake(), Storage::fake(), Dispatcher::fake(). Laravel's Queue::fake() / Storage::fake() / Event::fake(): record pushes, use a private temp disk, record events without running listeners — each with assert_* helpers whose failures say what did happen and point at the test line. See docs/testing.md.

  • App::swap / App::swap_as — replace a binding after every provider has registered (and after the framework's own bindings), so a fake wins over the production wiring without editing it. Laravel's $this->swap().

Changed

  • #[derive(Model)] rejects unknown options. It used to discard anything it didn't recognise — including parse errors — so a misspelt global_scope would have compiled into a model that silently leaked across tenants. Unknown keys, malformed relations and non-identifier table / column names are now compile errors.

    Upgrading: a build that carried a meaningless #[model(..)] option stops compiling at that line; remove or correct it.

  • Bulk update binds its SET values and constraints into one argument list, replacing a step that re-derived the WHERE bindings separately (a precondition for global scopes, which add constraints the old path didn't know about).

  • The container stores Arc<T> behind dyn Any (rather than T), which is what lets T be unsized. bind / get behave exactly as before.

  • A durable queue's recovered jobs are delivered once every provider has booted rather than in QueueProvider::boot, so handlers registered by a later provider receive them. A recovered job with no handler moves to the failed table instead of being dropped. In-memory queues behave as before.

Fixed

  • The rata new smoke test in CI failed to build the scaffolded frontend (failed to resolve import "@elyra/runtime"): --elyra links the runtime to the checkout's runtime/ as a file: dependency, whose entry point is the unbuilt dist/. CI now builds the runtime first, and rata new --elyra prints that step when dist/ is missing, since a fresh local clone hits the same wall.

  • find and all returned soft-deleted rows. Both issued raw SQL that bypassed the builder, so #[model(soft_deletes)] only applied to query(). They now go through the builder, which also makes them respect global scopes — otherwise the new scopes would have had the same hole. A belongs_to lookup, which calls find, no longer returns a trashed owner.

  • Aggregates dropped joins. count / sum / avg / min / max — and so paginate's total — cleared a query's joins, so any constraint on a joined column produced invalid SQL. Joins are kept now.

Testing

  • tests/eloquent.rs (12 tests) covers casts, local and global scopes on reads and bulk writes, pivots, factories and the soft-delete fix on SQLite; tests/model_servers.rs gains casts, a globally-scoped bulk UPDATE (the $n numbering across SET and WHERE on Postgres) and a pivot round trip on real MySQL 8 and Postgres 16.

0.5.8 — 2026-08-29

A security + structure release. The last all-or-nothing grant in the capability model is gone — individual commands can now be gated with #[command(can = "…")], so a script that reaches the page no longer reaches every command an app registers. Secrets are wiped from memory instead of being left in freed allocations, a 403 finally says which gate refused, and the 1 887-line shell.rs is split so the access-control decision is auditable on its own.

Upgrading: two changes need attention, both under Security below — Secrets::get returns a Secret instead of a String (feature secrets), and #[command] now rejects an attribute it previously ignored. See docs/security.md for the extended model.

Security

  • Per-command abilities — #[command(can = "…")]. Capability::Commands is one grant covering all of /__cmd/*, so a single XSS reached every command an app registered. A command can now declare an ability, which the app must grant explicitly:

    #[command(can = "posts.delete")]
    async fn delete_post(ctx: Ctx, id: i64) -> elyra::Result<()> { /* … */ }
    
    App::new().allow_ability("posts.delete");   // or .allow_abilities([..]) / "posts.*"
    

    Deny-by-default: without the grant the call answers 403 naming the missing ability. Commands without can = .. are unchanged, so this is opt-in per command. Rust-side dispatch (providers, queue jobs, TestApp) is never gated — the same split as Sidecar::spawn vs. frontend sidecar spawn. See docs/security.md.

  • #[command] validates its attribute. The attribute token stream used to be ignored outright, so #[command(anything)] compiled silently. Unknown options and malformed abilities are now compile errors.

  • ForbiddenError now reports which gate refused. A 403 always read as "missing or invalid IPC token", so an ungranted capability — and now an ungranted ability — sent you looking in the wrong place. The runtime reads the shell's explanation into the message and exposes it as error.detail.

  • Secrets::get returns a Secret, not a String (feature secrets). The value wipes its bytes on drop via zeroize instead of leaving the plaintext in a freed allocation, and its Debug renders Secret(***) so a stray log line can't leak it.

    Upgrading: Secret derefs to &str, so secrets.get(k)?.as_deref(), &*secret and secret.expose() all work; only code that bound the result as an owned String needs .to_string() (which escapes the wipe — prefer passing the &str). get_or_migrate_env changes the same way.

Added

  • elyra::prelude — App, Ctx, Container, Provider, Result, EventBus, the middleware types, command + commands!, and (behind database) Model/Query/Database, in one glob import.
  • rata make:middleware <name> — scaffolds a Middleware impl and prints the .middleware(..) wiring step, completing the make:* set.
  • ai is documented in the README feature table (the feature itself shipped in 0.4.0).

Changed

  • shell.rs split into a shell/ module. 1 887 lines that mixed protocol, routing, access control and the webview lifecycle are now guard (token, capabilities, rate limits, body limits, CORS, CSP), protocol (the wire shapes), router (dispatch), facades, update, assets and webview. Purely internal — shell is a private module, so no public API moved — but the access-control decision is now auditable on its own, with 8 new unit tests over guard::check that the old layout made awkward to write.

  • Frontend toolchain moved to the current major line. The rata new template and the example app now scaffold Vite 8, @sveltejs/vite-plugin-svelte 7 and Svelte 5.56 (from Vite 6 / plugin 5 / Svelte 5.20). The runtime package builds on TypeScript 7 and tests on Vitest 4.

    Upgrading: these raise the Node floor to ^20.19 || >=22.12, declared in both @elyra/runtime and generated apps. Existing projects keep working on their pinned versions; to move, bump the three devDependencies together — plugin-svelte 7 requires Vite 8 and Svelte >=5.46 as peers.

  • Dependency refresh across the workspace lockfile (tokio 1.53, thiserror 2.0.20, ureq 3.4, tray-icon 0.24.2 and friends). No API changes.

Testing

  • The frontend runtime went from 9 tests to 56. src/index.test.ts is split into invoke, channel, system and token suites over a shared test-support.ts, covering the command framing and header contract, every error kind (command/validation/panic/forbidden/unknown), the cancellable path, the event pump's reconnect, backoff and give-up-on-403 behaviour, the snake_case /__sys/* payloads, and the no-token document.
  • The IPC gate got its own unit tests. Eight cases over guard::check — a missing and a forged token, a granted route, an ungranted capability, an oversized body, and the four ability outcomes — plus six end-to-end ability tests through the real route() pipeline in tests/shell.rs.

0.5.7 — 2026-07-29

A hardening + Laravel-parity release. The IPC surface is now gated by a per-run token, an origin rule and a capability model; the event bus, the settings store, the cache and the queue got the durability they were missing; and the Laravel-shaped pieces that were absent — Log, Config, Secrets, TestApp, a schema builder, pagination/aggregates/joins/soft deletes/transactions — landed.

Upgrading: four behaviour changes need attention, all listed under Changed below (IPC token for hand-written frontends, opt-in capabilities for destructive routes, sidecar_allow for frontend spawn, and the new Asset shape). See docs/security.md for the new model.

Security

  • IPC token + origin-scoped CORS. Access-Control-Allow-Origin: * was sent on every response, including /__cmd/*, /__sidecar/*, /__sys/* and /__storage/* — any origin able to reach the protocol had full access to commands, the filesystem, the clipboard and process spawning. Production builds now send no CORS headers at all, rata dev gets CORS for the exact ELYRA_DEV_URL origin only, and every /__* request must carry this run's random IPC token (injected into the webview as globalThis.__ELYRA__.token, attached automatically by @elyra/runtime as x-elyra-token). Rejected requests get 403 and surface as ForbiddenError on the frontend.
  • Capabilities for the frontend. Each /__* route now maps to a security::Capability. The everyday ones are granted by default; StoreClear, CacheFlush, StorageDelete and UpdaterInstall are opt-in via App::allow_frontend(..), and deny_frontend(..) revokes any of them. The expensive ones are also rate-limited per window.
  • Sidecar spawn is deny-by-default. /__sidecar/spawn accepted any program + args from the frontend (arbitrary code execution from a single XSS). The frontend may now only spawn programs named via App::sidecar_allow(..); Rust-side Sidecar::spawn is unchanged.
  • shell.open is policy-gated. Only http/https/mailto (plus schemes added with App::allow_open_schemes(..)) are handed to the OS; file: URLs, relative/non-existent paths, and anything executable (.exe, .sh, .app, .desktop, …) are refused. App::deny_open_paths() blocks local paths too.
  • A strict CSP by default. security::DEFAULT_CSP is served with every HTML response unless overridden with App::csp(..) or disabled with App::csp_disabled().
  • Request bodies are bounded and depth-checked. 16 MiB by default (App::max_request_body), and MessagePack nested deeper than 64 levels is rejected before it reaches serde's recursive deserializer (a ~10 KB body could overflow the stack and abort the process).
  • Single-instance handshake is authenticated. The rendezvous moved from a loopback TCP port with a guessable magic string to a 0600 Unix socket (TCP on Windows) gated by a random per-install token, and forwarded deep links are now parsed and validated instead of prefix-matched.
  • Updater hardening. HTTPS is required for the manifest and the artifact (loopback excepted, allow_insecure to opt out), the download streams to disk with a max_artifact_bytes cap instead of buffering in memory, and a failed signature check leaves nothing staged.
  • Secrets. New secrets feature: Secrets stores tokens in the OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service) instead of the clear-text settings file, with get_or_migrate_env to move off env vars.
  • New docs/security.md: the IPC surface, the three gating mechanisms, dangerous operations, and a release checklist.

Added

  • Log facade. Levels, targets, ISO-8601 timestamps, a rotating file sink (LogProvider::to_app_dir), ELYRA_LOG for runtime control, and log_path() for a "send us your log" button. Every command dispatch is traced with its duration; the framework's eprintln!s are gone.
  • Config layer. elyra.toml, config/*.toml, .env and process env merged into one dotted-key map with ${VAR} expansion, typed accessors and section(). Bound via ConfigProvider.
  • Test facilities. elyra::testing::TestApp invokes commands through the real middleware pipeline without a window (invoke, invoke_ok, invoke_err, invoke_validation_errors, events_on, assert_emitted), and TestShell drives the actual /__* routing for IPC-level tests.
  • Schema builder. elyra_db::Schema::create/table/drop/rename renders DDL per driver (SQLite/MySQL/Postgres), with RustMigration for migrations written in Rust and rata make:migration --rust to scaffold them.
  • Query-builder parity. where_like / where_null / where_not_null / where_between / or_where_eq, chained order_by, offset, join / left_join, count / sum / avg / min / max / exists, paginate (returning Page), chunk, bulk update / delete, soft deletes (#[model(soft_deletes)] + with_trashed / only_trashed / restore), and Database::transaction / begin.
  • Seeders. App::seeder(..) + ELYRA_SEED=1; Rust migrations run with ELYRA_MIGRATE=up|down.
  • Queue maturity. Retries with exponential backoff, per-job JobOptions (attempts / backoff / timeout), a failed-jobs list with retry_failed, push_later, typed dispatch / on_typed, bounded capacity with backpressure, and multiple workers (QueueProvider::with_workers).
  • Typed event channels + bigint codegen. App::event::<T>("channel") makes rata codegen emit an ElyraEvents map and a narrowed channel(); App::codegen_bigint() exports 64-bit integers as bigint.
  • Asset caching + ranges. Embedded assets are served borrowed (no per-request copy) with ETag, 304 handling, immutable caching for fingerprinted files and Range/206 support for media.
  • Cross-platform app menu. App::menu(..) now renders on Windows and Linux (per-window menu bar via muda), not just macOS.
  • Bundling beyond macOS. rata bundle builds a .deb (no dpkg needed) plus a portable .tar.gz on Linux and a portable folder on Windows, and the macOS Info.plist now registers CFBundleURLTypes from [bundle].deep_link.
  • @elyra/runtime is publishable. Built to dist/ with .d.ts, an exports map, files, 9 vitest tests, and a provenance publish workflow.
  • Pool tuning. DatabaseOptions (max connections, acquire timeout, idle / lifetime) and SQLite WAL + busy_timeout + foreign_keys by default.
  • CI. Rust matrix across macOS/Linux/Windows, an MSRV (1.80) job, cargo deny (advisories / licenses / bans / sources), a rata new build smoke test, and runtime typecheck + tests + build.

Fixed

  • Events reached only one window. The event bus kept a single shared queue, so with several windows whichever one polled first took the batch and the others silently lost the events. Each webview now identifies itself (x-elyra-client-id) and gets its own queue; an emit fans out to all of them. New: EventBus::next_batch_for, disconnect, client_count.
  • A panicking command hung the frontend forever. Non-cancellable commands were dispatched inline in the task owning the protocol responder, so a panic (or a missing container binding, which panics by design) dropped the responder without a reply and await invoke(..) never settled. Commands now always run on their own task; a panic becomes a 500 with x-elyra-error-kind: panic.
  • Structured errors were unusable. Error::Command prefixed every message with "command failed: ", so a ValidationErrors bag arrived as command failed: {"email":[…]} and could not be parsed — the documented validation flow was broken end to end. Messages are now verbatim, the response carries x-elyra-error-kind, and the runtime throws a typed ValidationError with a parsed errors bag.
  • rata new produced a project that couldn't build. The scaffold pinned elyra = "0.1" (against a 0.5.x API) and @elyra/runtime ^0.0.0; both now use the CLI's own version.
  • Store writes were neither atomic nor coalesced. settings.json is now written to a temp file and renamed (with a .bak fallback on read), and bursts of set() are debounced off the IPC thread instead of writing per call.
  • Cache was unbounded. Entry-count and byte budgets with LRU eviction (Cache::with_limits), and TTLs now use the wall clock as well as the monotonic one, so a deadline no longer freezes while the machine sleeps.
  • RateLimiter could overshoot its limit. attempt() was a check followed by a separate increment; it now uses one atomic Cache::increment_if_below.
  • where_in and joins. Qualified table.column identifiers are accepted (and validated) so joined columns can be filtered and ordered.

Fixed (post-tag)

  • The cross-platform app menu broke non-tray builds off macOS. ABOUT_MENU_ID and UserEvent::MenuClick were gated on any(target_os = "macos", feature = "tray"), but the new per-window menu references them unconditionally — so cargo build --features database failed to compile on Linux and Windows. Both are now unconditional. Caught by the new CI matrix and the rata new smoke test.
  • Internal crates now carry a version alongside their path, which cargo publish requires and cargo deny's wildcard check flagged.
  • SQLite URLs built from a filesystem path were broken on Windows. The obvious format!("sqlite://{}", path.display()) produces a drive colon and backslashes where the URL grammar expects an authority and forward slashes, so SQLite answered unable to open database file. New elyra_db::sqlite_url(path) (and sqlite_url_opts) build it portably; the model tests now use it. This affected any app deriving its database path at runtime, not just the tests.
  • Linux builds now need libxdo (the cross-platform app menu links muda); documented in the getting-started prerequisites.
  • rata new scaffolded a project that still couldn't build. Correcting the version strings wasn't enough: Elyra isn't published to crates.io or npm, so elyra = "0.5.7" and @elyra/runtime@^0.5.7 named packages that don't exist. The scaffold now depends on the matching tagged GitHub release — a git dependency for the crate, and the @elyra/runtime tarball attached to the release for the frontend (npm accepts a tarball URL but cannot install a git subdirectory).

Changed

  • Asset carries Cow<'static, [u8]> plus an etag (was Vec<u8> + mime only).
  • CacheProvider and QueueProvider are now constructed (CacheProvider::new() / ::with_limits(..), QueueProvider::new() / ::with_workers(..)) instead of being unit structs.
  • Queue::push returns bool (false when the queue is full) and the frontend's queue.push reports a full queue as an error instead of silently dropping.
  • Frontends that talk to the bridge without @elyra/runtime must now send x-elyra-token (and x-elyra-client-id for /__events); the token is exposed to page scripts as globalThis.__ELYRA__.token.
  • @elyra/runtime ships built output. The package now exports dist/ with .d.ts files instead of raw src/*.ts; bundlers other than Vite work as a result, and the version tracks the crate (0.5.7).
  • elyra and @elyra/runtime are both at 0.5.7; rata new pins the CLI's own version instead of a hard-coded one.
  • MSRV is now declared as 1.94 (was 1.80). The old value was never verified — the new CI job showed it can't build at all, since sqlx 0.9 requires 1.94 and several dependencies need edition2024 (Cargo 1.85+). Nothing regressed; the declaration was simply wrong.

0.5.6 — 2026-07-29

Added

  • Rate limiter. A cache-backed RateLimiter (Laravel-RateLimiter-style) — too_many_attempts / hit / attempts / remaining / clear / attempt, with self-expiring per-key counters. Get one via Cache::limiter().
  • Task scheduler. A Laravel-Schedule-style Scheduler for recurring background jobs — every / every_minutes / hourly / daily, async closures, bound via SchedulerProvider. Interval-based (from app start), in-process; registration works before or after start.
  • Artisan-style generators. rata make:command, make:provider, and make:model scaffold a source file under src/ (with normalized snake/Pascal names and pluralized model tables) and print the mod + registration wiring step. Existing files are never overwritten.
  • Validation. A Laravel-style validator (elyra::validation): check command input against a rule string ("required|email|min:18") and get a per-field ValidationErrors bag. Return it via ? from a command and the frontend reads the structured errors with validationErrors(err) from @elyra/runtime. Rules: required, nullable, string, integer, numeric, boolean, email, url, min, max, size, in, same, confirmed. Core, no deps.

0.5.5 — 2026-07-25

Fixed

  • The bundle updater rejected every update. 0.5.4 verified the downloaded bundle with codesign --verify --strict --quiet — but codesign has no --quiet flag, so it exited 2 with "unrecognized option" on every run and the updater concluded that no update was correctly signed. Auto-update was dead on arrival for bundled apps: safe (nothing was installed) but useless.

    The verification now lives in its own function, carries codesign's own reason into the error message, and is covered by a positive test: a correctly signed bundle must be accepted, both freshly signed and after the ditto round-trip the updater performs. Rejection tests alone could not catch this — a broken invocation rejects everything, which looks exactly like a working guard.

0.5.4 — 2026-07-25

Fixed

  • The auto-updater no longer breaks a signed macOS .app. Updater::apply_and_relaunch replaced only the running executable. Inside a code-signed bundle that is fatal: the signature seals Info.plist and every file under Contents/, so dropping a new binary in — and leaving the .old backup beside it — broke the seal, and macOS then refused to launch the app at all with "The application can't be opened." The app had to be reinstalled by hand.

    The updater now detects that it is running inside Foo.app/Contents/MacOS/ and switches to whole-bundle replacement: the artifact must be a zip of the signed .app, which is expanded with ditto (preserving extended attributes and the signature), verified with codesign --verify --strict, checked to carry the same CFBundleIdentifier, and only then swapped in with a rename inside the bundle's own directory — with the outgoing copy moved outside the bundle and a roll-back if the swap fails. Relaunch goes through open so LaunchServices re-registers the new bundle.

    A bare-binary artifact offered to a bundled app is now refused with an explanation instead of applied. Loose (unbundled) executables keep the previous in-place swap.

    Releasing note: apps distributed as a signed .app must publish a zip of the bundle as the update artifact. A release that keeps publishing the bare executable will now be rejected by the client rather than installed.

0.5.3 — 2026-07-24

Added

  • rata bundle app icon. The macOS bundle now generates the native dock/Finder icon: it renders the source image to Contents/Resources/AppIcon.icns (sips + iconutil; SVGs rasterized at 1024 via qlmanage, sips fallback) and sets CFBundleIconFile. Configure with [bundle].icon, or it auto-detects app/public/icon.svg (shipped by rata new), icon.png, etc. Best-effort — the bundle still builds without it.

0.5.2 — 2026-07-22

Added

  • Command cancellation. invokeCancellable(command, ...args) in @elyra/runtime returns { id, result, cancel }; cancel() aborts the in-flight command on the Rust side (via a request-id header + a /__cancel route that aborts the command's task). Progress is done with the event bus (documented pattern) — no new API needed.
  • AI rate limiting + token budget. AiBuilder::rate_limit(per_minute) throttles every provider call (waits, doesn't error); token_budget(max) refuses new prompts once cumulative tokens hit the cap (Error::Budget); Ai::tokens_used() reports the running total.
  • Opt-in CSP. App::csp(policy) sets a Content-Security-Policy header on HTML responses served over elyra:// (off by default — a too-strict policy can break the webview).

Changed

  • Locks no longer poison. Switched internal std::sync::Mutex to parking_lot::Mutex across the cache, event bus, sidecar, store, queue, windows, and the AI client — a panic while holding a lock can no longer cascade into a poisoned-lock crash.

Fixed

  • Sidecar CPU spin. If every command sender dropped while a child was still running, the owning task's select! busy-looped on a closed channel at 100% CPU. The command arm is now disabled once the channel closes; the task only waits on the child to exit.
  • Unbounded EventBus growth. Emitted events buffered without limit when the frontend was gone/reloading/slow. The queue is now capped (MAX_QUEUED); when full the oldest half is dropped so a reconnecting frontend still gets recent state.
  • Cache TTL leak. Expired entries were only reclaimed when the same key was read again. CacheProvider now starts a background sweeper (Cache::sweep) that drops expired entries periodically; it holds a Weak ref and stops when the cache is dropped.
  • Predictable updater temp files. Downloaded updates were written to a static path in the temp dir. They now use an unpredictable filename, refuse to open a pre-existing path (O_EXCL), and are created 0600 on Unix — mitigating symlink attacks and collisions on shared machines.

Changed

  • Migrations run in a transaction. Each migration (and its history row) now commits atomically where the driver supports transactional DDL (SQLite, Postgres); a mid-file failure rolls back cleanly. MySQL auto-commits DDL, so partial state there remains possible — documented.

0.5.1 — 2026-07-17

Added

  • substrate-core crate. A tiny, dependency-free crate defining the shared, backend-agnostic Cache / Storage / Queue contracts behind the "one ecosystem" facades — the same traits the Askr/Laravel side can implement. Elyra's Cache, Storage, and Queue now implement them (re-exported as elyra::substrate); conformance is verified in tests/substrate.rs. Cache is byte-internal, so substrate get/put round-trip losslessly.

Changed

  • Cache stores values as bytes internally (the JSON/typed API is unchanged sugar on top). No behavior change for existing callers.

0.5.0 — 2026-07-17

Added

  • Queue facade. An in-process background job queue with the same surface as Laravel's Queue:: — push a named job, register an async handler with on. Jobs run in order on a background task; status is emitted on elyra:queue (onQueue). Bind with QueueProvider; push from Rust (ctx.get::<Queue>()) or the frontend (queue in @elyra/runtime). In-process / non-durable by design (durable, cross-process queues are Askr's domain).
  • Storage facade. A filesystem disk with the same surface as Laravel's Storage:: — put / get / append / exists / delete / size / files / url, every path jailed to the disk root. Bind with StorageProvider::at(root); use from Rust (ctx.get::<Storage>()) or the frontend (storage in @elyra/runtime).
  • Cache facade. An ergonomic in-process, TTL-aware key-value cache with the same surface as Laravel's Cache:: (and Askr's shared cache) — get / put / add / remember / increment / forget / flush, typed helpers, arbitrary JSON values. Bind with CacheProvider; use from Rust (ctx.get::<Cache>()) or the frontend (cache in @elyra/runtime). First of the shared "one ecosystem" facades that mirror the Askr/Laravel side over a local backend.

0.4.0 — 2026-07-15

Added

  • AI reliability (ai). Automatic retries with exponential backoff on transient failures / retryable statuses (AiBuilder::retries / retry_backoff), provider failover (Chat::failover([...])), and in-memory response caching for plain prompts (AiBuilder::cache / cache_ttl, clear_cache).
  • AI provider tools (ai). Native, server-executed web search and web fetch (web_search / web_fetch on Chat, WebSearch / WebFetch / UserLocation). Anthropic-native; OpenAI returns Unsupported (Responses API not used yet).
  • AI audio (ai). Text-to-speech (ai.speech(...).generate() → GeneratedAudio) and transcription (ai.transcribe(bytes, name).generate()), over OpenAI (gpt-4o-mini-tts / whisper-1 defaults).

0.3.1 — 2026-07-14

Added

  • AI SDK (ai feature). A new Laravel-inspired elyra-ai crate, re-exported as elyra::ai: anonymous + named agents (Agent), tools with an automatic tool-use loop (Tool), sub-agents (an Agent used as a tool via sub_agent / AgentTool), structured output (prompt_as::<T> via serde + schemars), streaming (stream → StreamChunk, ideal for the event bus), images, embeddings, and an in-memory vector store for RAG (VectorStore + cosine_similarity) over Anthropic + OpenAI. AiProvider binds an env-configured Ai client into the container. Default text model claude-sonnet-5; images gpt-image-1.
  • Single-instance (App::single_instance). Later launches focus the running window and forward their command line on elyra:second-instance (onSecondInstance), then exit. Portable loopback rendezvous with a per-app handshake.
  • Deep-linking (App::deep_link("myapp")). Launch URL via deepLink.initial(), later URLs on elyra:deep-link (onDeepLink) — macOS open-URL event + Windows/Linux scheme registration; pairs with single-instance for while-running delivery.

0.3.0 — 2026-07-14

Added

  • Sidecar processes (sidecar feature). Spawn and manage child processes via sidecar in @elyra/runtime (spawn / write / kill) or the elyra::sidecar::Sidecar handle; stdout/stderr lines and exit stream on the elyra:sidecar channel (onSidecar). No extra crate — uses tokio.

  • Autostart (autostart feature). Launch the app at login via autostart in @elyra/runtime (enable / disable / isEnabled) or the elyra::autostart module. Backed by auto-launch (LaunchAgents / registry / .desktop).

  • Settings store. A persistent key-value store (store in @elyra/runtime, Store in the container) backed by settings.json in the OS config dir — get / set / delete / all / clear, arbitrary JSON values. Core, no feature flag.

  • Native application menu. App::menu(Menu::new().submenu(Submenu::new("File")…)) adds custom submenus (with accelerators) after the standard app + Edit menus; clicks emit elyra:menu (subscribe with onMenu). Rendered on macOS.

  • Global shortcuts (shortcuts feature). App::global_shortcut("CmdOrCtrl+Shift+P") registers OS-level keyboard shortcuts; firing one emits the elyra:shortcut event (subscribe with onShortcut). Backed by global-hotkey.

  • Window-state persistence. App::persist_window_state() remembers the primary window's size, position, and maximized state between runs (stored under the OS config directory, keyed by the About name). Dependency-free.

  • Window control + file drop. @elyra/runtime exports appWindow (minimize / maximize / fullscreen / close / focus / show / hide / center / setTitle / setSize) with live state via appWindow.onState, and onFileDrop for native file drops. Backed by new Windows methods on the Rust side (usable from commands, with an optional target-window label). Core — no feature flag.

  • UI components in @elyra/runtime. Themed, dependency-free primitives: alert / confirm / prompt dialogs, toast() notifications, a ⌘K command palette (registerCommands / openCommandPalette), and contextMenu(). They read the app's CSS variables, matching the About / update components.

  • System integration (system feature). Native desktop essentials exposed through @elyra/runtime: file dialogs (dialog.open / dialog.save), opening URLs/files in the OS (shell.openExternal), the clipboard (clipboard.readText / writeText), OS notifications (notify), and standard paths (paths). Backed by rfd, open, arboard, notify-rust, and dirs; also usable from Rust via the elyra::system module.

0.2.0 — 2026-07-13

Added

  • Models — relation auto-hydration. Declare a relation on a field (#[model(has_many(Book, fk = "author_id"))] books: Vec<Book>) and the derive skips it as a column, defaults it to empty, and generates a with_<field> batch hydrator that fills it in one query — no more joining a HashMap by hand. Works for has_many (Vec<T>), has_one / belongs_to (Option<T>; belongs_to targets must be Clone).

  • Models — non-i64 primary keys. A single-column primary key may now be any type (e.g. String), marked with #[model(id)]. The value is app-supplied and included in the INSERT (no key read-back), and find takes that key type. The default i64 autoincrement behaviour is unchanged. Composite keys remain unsupported.

  • Models — column-aware belongs_to. The owning row is looked up against the related model's actual primary-key column (<T>::PK) instead of a hardcoded id, and the child's foreign key is read by column name — so belongs_to works even when the owner's PK column is renamed via #[model(column = "..")].

  • Codegen: serde container attributes are now reflected in the generated TypeScript via specta-serde — rename / rename_all, tagged and untagged enums (as discriminated unions), flatten (as intersections), and skip. Elyra's numeric policy (64-bit ints and floats render as number) is applied on top.

  • Database tests: model CRUD now runs against real MySQL and Postgres servers in CI (model_servers.rs), exercising per-driver placeholders (? vs $n) and key retrieval (last_insert_id vs RETURNING). The tests are opt-in via ELYRA_TEST_MYSQL_URL / ELYRA_TEST_POSTGRES_URL and skip cleanly when unset.

Changed

  • Updater: UpdaterConfig::auto_check now defaults to false. The silent startup check (and its toast) is opt-in via .auto_check(true), so apps no longer notify about updates on launch unless they ask to.
  • Dependencies: updated to their latest releases — sqlx 0.9 (dynamic SQL is now wrapped in AssertSqlSafe), ureq 3, ed25519-dalek 3, tray-icon 0.24 + muda 0.19, and a cargo update across the tree. No public API changes.
  • Tooling: CI typechecks the runtime with TypeScript 7 and runs on Node 24; @msgpack/msgpack bumped to ^3.1.3.
  • Docs: clarified that code signing, Apple ID / Developer ID, notarization, and binary distribution are the application's responsibility — not the framework's. Removed them from the roadmap and added an explicit "Out of scope" section.

0.1.0 — 2026-07-13

First public release. Everything below is compiled, clippy-clean, and tested (SQLite for the database layer; GUI/OS integrations are launch-smoked, with visual or side-effecting steps called out as unverified in the docs).

Added

Core

  • App builder — fluent assembly of a desktop app: window options, container bindings, providers, middleware, commands, and assets.
  • Container + Ctx — a type-keyed service container resolvable from any command, provider, or background task (ctx.get::<T>()).
  • Providers — two-phase register / boot wiring, like Laravel service providers.
  • Middleware pipeline — outermost-first command middleware around dispatch.

IPC bridge

  • elyra://localhost custom protocol — the whole app lives under one origin: assets, commands (/__cmd/*), and the event stream (/__events).
  • MessagePack wire format — compact argument arrays in, named maps out (structs decode to JS objects); no JSON in the hot path.
  • #[command] async fn — typed commands dispatched on a multi-thread tokio runtime; the UI thread never blocks. Result commands surface Err as a rejected promise (CommandError).

Events

  • EventBus + channel() — Rust→frontend push over a multiplexed long-poll, batched per flush; a Svelte-readable store on the frontend.

Windows, tray, updater

  • Multi-window — additional windows at startup or at runtime via the container-bound Windows handle.
  • System tray (tray feature) — icon + menu; clicks arrive on the tray event channel.
  • Auto-updater (updater feature) — ed25519-verified update manifest, semver comparison, HTTP fetch, and signature-checked staged download.
  • macOS application menu — an Edit menu (so ⌘C/⌘V/⌘X reach the webview) and a custom About item.

Data (database feature)

  • Database — one handle over SQLite / MySQL / Postgres via sqlx's Any driver, with per-driver placeholder rendering.
  • Migrations — rata migrate with batches, reversible down, and status.
  • #[derive(Model)] — Active Record with CRUD, a typed query builder (where_*, where_in, order_by, limit, get/first), bool↔INTEGER mapping, #[model(column)], #[model(timestamps)], relations (has_many / has_one / belongs_to), and N+1-avoiding eager loading (load_<name>).

Codegen & runtime

  • rata codegen — specta types → TypeScript definitions and a typed api.* facade that mirrors every #[command].
  • @elyra/runtime — invoke(), channel(), and the generated api.*.

Tooling

  • Ratatosk (rata) — new (scaffold with the Grove theme), dev (Vite HMR + elyra:// IPC), codegen, build, bundle (macOS .app + ad-hoc signing), and migrate.

UI components

  • About dialog — set metadata once with App::about(AboutInfo::new(..)); the shell serves it at /__about and @elyra/runtime renders a themed dialog. On macOS the standard About <App> menu item opens it; from the frontend, call openAbout().
  • Update component — App::updater(UpdaterConfig::new(..)) adds a silent startup check, /__update/check + /__update/install endpoints, progress on the elyra:update channel, and a themed update toast in @elyra/runtime (available → install → download → restart). Updater::apply_and_relaunch replaces the running binary and re-execs.