Project
Changelog
Everything that changed, version by version.
Notable changes to Renox. From 1.0 the project follows semantic versioning; see
docs/stability.md for what counts as a breaking change. Until the first
release on crates.io, apps made by rnx new are pinned to a commit, and this file lists
changes by milestone (each one pull request; details in its description and in
ROADMAP.md).
#Unreleased
#1.0.0-rc.6 · 2026-10-06
The fixes found by testing every untested path (the coverage epic #246), and two UI kit behaviours.
- Back closes open sheets and menus: a page shown again from the browser's back/forward cache has its sheets and menus closed, as after any other way back (a sheet's action form starts fresh, as after Escape).
- Escape closes a toast (#266): the toast that has the focus, else the newest one, once nothing else that Escape closes (a sheet, a menu, a popover, a tooltip) is open.
- Charts show fractions (#282): line, area, bar, pie and doughnut charts gave plain
numbers 0 decimals in their tooltips and data table, so 4.5 read "5". They now get the
decimals the data needs (up to 2), as scatter charts did;
decimals=still decides, and money and percent keep their own. rnx servestops its app (#288): a signal sent tornxalone (kill, an editor's stop button, a closed terminal) left the app running on its port;rnxnow passes SIGINT/SIGTERM/SIGHUP on to the app and Tailwind's watcher.- Fixed:
- The data grid's column menu (show/hide, freeze, move, reset) threw and saved nothing
(#275); a date range picked on the grid's calendar wasn't applied (#285); two grids on a
page lost each other's page and sort (#286);
match=alland the default rows per page stayed in the grid's URL (#287); a poll answering during an inline edit wiped what was typed (#289). - Validation:
before/afterignored a date limit given as text (#271); live validation of a list field didn't show its items' errors (#274). - Views: a failing
App::sharevalue showed a bare 500 instead of the error page (#272); an app error page that doesn't parse showed the bare fallback instead of Renox's page (#278). - PostgreSQL: a user's extra
REAL/SMALLINT(#273) andTIME(#281) columns read asnull. - Mail: the plain-text part repeated the text before a
<that opens no tag (#279). - UI kit: a required searchable select left empty focused its hidden native select (#283);
on phones, the
rx-shellsidebar grew to half the screen on short pages (#284). - renox-editors: a code editor removed right after typing threw in the console (#290).
- examples/hello threw under
CSP=strictwhen the guestbook was sent (#276).
- The data grid's column menu (show/hide, freeze, move, reset) threw and saved nothing
(#275); a date range picked on the grid's calendar wasn't applied (#285); two grids on a
page lost each other's page and sort (#286);
- Tests: browser tests for renox.js, the UI kit, the grid, the editors and every
example's main flow (
tests/browser, CI jobbrowser), the binary andrnxas real processes on SQLite and PostgreSQL (tests/process, CI jobprocess), and the coverage job fails under 90 % of lines (#246, #247).
#1.0.0-rc.5 · 2026-10-05
- Roles per branch (#244): a role can be given in one record (a store, a team, a school)
and for a period:
user.assign_role_in(&db, "manager", &Scope::of(&store))with optional.from(date)/.until(date),remove_role_in,sync_roles_in,assignments,users_with_role_in. Each request picks its scope withpermissions::set_scope(…), and the existing checks (require_role,require_permission,has_role,has_permission,can()) count the global roles plus the roles in that scope that are within their dates. For policies and lists:has_permission_in(permission, scope)(the record's scope) andscopes_with::<Store>(permission)(All, or the stores where the user has it;Scopes::applyfilters a query on one or more columns). Migration00010101000510_add_scope_to_role_user(Permissions module); existing assignments stay global with no dates, so apps with global roles only see no change.permissions:prunedeletes ended assignments.route:listmarks role and permission guards that count the active scope. Guide: docs/authorization.md "Roles per branch". - New plugin: renox-oauth (#147): sign in with Google and GitHub (another provider is one
Providerimpl). Accounts link by an address both sides verified; new users can sign up without a password and set one later; providers are unlinked from/account(never the last way in); state and PKCE are checked; the second login step (renox-2fa) still applies. renox-core gainsauth::sign_in,register_verified,registration_open,confirm_identityandUser::has_password; the account page handles users without a password. Guide: docs/oauth.md; examples/teams uses it. - New plugin: renox-admin (#148): an admin panel generated from the models (Filament's
resources):
Admin::new().authorize(…).resource(…)with anAdminResourceper model (grid columns, form fields, filters, actions, policy). Lists with search, filters, bulk actions and exports, create/edit forms, delete, a trash tab for soft deletes, a view page. Closed untilauthorizeorgateis set. Guide: docs/admin.md; new example examples/admin. - New plugin: renox-billing (#155): subscriptions with Stripe and Xendit (Laravel
Cashier): plans in code, trials (with or without a payment method), checkout, swap,
cancel, resume; the gateways' webhooks through
renox::webhook(signatures checked, each event applied once, older events ignored);require_subscription/require_planguards; a plans page and an account card. renox-core gainsRegistry::provide. Guide: docs/billing.md; new example examples/billing. - Fixed (#219), from the docs audit's list of suspected bugs:
.without_csrf()didn't exempt aRoutes::resourceupdate route from CSRF for PUT or PATCH (it is listed asPUT|PATCH).- On PostgreSQL,
?inside dollar-quoted ($$…$$,$tag$…$tag$) andE'…'strings was turned into a placeholder. - A migration with
CONCURRENTLYfollowed by a line break or tab ran inside a transaction and failed on PostgreSQL. migrations!ignored a.postgres.down.sql/.sqlite.down.sqlwithout its own.up.sql; it is now a compile error (breaking for an app that has such a file: give it its own.up.sql, or delete it).- A job whose worker died during its last attempt went to
failed_jobswithout itsfailedhook or an error report; both now run. REQUEST_TIMEOUTdidn't bound the session and auth layers (database sessions waiting for a connection); they're now cut off one second after the handler's limit.- Registration skipped a second login step that every user must pass.
- Docs:
i8and bareserde_json::Valueon PostgreSQL, thefilefield's Rust type,.fragment()and hx-boost, TestApp's defaults, the grid's search and selection limits, the 1.x promise for new commands, RELEASING.md after the first release.
- Fixed (#226): with live reload on, pages kept in the browser's back/forward cache held
their
/_renox/livestream open, and after a few navigations requests hung. Live reload and the notification bell's stream now close when a page is hidden and reopen when it comes back. - Fixed:
rnx new --notificationspanicked whenrnxwas built from a checkout with CRLF line endings (Git on Windows). - Dashboards (#152):
chart("scatter", …)andchart("bubble", …)place points by two numbers (a bubble's area by a third), with axes fitted to the data,x_title/y_title(also on line, area and bar charts),x_format/size_format, a tooltip per point (the arrow keys move between points) and the data table.period_filterhas a "Custom" button: two of the kit's date fields sent as?period=custom&from=…&to=…(keyboard-usable; a refused range shows a message), andcustom=falseto leave it out.Period::between,Period::weeks(12w),Period::per,Period::is_custom,Bucket::Week:Trendsums per ISO week (Monday to Sunday inAPP_TIMEZONE, labelled by the Monday) on SQLite and PostgreSQL. A new template functionquery_fields(…)writes the page's query as hidden inputs. examples/shop's dashboard uses them. - Full-text search (#154): one API on SQLite FTS5 and PostgreSQL
tsvector(Laravel Scout's database engine).#[model(search = "title, body")]names the searched columns (the first weighs most in the ranking;search_language = "simple","spanish", … changes the stemming from English);renox::db::search::migration::<Post>(name)is the migration that creates the index (an external-content FTS5 table with insert/update/delete triggers, or a generatedsearch_vectorcolumn with a GIN index) and fills it from existing rows.Model::search(words)/Query::searchgive the matches best first, and combine with filters, default scopes, soft deletes and pagination;Query::where_searchandQuery::order_by_relevanceare the two halves. Every word must match, as a word, a prefix or another form of it; only letters and digits of the input are used, bound as one value, so search syntax in user input is plain text. The database keeps the index current on every write (bulk updates,insert_manyand raw SQL included, which skip model hooks);renox::db::search::rebuild::<Post>(&db)refills it. The data grid's search box uses the index when the grid's model has one (also for columns the grid doesn't show), best match first until the user sorts.migrate:freshdrops virtual tables first. New constsModel::SEARCHABLEandModel::SEARCH_LANGUAGE(with defaults). Guide: docs/search.md; examples/relations' blog search uses it. - Live pages (#151): three additions around the notification bell.
rnx new my-app --notifications: the plain app withAuth::new().notifications()and the kit'snotification_bellin its layout's bar, and a test for it (the starter kit already had them).- The app's own events over the bell's Server-Sent Events stream:
state.broadcast(event, data)(every open page) andstate.broadcast_to(user_id, event, data)(one user's pages) arrive as DOM events ondocument, forhx-trigger="order-updated from:document", Alpine'sx-on:….documentor a script;renox:toastwith{"toasts": [toast]}shows toasts. Pages without the bell open the stream with the kit's newevent_stream(); one stream per page either way. Broadcasts are fire-and-forget and stay in the process that sends them (not stored, not seen by other servers or a separatequeue:work): docs/mail.md "Your own live events" says when to store a database notification instead. Tests:TestApp::fake_broadcasts,broadcasts,assert_broadcastandSentBroadcast. - Toast actions that send a request:
ToastAction::post,put,patchanddelete(the newmethodfield) make a button that sends it through htmx with the CSRF token, only to the site's own paths; a failure without a toast of its own shows "That didn't work. Try again." (ui.request_failed). They work in stored notifications too (a form in the bell's list) and from scripts (Renox.request(method, url)). New toasts from the bell's stream now also show the notification's own actions. - examples/jobs uses all three: the bell,
order-updatedbroadcasts that reload the staff's order list, and a failed charge broadcast as a toast whose "Reopen" button posts to/orders/{id}/reopen.
rnx new(#221): the AGENTS.md of a new app says exactly where Renox's docs are offline: for anrnxfrom crates.io, thegit clone --depth 1 --branch v<version> …of the app's version (the downloaded crates hold only the source); for a Git pin, Cargo's checkout of that commit; for--renox-path, the local checkout.- Two-factor authentication (#146, #170–#173): the
renox-2facrate is finished. With.module(TwoFactor::new())next toAuth::new().account(), users turn it on from/account(their password, a QR code, a code to confirm), get eight recovery codes shown once, and are asked for a code after their password from then on (Registry::second_factor): codes from the step before or after now are accepted, each works once, wrong ones count towards the login throttle. New recovery codes and turning it off ask for the password too. EventsTwoFactorEnabled,TwoFactorDisabled,RecoveryCodeUsed, recorded by theAuditmodule when the app has it. The pages can be replaced from the app's views. Guide: docs/two-factor.md (on the docs site); examples/teams uses it. - Docs match the code (#208–#217, #219): the docs-gap audit's 140 items fixed across the
README, CHEATSHEET, llms.txt, SECURITY, RELEASING, CONTRIBUTING, every guide in docs/, the
AGENTS.md and .env new apps get, the examples' READMEs and renox-2fa's README. Among them:
make:command/make:modelexamples that failed without--module, test examples that needed.account(),HxRetarget("…")needing.into(), row locks that only apply to row-reading calls,SET LOCAL statement_timeout, maintenance mode not pausing jobs, theAuthmodule's routes, and renox-2fa described as in progress. - Fixed (#218): examples/grid's Open links pointed at
/orders/%7B…%7D(route()was given a map); they now use the id. - Fixed (#185): a grid's
Column::related(andcount_of/sum_of) cells were empty when the page's query wasModel::unscoped()of a model with a default scope, as staff pages over tenant data are, and in exports too: the related values were read again through the scope, which outside a tenant gives no rows. They're now read for exactly the rows the page shows, without the scope. - Fixed (#184): a grid's
Column::moneyshowed the stored smallest unit as is, so withAPP_CURRENCY=AED(or USD, EUR…) 4,000.00 showed as 400,000, in cells, summaries, group subtotals and exports, andmin./max.filters compared the typed number with cents. Money columns now show whole units with the currency's usual decimals, filters take whole units, and CSV, Excel and print exports match. Currencies without decimals (IDR, JPY) look the same as before. An inline edit still sends the stored value. - Guides for beginners, part 3 (#193): grid.md, testing.md, postgresql.md, operations.md, development.md, stability.md and laravel.md rewritten in plain words; README.md and CHEATSHEET.md made plainer (an opening for each section, comments in the samples).
- Guides for beginners, part 2 (#193): relations.md, types.md, authorization.md, queue.md, mail.md and scheduling.md rewritten in plain words, like part 1.
- Guides for beginners, part 1 (#193): routing.md, validation.md and ui.md rewritten in plain words: what the page is for, the words it uses, short paragraphs, "what's going on" after each example, comments in the code samples, and Laravel notes, tips and warnings as callouts. Every heading (and so every link to one) is kept.
- The tutorial, for beginners (#192): docs/tutorial.md rewritten in plain words: what you'll
build and learn, a table of the words web apps use, a doc comment on every function and
comments on the lines that need one in every code sample, "what's going on" after each
sample, and callouts for Laravel users, tips and warnings. It installs
rnxfrom crates.io. Toastis in the prelude (#175):use renox::prelude::*;now bringsToast, which nearly every handler that changes something returns (HxRefreshalready was). The examples, the generators and the guides drop theiruse renox::Toast;; an existing one still compiles.- Docs links (#141): the crates'
homepageis now https://renox.renoxium.com, and the README, llms.txt and new apps'AGENTS.mdpoint readers there.
#1.0.0-rc.4 · 2026-10-04
The fourth release candidate: fixes found by using rc.3, and two extension points for
plugins. Install it with cargo install renox-cli --version 1.0.0-rc.4; an rc.3 app moves
over by changing renox = "1.0.0-rc.3" to "1.0.0-rc.4". Nothing in the API changed
incompatibly.
-
For rc.3 apps: the kit's sheets and dialogs sit in the middle again under Tailwind's reset (#133), and an app no longer fails to start now and then with "pool timed out" or "database is locked" on SQLite (#144, #163, #178).
-
For plugins:
Registry::second_factor(a step after the password, #167) andRegistry::account_section(cards on/account, #168), whichrenox-2fa(#146) is built on. -
In the repository, not yet on crates.io:
crates/renox-2fa(#169), the first plugin crate, and the docs site's deploys (#141) and new look (#189–#191). -
Dependencies:
syn3 (renox-macros) andserde_html_form0.4; the checkbox regression the latter brought (#160) was fixed before this release. -
Docs site (#189, #190, #191): code samples in coloured panels (a small highlighter in
site/src/highlight.rsfor Rust, templates, shell, TOML, SQL, JSON, PHP, CSS and config files, rendered on the server), JetBrains Mono, a copy button; the pages use the whole window with no sideways scrolling on phones; icons for every page (Lucide, inlined), a page header with what the page is for and its reading time,> [!TIP]-style callouts, and a new home page. The quick start shows the workspace's version. -
renox-2fa(#169), the first plugin crate, started:crates/renox-2fa, versioned withrenox.TwoFactor(a module) brings thetwo_factortable (SQLite and PostgreSQL: one row per user, the TOTP secret sealed withAPP_KEY, when it was confirmed, the last code used, hashed recovery codes; deleted with its user);renox_2fa::totp(RFC 6238 codes, base32,otpauth://URIs, one-step clock drift, a code works once), tested against the RFC 6238 and RFC 4648 test vectors;renox_2fa::qr::svg(the QR code, via theqrcodecrate, MIT OR Apache-2.0, no image crates). The account page, the login challenge and recovery codes follow (#170–#173). -
The docs site's deploys (#141): https://renox.renoxium.com, on the owner's Ubuntu 24.04 server.
.github/workflows/release-site.ymlbuilds the site on Ubuntu 24.04 when the docs change onmainand uploads the binary as an artifact; the server pulls each new one, checks/healthand goes back to the previous release if it fails. GitHub holds no key to the server. -
Fixed (#163, again): opening a new SQLite file from several pools or processes at once could still fail with "database is locked". One try could wait out the whole busy timeout (five seconds, as long as the budget), leaving no time to try again. The connection that switches the file to WAL now waits 100 ms per try, retries are jittered (25–75 ms) so processes that collided don't collide again, and opening tries at least three times. The error now says which step failed ("switching it to WAL").
-
Account page sections (#168): a module adds a card to
/accountwithRegistry::account_section(template, order, |user, state| …); the template reads what the closure returns assection.data. They show after the built-in cards, before "Delete account"; a page that replacesrenox/auth/account.htmlkeeps them with{% include "renox/auth/account_sections.html" %}. Forrenox-2fa(#146) andrenox-oauth(#147). -
A second login step (#167): a module can ask for something after the password, such as a code from an authenticator app (the coming
renox-2fa, #146).Registry::second_factor(challenge_route, |user, state| …)says who must pass it; the login then waits in the session for ten minutes (auth::pending_login) and the browser goes to the challenge, whose handler finishes it withauth::complete_login. Wrong codes count towards the login throttle (PendingLogin::failed,locked_out), which isn't cleared until the step is passed. Two second steps, or a challenge route that doesn't exist, fail at boot. -
CI (#143):
tests/cli/run.shalso makes apps with thernx newoptions people combine, with names before and after "renox": plain,--tailwind,--starter --tailwind, and--starterwith the database; each passescargo fmt --check, clippy and its tests. Theclijob's postgres run now runs the apps' tests and commands against a PostgreSQL service (E2E_POSTGRES), where it only built before.__pycache__/is ignored. -
tests/cli/smoke.py(#142), run by tests/cli/run.sh: the generated apps are used over HTTP the way a browser does (cookies, CSRF tokens, Referer). Every GET page answers a guest and a logged-in user without a server error; a--resourcemodule is created (an invalid form first: errors and old input), listed, shown, edited (an unticked checkbox must store false, checked in the database) and deleted, with its toasts and a 404. On the starter app: sign-up lands on email verification, the seeded admin uses the dashboard, users page and activity log and changes a member's roles (recorded), and a member gets 403. -
Fixed (#160): with serde_html_form 0.4 (#137), an unchecked checkbox no longer read as
false: its field was "required" or kept its old value.Valid<T>recognises the bool error by its wording, which 0.4 changed (expected "true", "on" or "false"); both wordings are recognised now, with a test that asks the installed serde_html_form. -
Fixed (#158): the MSRV CI job is back on Rust 1.94 (Dependabot's #135 had moved it to 1.120); Dependabot ignores that action (#156).
-
Work is tracked on GitHub: issue forms for bugs, user stories (with acceptance criteria) and tasks; a pull request template with the sections every PR has; Dependabot (grouped, weekly for crates, monthly for Actions); release-note sections. CONTRIBUTING.md "How work is tracked"; ROADMAP.md keeps principles, records and decisions, open work moves to issues.
-
Fixed (#133): with
rnx new --tailwind(or any* { margin: 0 }reset), the kit's sheets and confirm dialogs (sheet,action_sheet,confirm) and the data grid's confirm dialog opened in the top-left corner: they relied on the browser's ownmargin: autofor modal dialogs, which any author rule beats..rx-sheetand.rx-grid__dialognow setmargin: autothemselves; slide-overs and the phone bottom sheet keep their own margins. -
Fixed (#144): on a busy machine, booting an app on an in-memory SQLite database (every
TestApp) could fail with "pool timed out while waiting for an open connection": sqlx opens the first connection within the pool's acquire timeout, which is capped at 2 s for in-memory databases. Opening now tries again untilDATABASE_ACQUIRE_TIMEOUThas passed; queries keep the 2 s cap, so a test waiting on its own transaction still fails fast. -
Fixed (#163): processes opening a brand-new SQLite file database together (
serveandroute:listright aftermigratemade the file) could fail with "database is locked" or "pool timed out": creating the file and switching it to WAL take an exclusive lock, inside the pool's acquire timeout. A file database is now opened once with a plain connection before its pool, and both that connection and the pool retry "database is locked" untilDATABASE_ACQUIRE_TIMEOUThas passed. (#178: plus one busy wait, 5 s, so a long wait for the lock still leaves time for another try; the pool's open also retries a pool timeout.) -
Docs (#174): the CHEATSHEET promised that a checkbox's
onreads astruewithout saying onlyValid<T>does that; the prelude'sForm<T>(axum's) answers 422. The CHEATSHEET, docs/validation.md "Browser values" and a new app's AGENTS.md now say to read forms withValid<T>even when they have no rules; a test pins both behaviours.
#1.0.0-rc.3 · 2026-10-04
The third release candidate: the same API as rc.2, with three fixes found by using it and a CI
job that follows the tutorial. Install it with cargo install renox-cli --version 1.0.0-rc.3;
an rc.2 app moves over by changing renox = "1.0.0-rc.2" to "1.0.0-rc.3". Files the
generators already wrote are the app's and don't change: only new rnx make:model and
--resource runs name their tables in the plural (#127).
- Fixed:
TestApp::traveldidn't reach jobs:run_jobs()ran each job in a task of its own (tokio::spawn), which starts without the task-local clock offset, so a job saw the real time. Jobs, theirfailedhooks, webhook handlers and scheduled runs now keep the clock of the code that started them (clock::carry). Found by following the tutorial: its digest test, which travels a week, failed. - docs/tutorial.md: the code is as rustfmt prints it (a reader's
cargo fmt --checkfailed on the seeder and the tests), and the address field is labelled "address" so its messages match the form ("The address field is required.", not "url"). tests/tutorial/run.sh(CI jobtutorial): follows the tutorial step by step, as a reader does (steps found by their sentences,rnxcommands from its bash blocks), then runscargo fmt --check, clippy, the tutorial's tests, migrate, the seeder twice, and checks the app answers. A tutorial that can no longer be followed fails CI.- Fixed (#127):
rnx make:model --migrationnamed the table after the model in the singular (WaitlistSignup→waitlist_signup), unlike--resource, the docs and the examples.make:modelnow writes the plural (waitlist_signups,categories) in both#[model(table = …)]and the migration.make:module <name> --resource --model <Model>names the table after the model as well (news --model Article→articles, it wasnews); with the usual plural module (products) nothing changes.rnx make:migration create_x_tablekeeps the name it is given. - Fixed (#128): a kit
cardstretched to its row's height (two cards side by side in a grid, one longer than the other) put the extra space between its title and its body, so the shorter card's body sat lower than its neighbour's..rx-cardnow hasalign-content: start: the card keeps its own spacing and the extra height stays below.
#1.0.0-rc.2 · 2026-10-04
The second release candidate: the same API as rc.1, with rnx fixed so a new app passes
cargo fmt --check (#124). Install it with cargo install renox-cli --version 1.0.0-rc.2;
an app made by rc.1 moves over by changing renox = "1.0.0-rc.1" to "1.0.0-rc.2".
- Fixed (#124): a new app failed
cargo fmt --checkbefore anyone wrote a line: the starter kit'stests/home.rssorted its imports for names beforerenoxonly (renoxiumfailed,deskpassed), the plain app'stests/home.rshad two chains over rustfmt's width, and almost everyrnx make:*generator wrote or edited files rustfmt would change (where amodline or.module(…)lands depends on the name).rnx newand everyrnx make:*now put the Rust files they wrote or edited through rustfmt (each file on its own, so the rest of the app is left alone; the app'srustfmt.tomlapplies; nothing happens without rustfmt), and both stubs are formatted as they are. - README: install from crates.io, the first steps after
rnx new, and how to use Renox with Claude Code or another coding agent (theAGENTS.md/CLAUDE.mdevery new app has, and a snippet for other projects). The crates.io and docs.rs badges. - The docs site: search results styled as the list they are; an empty favicon (no 404 in the console).
#1.0.0-rc.1 · 2026-10-03
The first release on crates.io, a release candidate for 1.0.0: renox, renox-core,
renox-macros and renox-cli, all at this version. Everything below, from M0 to the v1.0
steps, is in it. Try it with cargo install renox-cli --version 1.0.0-rc.1; apps made by
that rnx new depend on renox = "1.0.0-rc.1" (Cargo picks a pre-release only when asked).
If nothing turns up, the same code becomes 1.0.0, and the semver promise
(docs/stability.md) starts there.
rnx newfrom crates.io writes a pre-release version whole ("1.0.0-rc.1", not"1.0", which would never pick it).
#v1.0 · The starter kit and the semver promise (V1e)
rnx new <name> --starter: the starter kit (Breeze and Jetstream as the yardstick), written over the plain app: the kit's sidebar layout with the notification bell;Authwith email verification, the account pages and notifications, landing on/dashboard; roles (admin,member) with thePermissionsmodule, every sign-up a member; a dashboard (sign-ups over a period, a chart, the person's recent activity); a users page where admins change roles (recorded in the activity log); the activity log page (Audit, a grid with exports); ausers:admin <email>command for the first admin; a seeder; seven tests. Works with--database postgresand--tailwind.- docs/stability.md states the 1.x promise: code keeps compiling (checked by cargo-semver-checks), deprecation before removal in 2.0, data and sessions survive upgrades, jobs queued by one 1.x release run on later ones, settings keep their names and defaults, security fixes for the previous minor release for six months (SECURITY.md too), the MSRV rises only to a Rust release six months old, generated files are the app's.
tests/cli/run.shmakes, lints and tests a starter app (CI'sclijob).
#v1.0 · The documentation site, the tutorial and the Laravel guide (V1d)
site/(packagerenox-site, not published): the documentation site, a Renox app on the UI kit. It compiles README.md, CHEATSHEET.md, every guide indocs/, the changelog and the project files into one binary; links between the files become site addresses, the doctests' hidden lines are left out, headings get anchors and a table of contents. Search,/sitemap.xml, ETags on the pages, systemd service and socket units and a Caddy recipe insite/README.md.- docs/tutorial.md: one app built from
rnx newto a server (Stash, a bookmarks app): a model and migration, a list page, an htmx form with toasts, a policy, a weekly mail from a scheduled job, tests with a factory and time travel, deploying with systemd and Litestream. - docs/laravel.md: Laravel → Renox, concept by concept.
- Both guides are compiled as doctests (
TutorialGuide,LaravelGuide). - docs/scheduling.md named
on_failure's arguments in the old order.
#v1.0 · The API audit's should-fix items (V1c)
Cheap now, breaking later: the rest of the audit, as the owner chose (all but renaming gate
to authorize_gate). Breaking, item by item:
- The state comes last in every closure:
App::command(name, about, |args, state| …),App::channel(name, |to, message, state| …),on_failure(|err, state| …),Auth::on_registered(|user, form, state| …), likelistenandreportalready were. A seeder gets theAppState(|state| … &state.db), not just theDb. AppState::routes,viewsandtranslatorare internal (state.url(…),view(),Langandt()cover them);RouteTableandViewsare no longer exported.state.disk(name)isstate.disk_named(name), likemailer_named.Factorykeepsdefinition()andfactory();Model::make(),create_one(&db)andcreate_many(&db, n)are gone:factory().make_one(),.create_one(&db),.count(n).create(&db).- Timestamps are
DateTime:FailedJob::failed_at,BatchStatus::created_at,WebhookCall::received_at/processed_at,maintenance::Down::since(still unix seconds in its file). Recipient's fields are private:user(),address(channel),locale().Session::nowisSession::flash_now;i18n::set_localeisi18n::remember_locale.Config::load/from_env/from_varsandTrustedProxies::parsereturnrenox::Result;Schedule::upcomingreturnsUpcomingRuns (name,at,zone).timezone::Zoneis opaque:Zone::UTC,Zone::fixed(seconds),"Asia/Jakarta".parse();chrono-tzis no longer part of the API.- Sealed:
auth::Viewer,db::Executor,relations::ForeignKey. Model'svalues,set_id,touch,set_deleted_atare#[doc(hidden)](implementModelwith the derive);queue::handler/JobHandlerare internal;shell::run_withis hidden;auth::Canis#[non_exhaustive].maintenance::down(storage, DownOptions::new().secret(…).retry(…)).Queue::retry(id) -> boolandQueue::retry_all() -> u64replaceretry(Option<i64>).DebugforConfig,AnalyticsConfig,MailConfigandStorageConfighidesAPP_KEY, passwords and secrets (andConfig::vars' values).renox::minijinjais re-exported; docs/stability.md listsbytesandhttpamong the public dependencies and no longer listschrono-tz.
#v1.0 · The API audit's must-fix items (V1b)
A read-only review of every public item before 1.0 found nine things that would be breaking to change later; the owner chose to fix all of them (and the should-fix ones, next). Every item here is breaking.
- Settings are enums, not strings:
Config::session_driver(SessionDriver),log_format(LogFormat),cache_store(CacheStore),MailConfig::mailer(mail::MailDriver) andencryption(mail::MailEncryption),StorageConfig::disk(storage::DiskDriver),WebhookCall::status(webhook::WebhookStatus); each#[non_exhaustive]withas_str()andDisplay.Config::timezoneis atimezone::Zone. An unknown value now fails when the config is read, naming the variable. MailConfig::from_envandStorageConfig::from_envreturnResult(an unknown driver or a port that isn't a number is an error naming<PREFIX>_…);App::mailerandApp::diskclosures returnResulttoo (|c| MailConfig::from_env(c, "NEWS")is unchanged).- Lifetimes are
Durations:Config::session_lifetime,remember_lifetime,Session::set_lifetime(Duration),auth::login(session, user, Some(duration)). The.envvalues stay in minutes. Notification:channels(&self, to: &Recipient)(Laravel'svia;channels_foris gone),to_database(&self, to, state) -> Result<Value>,to_channel(&self, channel, to, state).AppState::notify(to: impl Into<Recipient>, …)takes a user (&User,&AuthUser) or aRecipient;notify_tois gone.Job::failed(self, ctx: JobContext, error), likehandle: the hook sees the job's id, attempt and batch.validation::Localeis gone (English was its only variant):Validator::new(),Validator::rules_of(&form), andValidator::new().in_lang(&lang)for the app's translations;Validator::locale()is gone.Upload's fields are private:file_name(),content_type(),bytes(), andUpload::new(name, type, bytes)for tests;#[non_exhaustive].Migrationis built withconstmethods:Migration::new(name, up, down) .sqlite(up, down).postgres(up, down),name(); its fields are private anddb::Scriptsis gone.migrations!()writes the same calls.FromDb: docs/stability.md and the trait's docs list the types Renox promises; others that sqlx decodes still work, as an escape hatch tied to sqlx.
#v1.0 · Release readiness (V1a)
The first step towards 1.0, which the owner started after M34: everything needed to publish the crates, without publishing them yet.
renox,renox-coreandrenox-macrospin each other exactly (=in the workspace's dependencies): they're released in lockstep, as the macros write code against renox-core's items of the same release (docs/stability.md says so).renox-macroskeepsrenoxas a path-only dev-dependency, likerenox-core, socargo publishdrops it and the publish order (macros → core → renox → cli) has no cycle.- docs.rs builds
renoxandrenox-corewithpostgres,uuidandxlsx. rnx newfrom anrnxinstalled withcargo install renox-climakes apps depend on that release (renox = { version = "1.2" }), with AGENTS.md linking to the docs at its tag; anrnxinstalled from git still pins the commit.- A
semver checksCI job compares pull requests' public API with their base branch (cargo semver-checks --release-type minor), informational until the first release. - RELEASING.md: the release checklist (crates.io account,
cargo login, version, changelog, dry runs, publish order, tag, checks, a release candidate first, yanking).
#M34 · The rest of the parity review's small additions
Field::current_password(): the logged-in user's password (Laravel'scurrent_password).Password::uncompromised(): refuses passwords from known breaches through Have I Been Pwned's range API (only five characters of the SHA-1 leave the server; allowed with a warning when the service can't be reached). New dependency:sha1(RustCrypto, the samedigestassha2).Validator::finish_for(&state, user)runs those two with the database checks;Valid<T>uses it.finish(&db)still works, without them.Session::keep(&[keys])andSession::now(key, value)(Laravel'sflash()->now()).- Named error bags:
Validate::ERROR_BAG(#[validate(bag = "login")]with the derive),ValidationError::in_bag/bag,Session::flash_errors_in/errors_in/error_bag, the template functionserror('email', bag='login')anderrors_in('login'), andbag=on the kit's form fields.errorsanderror()show the default bag only. - Breaking:
ValidationErrorhas a private field (its bag), so it can't be built with a struct literal any more; useValidationError::new. - Several mailers:
App::mailer(name, settings),MailConfig::from_env(config, prefix)(its driver defaults to the app'sMAIL_MAILER),state.mailer_named(name),state.queue_mail_via(name, mail)(therenox.send-mail-viajob). MAIL_FAILOVER(MailConfig::failover): mailers tried in order when the default one fails; a permanent error (a bad address) isn't handed on; an unknown name fails at boot.relations::has_many_through: a parent's children through a middle model, in two queries.- Examples: jobs sends its sales reports through a
reportsmailer; the relations blog's category page lists the latest comments of its posts withhas_many_through. - Docs: validation.md (bags,
uncompromised,current_password), mail.md (more mailers, failover), relations.md (has_many_through), routing.md (keep,now), ui.md (bag=), operations.md, CHEATSHEET, README, the parity review and its PDF.
#M33 · Small additions from the parity review
The "small adds" the Laravel parity review still listed, before 1.0 freezes the API.
- Validation rules:
gt,gte,lt,lteagainst another field (numbers, dates typed or as text, text by length, lists, files; two texts that read as numbers compare as numbers);decimal(min, max);dimensions(&Dimensions)(pixels, read from the PNG, JPEG, GIF or WebP header);prohibited,prohibited_unless,prohibits;required_with_all,required_without_all;accepted_if,declined,declined_if;numeric,integer,multiple_of,min_digits,max_digits;json,ulid,timezone,mac_address,ascii,hex_color,doesnt_start_with,doesnt_end_with,not_matches. Each has an English message (renox.validation.<key>translates it), and the derive takes them (#[validate(gt("min", &self.min))]). Upload::dimensions(): an image's width and height.- Breaking:
Inspected::Filehas adimensionsfield and is#[non_exhaustive]; a pattern on it needs... - Route model binding:
Found<M>loads the row a route parameter names (the one named after the table, else the only one), by key or, when the parameter is named after a column ({slug}), by that column; a missing row is a 404 and default scopes apply. In the prelude. Routes::view(path, template),Routes::redirect(from, to)(302) andRoutes::permanent_redirect(301).Routes::etag(): anETagon the routes before it, and304 Not Modifiedfor a matchingIf-None-Match. Hashed after the page is rendered, so it works for views too.- Named disks:
App::disk(name, settings),state.disk(name),Storage::name,StorageConfig::from_env(config, "PREFIX")(<PREFIX>_DISK,_PATH,_BUCKET,_URL, the rest falling back toS3_*),StorageConfig::root. A named disk's public files are served at/_renox/disks/<name>/public/…, private ones throughtemporary_url. App::xsrf_cookie(): the CSRF token as anXSRF-TOKENcookie scripts can read, accepted back inX-XSRF-TOKEN(XSRF_COOKIE,XSRF_HEADER).TRUSTED_HOSTS(Config::trusted_hosts): otherHosts get a 400;APP_URL's host is always allowed and/healthanswers any host.- Examples: crud and the relations blog load models with
Found; the blog's feed and sitemap have ETags; backoffice's exports go to anexportsdisk; uploads refuses photos over 6000 pixels. - Docs: validation.md (the new rules), routing.md (
Found, view and redirect routes, ETags,XSRF-TOKEN, trusted hosts), operations.md, CHEATSHEET.md (more disks), README, the parity review and its PDF.
#Docs refresh after M32
- The Laravel parity review, until now the snapshot taken after M17, is rewritten for the code
after M32:
docs/audit/2026-10-laravel-parity.md(was2026-09-laravel-parity.md), with the milestone that closed each gap, Filament's features next to the kit and the grid, the gaps still open, and measured numbers. docs/audit/2026-10-laravel-gap-report.pdf, an English summary of it, replaces the Indonesian2026-09-laravel-gap-report.pdf(from M20b).docs/audit/2026-09-pre-1.0.mdsays every finding in it is closed.- README: the status after M32, what the examples show today, Filament's forms, infolists, actions, widgets and demo in the Laravel table.
- The guides, CHEATSHEET.md, llms.txt, CONTRIBUTING.md, the example READMEs and the new
app's AGENTS.md checked against the code. Corrections: docs/operations.md lists every
built-in command with its options (
migrate:fresh,db:seed,db:shell,route:list,ui:publish,schedule:workwere never mentioned) and each command's defaultRUST_LOG; docs/ui.md's sidebar example importsnotification_bell(it failed to render), names the four fields that takehide_label, and lists the missingrepeater,file,wizardandmedia_cardparameters;Routes::webhooktakes a path in docs/routing.md; AGENTS.md.stub'saction_sheettakesaction, noturl, and a cut sentence is repaired; CONTRIBUTING.md lists every CI check, the browser-check rule and the English-only rule; the last Indonesian words ("UMKM",create_produk) are gone.
#M32 · English only
Everything in the repository is now English: code, comments, docs, tests, and the examples' seed data, names, pages and mails. Renox no longer ships a built-in Indonesian locale.
- Breaking: the built-in
idtexts are gone. Validation messages, auth pages and mails, and the kit's texts are built in for English only. Apps add any other language withresources/lang/<locale>.json(renox.validation.*,renox.auth.*,ui.*), as before. An app that relied onAPP_LOCALE=idwithout a lang file now gets English. - Breaking:
validation::Localehas onlyEn(still#[non_exhaustive]);Locale::parsereturnsEnfor every input. rnx newno longer writesresources/lang/id.json.- The password check on the account pages names the field with the app's
renox.validation.attributes.<field>, when there is one. chart(…)month labels are English for every locale; axis ticks keep the locale's number separators.- Command prompts accept only English answers ("tidak" is no longer a no).
- examples/hello and examples/shop use Spanish (
es.json) as their second language; hello's routes are/hello/{name}and/language/{locale}. Every example's seed data, names and tests are in English. Place names,Asia/Jakarta, phone numbers and the rupiah currency stay: they are data, not language. - The framework's tests use a Spanish fixture (
crates/renox/tests/lang/es.json) for everything the Indonesian built-ins used to cover.
#M31 · A warm default theme and a type scale
The owner found the kit's look plain. Three directions were tried on the examples (the look as it was, "Warm", and a neumorphism-like "Soft"), and then Warm with a fixed type scale. The owner chose Warm with the scale as the default.
- A new default look in renox-ui.css:
- Inter for text and Poppins for titles and figures, bundled as SIL OFL 1.1 Latin
subsets (about 72 KB), served from
/_renox/fonts/…with immutable caching.renox_ui()preloads the text font. - A warm paper page, white surfaces with a hairline and a soft shadow, rounded-rectangle buttons, a white secondary button with a border, and an indigo accent. Dark mode included; text keeps WCAG AA.
- Inter for text and Poppins for titles and figures, bundled as SIL OFL 1.1 Latin
subsets (about 72 KB), served from
- A type scale:
--rx-type-display,-title,-heading,-lead,-body,-label,-noteand-caption, each a wholefont, used by every component and available to apps. Small capitals for table, grid, stat and infolist labels; a stat's figure is the largest thing on its card and shrinks with it; changes are tinted pills; a table's total and a card's price use the title face. - New tokens:
--rx-font-display,--rx-surface-border,--rx-button-radius,--rx-button-padding,--rx-button-secondary-*,--rx-input-shadow,--rx-badge-radiusand--rx-sidebar-bg. The--rx-text-*sizes follow the new scale. data-rx-theme="classic"on<html>keeps the previous look. Its selectors use:where, so an app's own:roottokens (a brand colour) still win.- Changed look: apps get the new theme when they update;
data-rx-theme="classic"restores the old one. - docs/ui.md "Themes and type"; the README's demo GIF re-recorded.
#M30 · Every example on the UI kit
The owner asked for every example to use Renox's own UI kit instead of markup and CSS of
their own. The audit found what the kit lacked (each kit app had copied a navigation bar
from the rnx new stub, the back offices had their own sidebars and headers) and five
examples that didn't use the kit at all.
- New in the kit (
renox/ui.html, docs/ui.md "Navigation and page structure"):navbar,nav_linksandnav_link, with a "Skip to content" link and a scrolling links row on phones;sidebar,sidebar_linkandsidebar_sectionwith therx-shell,rx-shell__mainandrx-shell__contentlayout;page_header,toolbar(filters, no "optional" marks),row_actions,list,columns,card_grid+media_card,link_tabs,thumbnail,progress,menu_button(a menu item for htmx);- the classes
rx-page--fill(a page as tall as the screen, for a grid that fills it) andrx-image; hide_labeloninput,select,textareaandcheckbox;cancel_labelandfieldsonconfirm;[hidden]always wins insiderx-page;- the texts
ui.skipandui.main_navigation(en, id).
rnx newwrites the layout with the kit'snavbar; itspublic/app.cssstarts empty.- Every example's pages are on the kit:
- hello, jobs, postgres and htmx-recipes moved from their own CSS. htmx-recipes'
recipes are now htmx attributes on kit components:
action_sheetwithtarget/swap,menu_button, acheckboxwithattrs, alistof fragment rows. - relations uses the kit, plus Tailwind for the Markdown bodies'
prose. - api's browser client is a kit page.
- grid's frame is
rx-page--fill, anavbarandrx-grid-fill, with the kit'sprogressin a cell. - backoffice uses
rx-shell,sidebarandpage_header; its sign-in layout is the built-in one with the brand mark. - shop, teams, crud, fields, uploads and webhooks dropped their copied navbar CSS. Shop's cards, filters, admin sections and row buttons are kit components.
- shop's and jobs' mails use the kit's mail layout and components.
- What is left in the apps'
public/app.css: shop's brand colour tokens and backoffice's printed invoice.
- hello, jobs, postgres and htmx-recipes moved from their own CSS. htmx-recipes'
recipes are now htmx attributes on kit components:
AGENTS.md.stubtells app agents to build pages from the kit.- The README's demo GIF is re-recorded on the kit's guestbook.
- examples/grid: a filter test no longer fails on a fake name with an apostrophe.
#M29c · Examples extended
Smaller additions to four examples, the last of the M29 plan. No framework API changed.
- relations is also a public blog:
- Markdown bodies.
- A title and description per post (
seo()withPost::summary). - Search (
?q=, every word in the title or body), an RSS feed (/feed.xml) and a sitemap (/sitemap.xml). - Styled with Tailwind: the input is in resources/css/app.css, and the built public/css/app.css is committed.
- api has a browser client: a static page in public/ that logs in for a token and uses
the API as a mobile app or single-page app would.
GET /api/mesays who the token belongs to and what it may do. - uploads runs on S3 with its
s3feature: public photos atSTORAGE_URL, private invoices behind presigned links. A test runs it against a real server, and CI'ss3job runs it on SeaweedFS. - jobs' mail:
- The monthly statement attaches the customer's orders as CSV and sends a hidden
copy to the books (
bcc). - Replies to receipts go to support (
reply_to). - The warehouse mail copies the manager (
cc).
- The monthly statement attaches the customer's orders as CSV and sends a hidden
copy to the books (
#M29b · examples/backoffice
A new example: the back office of a small business, in the style of Filament's demo, built from Renox's parts. No framework API changed.
- Grids for customers (edited in place), products (bulk activate, a stock level cell),
invoices (grouped by status, totals, remembered filters, row menu), each product's stock
ledger, staff (roles in a sheet) and the activity log (a model over
audit_logs). - Invoices: a form with line items (the kit's
repeater, each line validated withValidator::nested), drafts that take no stock, issuing that takes it through the ledger in one transaction (409 and a rollback when something is short), voiding that returns it, "Paid in cash", and a print page. - Online payments: Midtrans Snap or a Xendit invoice made through
state.http(tested withFakeHttp), and their webhooks marking the invoice paid and telling the cashiers. - A stock ledger (
stock_movements): received, damaged, counted (the difference to the shelf), sold, returned, imported; stock never below zero. - Products imported from a CSV file in a sheet (multipart), a savepoint per line.
- Invoice exports in the background: a bulk action queues a job that applies the grid's filters, stores the CSV and puts a link valid for a day in the bell.
- Roles (admin, cashier, warehouse) with permissions on every change, staff added by an admin (no registration) who verify their email, the activity log, typed company settings shared with every page, and sign-in pages in the company's colours.
- A dashboard: billed and collected against the period before, unpaid, overdue invoices, products running low. 16 tests.
#M29a · The examples checked and fixed
An audit of the 13 examples (README against code, tests, seeders, patterns) found bugs and gaps; this fixes them. No framework API changed.
- Fixed in the examples:
- grid: guests could edit, reorder, bulk-change and delete orders; those routes need a
login now and the tools show only then (
orders_grid(can_edit)). "Mark paid" and a status edit now setpaidtoo. - jobs: anyone could register and then get the admin mail; registration is off.
- shop: mail subjects and texts, and the admin's "new order" notification, were English
only; they follow the recipient's
users.locale(set by the language switch) now. A pickup order's mail no longer says "We'll send it to: Pick up at the store" (orders.pickup).shop:make-adminfinds an email in any case; an admin's cancel that lost a race is a 409, not an audited no-op. - teams: the public team page linked to app pages its host doesn't serve; it has its own layout with absolute links.
- htmx-recipes: a duplicate task was added by a plain form post; edits weren't trimmed;
the "nothing to do" note stayed after the first add; infinite scroll repeated a row
after an add (it goes by id now,
?before=).
- grid: guests could edit, reorder, bulk-change and delete orders; those routes need a
login now and the tools show only then (
- Tests for what READMEs claimed but nothing tested: webhook repeats for every provider, missing secrets and unknown orders; the blog index's query count; hello's photo sniffing; expiring invoice links and upload limits; crud's live validation, "Nothing changed" and restore by a non-owner; api's CORS and nightly token pruning; postgres' validation, overdue boundary and NULL ordering.
- Every seeder can run twice (a seeded database is left as it is), each example tests it,
and fields and postgres have seeders now. Every example has a
.env.example. - fields, webhooks and uploads are on the UI kit (tables, empty states, toasts); fields and uploads can delete (uploads removes the stored file too).
- Current patterns:
Routes::resourcein crud,Redirect::routewhere a route has a name,#[derive(Validate)]for plain forms, themoneyfilter (andAPP_CURRENCYin Rust mail) instead of hand-written "Rp", the infolist on grid's order page, dead flash lines and CSS gone; module docs name the generators that match the code.
#Actions: forms in sheets, icon buttons, shortcuts
- New kit component
action_sheet(id, label, action, title, …)(Filament's actions as the yardstick): a button that opens a sheet with a form sent by htmx. A 422 shows its messages under the fields inside the sheet; a success closes the sheet and resets the form (so do Cancel and Esc); the handler answers with aToastandHxRefresh(orHxTrigger, or a fragment fortarget/swap). - Sheets:
slide_over=true(at the side, full height),width(sm,md,lg,xl) andicon.confirmtakesicon(on its button) andmodal_icon(over its title,"warning"by default: confirmation sheets now show a warning icon). - New
icon_button(icon, label, href=…): only an icon,labelas its accessible name and tooltip.button,link_buttonandopen_buttontakeicon,badge(a count) andkey;buttonandicon_buttontakedisabledanddisabled_reason(focusable, the reason as tooltip);link_buttontakesnew_tab. - Keyboard shortcuts with
key="mod+s"(data-rx-key; ⌘ on a Mac, Ctrl elsewhere; plain keys don't fire while typing), witharia-keyshortcuts. Tooltips from anydata-rx-tip. New icons:edit,more,download,external,refresh,search,settings,box. - examples/shop: the admin product list has an "Adjust stock" action per row
(
PUT /admin/products/{id}/stock), edit and "view in the shop" icon buttons (disabled with a reason for hidden products), "New product" onn; the product form saves on ⌘S / Ctrl+S. On phones the row's actions show as icons and the price column is hidden.
#Dashboards: figures, charts and periods (renox::chart)
- New module
renox::chart(Filament's widgets as the yardstick):Period(an extractor for?period=7d|30d|90d|12m|mtd|ytd, 30 days by default;previous(),range,labels,bucket),Trend::of(query, column).over(period)withcount,sumandaverage(grouped per day or month in SQL, inAPP_TIMEZONE, empty buckets at 0), andSeries(labels,values,total,change_from,named). - New template function
chart(kind, data, …): line, area, bar (grouped or stacked), pie and doughnut, drawn on the server as HTML and SVG with no JavaScript library: clean ticks, legends, a crosshair tooltip and keyboard navigation (in renox-ui.js), a "Show the data" table, six series colours validated for colour blindness in light and dark (--rx-chart-1…--rx-chart-6). - New kit components:
stats+stat(a figure, its change with an arrow, a sparkline, a link),dashboard+widget(cards in a grid; lazyurlandpoll),period_filter. New template functionquery_with(key=value)(the query string with keys set,pagedropped). Kit textsui.chart.*,ui.period.*,ui.stat.vs_previous,ui.loading. .rx-segmented__itemalso styles links witharia-current="page".- examples/shop: the admin dashboard has a period filter, four figures, revenue against the period before, orders per day and orders by status (loaded on their own, every minute).
#Notifications: richer toasts, and a live notification bell
- Toasts (Filament's notifications as the yardstick):
Toast::body,Toast::link,Toast::actionwithToastAction::link/ToastAction::event(new_tab),seconds,persistentandid(a new toast with the same id replaces it).toasts(position=…):top(default),top-start,top-end,bottom,bottom-start,bottom-end. In the browser,Renox.toast({…})andRenox.dismissToast(id).ToastActionandToastKindare exported. Toasts already in a session still read; the JSON of a plain toast is unchanged. - New:
DatabaseMessage(success/info/warning/error,body,url,link,action,withfor the app's own keys) to return fromNotification::to_database, andDatabaseNotification::message().to_databasenow runs in the recipient's language, liketo_mail. - New:
Auth::new().notifications():unread_notificationsin every view, thenotifications.*routes (a page that is also the bell's panel, read/unread/open/delete, read all, clear) and/notifications/stream, a Server-Sent Events stream that pushes the unread count and new notifications (woken at once in the same process, and looking at the table every 15 s for other servers; each stream lasts five minutes; all end at shutdown). - New kit component
notification_bell(unread_notifications): a badge, a panel loaded on open, new notifications as toasts with an "Open" link; a link to the page without JavaScript. Built-in viewrenox/notifications.html(inlayouts/app.htmlwhen the app has it). Kit textsui.notifications.*(en/id). - New
Usermethods:notifications_before,notification,mark_notification_unread,delete_notification,delete_notifications. - examples/shop: the bell in its bar, its three notifications as
DatabaseMessages in the recipient's language, a toast with a link after creating a product. examples/jobs: its admin notification as aDatabaseMessage.
#UI kit: infolists, and the money, since, words and markdown filters
- New:
infolist(columns=…, inline=…)andentry(label, value, …)inrenox/ui.html(Filament's infolists): read-only labels and values in a grid.formatisdate,datetime,since,money,number,markdown,bool,color,imageorkey_value;badge(a kind, or kinds by value) andlabels;url,copyable,tooltip,hint,prefix/suffix,limit/words,placeholder; lists as commas, lines or bullets, withlimit_listfolding the rest behind "Show N more";{% call entry(label) %}for any markup.repeatable(label, items, columns=…)shows a list of records, each a small infolist. - New template filters:
money(withcurrency,decimals,divide_by),since("3 hours ago", followingTestApp::travel),words(n)andmarkdown(pulldown-cmark; HTML typed in is shown as text and only http(s), mailto, tel and relative links are kept).renox::format_moneyformats an amount in Rust. - New setting:
APP_CURRENCY(an ISO 4217 code,IDRby default; anything else fails at boot),Config::currency. - New kit texts (en/id):
ui.yes,ui.no,ui.show_more,ui.since.*. The kit's copy button also copies adata-rx-copy-text. - New dependency:
pulldown-cmark(pure Rust, without its default features). - examples/shop: the order page is an infolist with a
repeatableof its lines, and every price usesmoney(the app'srupiahfilter is gone, so English pages showRp 75,000and Indonesian onesRp 75.000). examples/fields: a read-only product page (/products/{id}) showing every field kind.
#UI kit: options from the server, added and renamed in the select
- New:
select(…, options_url=…): the options come from the server as you type (GET ?q=…, after a short pause, older requests cancelled), for lists too long for the page; a value sent back after a failed submit gets its label fromGET ?values=…. - New:
select(…, options_url=…, editable=true): what was typed can be added ("Add “…”",POST label=…) and is chosen at once; the chosen option (or a chip, withmultiple) can be renamed in place (POST _method=PUT value=…&label=…; Enter saves, Escape gives up). A 422 shows its message under the field. - New:
renox::select:SelectOption({value, label},SelectOption::new(id, name)) and theOptionQueryextractor (q,values,is_lookup(),values_as::<i64>()). Options in the template may also beSelectOptions (maps withvalueandlabel). - New kit texts (en/id):
ui.searching,ui.load_failed,ui.add_option,ui.edit,ui.editing,ui.save_failed. - examples/shop: the admin's category select asks the server, adds categories and renames
them (
admin/categories.rs); the product form no longer loads every category.
#Examples: the kit's tabs and datalist
- examples/shop's admin dashboard puts low stock, notifications and recent activity on the
kit's
tabs+tab_panel; examples/teams' project name suggests common names withinput(…, datalist=…)(#90).
#Docs: catch-up after the UI kit form fields
has_old()in docs/validation.md and CHEATSHEET.md,Session::has_old_inputin docs/routing.md,renox_calendar()in CHEATSHEET.md's template functions.
#UI kit: more form fields
Closer to Filament's form fields, on the kit's own rules (labels, errors, keyboard, no JavaScript needed).
Stage 3:
- New: nested form names. A form with a name like
lines[0][name]is read as a tree, soValidfills aVecof structs or a map; errors are keyedlines.0.name, anderror(),old(), the kit's slots, live validation andrenox.jstake either spelling. A nested field is labelled by its own name (lines.0.name→ "name"; translations: the full name, thenlines.*.name, thenname). Plain forms are read as before. - New:
renox::KeyValues(also in the prelude): ordered pairs from the kit'skey_value, stored as a JSON list of pairs (a JSON object loses its order in PostgreSQL'sJSONB). - New kit fields:
tags_input;select(…, multiple=true, searchable=true)(an ARIA combobox over the native select);repeater(rows added, removed and moved, renumbered; a{% call(row, prefix) %}block draws a row);key_value;wizard+wizard_step. - New kit texts (en/id):
ui.search,ui.no_results,ui.remove,ui.add_row,ui.move_up,ui.move_down,ui.key,ui.value,ui.back,ui.next. - Examples: teams' "New team" is a wizard with a repeater of members (
v.nested+afterper row); fields gets tags and specifications (Json<KeyValues>, a new migration); shop's admin picks a category in a searchable select.
Stage 2:
- New:
toggle_buttons(one or, withmultiple, several choices as a row of buttons). - New:
file: a drop zone listing the chosen files, with image previews (preview) and the stored file (current). - New:
date_picker: a date typed or picked in a Cally calendar in a popover, sent asYYYY-MM-DD; the template functionrenox_calendar()loads Cally (the picker does, once). - New:
show_when/hide_when: fields shown while another field has a value; hidden ones are disabled, so they aren't sent. - New:
input(…, revealable=true)(show the password) andcopyable=true(copy the value). Renox's own sign-in, registration, reset, confirmation and account pages userevealable. - New kit texts (en/id):
ui.show_password,ui.hide_password,ui.copy,ui.copied,ui.choose_file,ui.choose_files,ui.current_file,ui.choose_date,ui.previous_month,ui.next_month. - Examples: shop's checkout asks courier or pickup (
toggle_buttons+show_when+required_if); uploads on the kit'sfile; fields withdate_pickerand a copyable key.
Stage 1:
- New:
radio(a radio group in afieldset, options with descriptions) andcheckbox_list(ticked values sent as a list), bothinlineor incolumns. - New:
form_grid(columns)andfieldset(legend)for form layout; every field takesspan. - New:
inputtakesprefix,suffixanddatalist;input/textareatakereadonly; every field takesdisabled;textarea,selectandcheckboxtakeid. - New: the template helper
has_old()(the previous request was a failed submit) andSession::has_old_input. - Fixed: after a failed submit, a checkbox the user unticked came back ticked when its
default was
checked=true. - Fixed: the error summary's links missed a field given its own
id; they now find the field by its name.
#Examples: the rest of docs/grid.md
examples/grid gets /follow-up: two grids on one page with their own query string prefixes
(prefix), and the column options docs/grid.md describes that no example used yet: link,
tooltip, wrap, limit, sortable(false), filterable(false), per_page and
Column::related(…).numeric().
#Guides for routing, validation, mail and scheduling
- New guides, each compiled as a doctest: docs/routing.md (
RoutingGuide: routes, groups, domains, extractors and responses, middleware and guards, sessions, CSRF, cookies, signed URLs), docs/validation.md (ValidationGuide:Valid<T>, every rule,#[derive(Validate)], hooks, messages), docs/mail.md (MailGuide: mail and notifications) and docs/scheduling.md (SchedulingGuide: the scheduler, events, the cache and locks, app commands). Before, these areas were only in CHEATSHEET.md. - README, llms.txt and CLAUDE.md list the new guides.
#Docs audit after M28
Fixes found by the audit:
- Data grid: date-time filters (the date range and the advanced filter's
on/before/after) take whole days ofAPP_TIMEZONE, the zone the cells are shown in. They took UTC days, so withAsia/Jakartaa filtered day was off by seven hours. User::delete_account(the account page's "delete account") also deletes the user'sgrid_preferencesrows, which used to stay behind.- New:
notifications:prune [--days 30](Auth module) andrenox::auth::prune_read_notifications(db, age)delete notifications read more than that ago; unread ones stay. Nothing pruned them before.
Docs:
- New guide: docs/grid.md for
renox::grid(compiled as theGridGuidedoctest). - CHEATSHEET: the missing patterns filled in (queries, the UI kit's macros, the data grid, commands, config).
- docs/postgresql.md: an unknown
DATABASE_URLscheme is refused at boot. docs/operations.md: when HSTS is sent, housekeeping fornotificationsandgrid_preferences, the prune functions. docs/authorization.md: async gates andcan(). docs/stability.md: the#[non_exhaustive]list. - README and CLAUDE.md: the
renoxcrate's default features includehttp. - ROADMAP: the default scope API (
#[model(default_scope = "…")],Model::unscoped()),gate_before's closure, the principles and app layout, and the audit's open issues. - The examples' READMEs and module docs, llms.txt, and the new-app
AGENTS.md.stubandenv.stubbrought in line with the code.
#M28e · Data grid: related columns, an advanced filter, remembered state, polling
- New:
Column::related,Column::count_of,Column::sum_of(values from other tables, sorted, filtered and searched),Grid::advanced_filter(rules with operators per kind, all or any),Grid::remember(the grid's state in the session) andGrid::poll. - Changed: grid sorting puts empty values last in either direction, on both databases.
#M28d · Data grid: cards on phones, more kinds of cells
- New:
Grid::cards_on_mobile(): rows as cards on phones, with sorting and filters in the toolbar. - New:
Column::image,Column::color, andbadges,icons,description,tooltip,wrap,limit,link,copyableandroundon columns (grid::Kind::Image/Color).
#M28c · Data grid: summaries and groups
- New:
Column::summary(grid::Summary: sum, average, range, count) in a footer that stays at the bottom, over every filtered row;Grid::groups/Grid::group_by: rows grouped with a folding heading and the group's own summaries. - Fixed: a grid column whose value the row doesn't have shows empty instead of failing the page.
#M28b · Data grid: selecting rows, bulk and row actions
- New:
Grid::bulk_actionwith row checkboxes, a "select all matching" choice andGrid::selected(grid::Selectionfrom the form);Grid::row_actionfor each row's menu;grid::Action(new,link,method,confirm,danger) with a confirmation dialog.
#M28a · Data grid: search, filter chips, row links, several grids on a page
- New:
Column::searchable()and the toolbar's search box (every word, any searchable column, as you type); the active filters as chips that clear one at a time. - New:
Grid::row_url(a click on a row opens it),Grid::empty_state, andGrid::prefixfor several grids on one page; a grid keeps the page's other query string values.
#M27d · Data grid: moving and resizing columns
- New: drag a heading to move its column; drag a heading's edge to resize it (double-click for
the automatic width, arrow keys from the keyboard). Widths are kept per user with the other
column choices (
GridPrefs::widths).
#M27c · Data grid exports
- New:
Grid::exports()andGrid::export: CSV, Excel and a print page (for PDF) of every row the filters match, in the user's columns. - New: the
xlsxfeature (rust_xlsxwriter, off by default) for Excel exports, with merged headings, typed numbers and dates, and frozen panes.
#M27b · Data grid: details, editing, row order, merged cells
- New:
Grid::audit()opens who created and last changed a row (and when) under it;Grid::details()adds what the page draws for_details. - New:
Column::editable()withGrid::edit_url: cells edited in place (double-click, Enter, F2) or a whole row in edit mode, sent asPATCHand validated by the app'sValid<T>, errors shown in the cells. - New:
Grid::reorder(column, url): rows dragged (or moved with the arrow keys) into order while sorted by that column, saved withgrid::RowOrder::save. - New:
Column::merge(): neighbouring equal values share one cell, nested from left to right. Grid::sort_bytakes several keys ("region,city,-total"); the default sort no longer goes into the URL.
#M27a · Data grid
- New:
renox::grid, a data grid for dashboards:GridandColumnin Rust (text, number, money, date, datetime, bool, select, tags and custom columns),GridRequest, and thegridmacro ofrenox/grid.html. It fills its container with only the rows scrolling, filters each column by its kind from the query string (text with%patterns, ranges, a date range calendar, choices), sorts and pages on the server, groups headings (Column::under), shows different columns on phones and desktops, and freezes columns left or right. - New: the column menu's choices are kept per user in the new framework table
grid_preferences(migration00010101000220_create_grid_preferences_table, in every app), or in the session for guests (POST/DELETE /_renox/grid/{grid}/prefs). - New:
{{ sparkline(values) }}, a small line or bar chart as inline SVG. - Bundled: Cally 0.9.2 (MIT), the calendar web components the date filters use
(
renox::CALLY_VERSION). - New example: examples/grid, a sales dashboard.
#M26c · Tests for the CLI and the weak spots
- New:
App::run_args(["migrate:status"])runs any command of the app binary from code (a test, or a program driving the app), asmy-app migrate:statuswould. - Fixed: a handler taking
renox::Pathon a route without that parameter answered 404; that is the app's mistake, so it is a 500 now. A value that doesn't parse is still a 404. - Tests for
rnx: argument parsing,make:module --resource(fields, plurals, every file written and registered),rnx new(every placeholder filled, PostgreSQL apps), theservefingerprint,key:generate's.envedit, Tailwind detection. Tests for the binary's built-in commands,DbError's questions anddb::Json.
#M26b · Docs brought up to date
- Every public item has a doc comment now (379 were missing, mostly struct fields and
methods);
#![warn(missing_docs)]keeps it that way in CI. RedirectExtis sealed anddb::InvalidUlidis#[non_exhaustive](neither was meant to be implemented or built by apps).- Guides, README, CHEATSHEET, llms.txt, the example READMEs and the files
rnx newwrites now cover M22–M25: model keys, savepoints,Encrypted, domain and fallback routes,route_is,class_names, loops, plural ranges, factory states,#[derive(Validate)]anddetect_locale.
#M26a · Key fixes, and examples for M22–M25
- Fixed:
insert_manyandupsertleft the key out forUlid,UuidandStringkeys (a NULL key on SQLite, an error on PostgreSQL). They now make ULIDs and UUIDs, writeStringkeys (a missing one is an error), andupsertmay useidas its conflict target for such keys. - Fixed:
unique(…).ignore(id)took only ani64; it takes any key now. - Fixed: a
Routes::fallbackanswer became a 404 whenever the app had apublic/directory (tower-http'snot_found_serviceoverrides the status); a fallback's own status (a redirect, a 200 page) is kept now. - examples/crud: the form derives
Validatewith apreparehook;products:importimports a CSV in one transaction with a savepoint per line; the seeder uses factory states and a sequence. - examples/api: products are keyed by
Ulid(public ids, and the list's cursors). - examples/shop: "recently viewed" with
session.pushand{% break %}, stock texts with plural ranges,class_nameson sold-out cards,Redirect::routeafter checkout and in the admin. - examples/teams: each team's public page on its own host (
Routes::domain("{team}.localhost", …),DomainParams, a domain fallback), with aslugcolumn (a new migration).
#M25 · Derived validation and the browser's language
#[derive(Validate)]: rules as attributes on the form's fields,#[validate(required, max = 100, unique("users", "email"))];each(…),distinct,rename = "…",label = "…".#[validate(hooks)]withimpl ValidateHooksforprepare,authorizeandafter.impl Validateby hand still works.App::detect_locale(): visitors who haven't chosen a language get their browser's (Accept-Language) when the app has texts for it; responses carryVary: Accept-Language.rnx make:module --resourcewrites the form with#[derive(Validate)].- examples/hello: its form uses the derive, and the guestbook follows the browser's language until a visitor picks one.
#M24 · Laravel's leftovers from M21
Routes::domain("admin.example.com", routes)andRoutes::domain("{account}.example.com", …)with theDomainParamsextractor: routes for other hosts, where the same path may mean another page. A host that matches a domain gets that domain's routes (plus Renox's own and the public files); other hosts get the routes without a domain.route:listshows a DOMAIN column when there are any.Routes::fallback(handler): what answers when no route and no public file does.route_is('admin.*', …)andrequest.routein views; theCurrentRouteextractor.Redirect::route("products.show", &[&id])?andRedirect::intended(&session, "/")(RedirectExt, in the prelude).session.push(key, value)andsession.increment(key, by).Product::factory().count(3).state(f).sequence(|i, p| …).create(&db)(andmake,make_one,create_one).- Plural ranges in translations:
"{0} Sold out|[1,5] Only :count left|[6,*] In stock". {% break %}and{% continue %}in templates, andclass_names('tab', {'active': on}).RouteInfohas adomainfield (it's#[non_exhaustive]);RouteTable::name_of(new) takes the domain.- examples/shop's admin nav marks its section with
route_is.
#M23 · Savepoints and encrypted fields
Transaction::savepoint(|tx| Box::pin(async move { … })): a transaction inside the transaction; onErronly its changes are undone and the transaction goes on (on PostgreSQL too, after a failed statement). Savepoints nest.renox::db::Encrypted<T>: a model field stored encrypted withAPP_KEY(AES-256-GCM, sealed JSON in aTEXTcolumn) and read asT;Option<Encrypted<T>>for nullable columns. Works in handlers, jobs, commands, seeders and tests alike: the app'sDbcarries the key. ItsDebughides the value; the column can't be searched.DbValuehas a new variant,Encrypted(it's#[non_exhaustive]).- examples/teams keeps its webhook secret in an
Encrypted<String>field.
#M22 · Model keys other than integers
- A model's key is its
idfield's type:i64as before, orrenox::db::UlidandUuid(renox'suuidfeature; a v7), both made on insert, or aStringthe app sets.Modelhastype Key: ModelKey;id(),set_id,find,find_or_404andfind_manyuse it. renox::db::Ulid: sortable 26-character ids (monotonic within a millisecond), stored as text, serialized as text; a malformed one inPath<Ulid>is a 404.renox::uuidis re-exported with theuuidfeature.Model::insert: always an INSERT, with the key set or a new one.- Relation loaders are generic over keys:
belongs_to,has_many,count_many,sum_manyandMorphreturn maps keyed by the parent's key;Pivot<L = i64, R = i64>names the two sides' key types (Pivotalone is unchanged). chunkandcursor_paginatework with any key type (cursors are the key's text).rnx make:model Invoice --key ulid|uuid|string [-m]writes the model and its migration.- examples/fields keys its products by
Uuidinstead of an extrapublic_idcolumn (runmigrate:freshthere). - Breaking: generic code over models that uses
id()as ani64needsM: Model<Key = i64>.ForeignKeytakes the key type (ForeignKey<K>). Hand-writtenimpl Modelblocks addtype Key = i64;.createnow always inserts: a model created with a non-zeroidkeeps it (it used to update that row).
#After M21 · Small fixes
/favicon.icoanswers204 No Content(cached for a day) unless the app haspublic/favicon.ico. Browsers ask for it on every site; the 404 it got before ran the whole middleware stack, rendered the error page and logged a console error each time.- The workspace dev profile uses
debug = "line-tables-only"(#68): full debug info made the test binary 415 MB and workspace builds ran out of memory.
#M21i · The examples' tests, typed commands and the stubs
- Fixed:
TestApp::traveldidn't reach the in-memory rate limits (Routes::throttle,throttle_by) or the login lock: they measured time withInstant. They now read Renox's clock. - Fixed: after
TestApp::travelpast the session lifetime,TestApp's CSRF token andacting_asstill used the old session, so the next form post got a 419; they now see the moved clock, as the server does. - examples/jobs:
App::reportposts errors to a chat webhook (ERROR_WEBHOOK_URL); tests use time travel for retries and unique jobs,fake_events,fake_notificationsandfake_httpfor the reporter. - examples/api, shop, hello: tests use
travelinstead of rewriting dates in SQL, plusassert_view,assert_json_pathandassert_json; shop runs its daily task by name. - examples/hello:
entries:pruneis a typed command that asks before deleting (--force). - examples/fields: colours checked with
each+one_ofanddistinct. rnx new:tests/home.rsshowsassert_viewand time travel;.envlistsAPP_HOST,DATABASE_POOL_SIZE,SESSION_LIFETIME,REMEMBER_LIFETIME,SESSION_COOKIEand the paths;AGENTS.mdlists every guide and adds traps about the clock and test fakes.
#M21h · The examples on the UI kit
- examples/shop and examples/teams: every page on the UI kit (navigation bar with an account
menu, kit fields and tables, badges, toasts instead of flashed messages, confirmation sheets
for deleting and cancelling, live validation, an error page in the layout); the shop
rebrands the kit's accent.
route()query arguments for filter links; teams' secret usesrenox::random_token(). - examples/htmx-recipes: an out-of-band count (
.fragment("row").also("count")),HxRetarget/HxReswapfor a duplicate task, toasts over htmx,route()for the scroll loader. - Fixed: toasts with non-ASCII text (curly quotes, accents, emoji) were dropped from htmx
responses, because a header can't hold them raw;
HX-Triggernow escapes them in its JSON. - Fixed: a toast returned with
HxRefreshwas sent inHX-Triggerand lost on the reload; it now waits in the session, as withHxRedirect. - Fixed: error pages didn't get
App::sharevalues, so a layout using one (a cart count) failed to render them withAPP_DEBUG(Renox's page showed instead).
#Docs and examples checked against M21 (#63)
- The socket recipe stops the service before enabling the socket (systemd can't listen while
the app holds the port); the shop admin's product delete asks in the kit's confirmation
sheet (its
hx-confirmnever ran); the shop's order policy checks the admin role itself and usesusers_with_role; crud's layout shows flashed messages;rnx make:mailprints how to actually send the mail; authorization.md, stability.md, the README, llms.txt and the ROADMAP no longer describe pre-M21 limits.
#M21g · Database sessions and deploys without refused connections
SESSION_DRIVER=database: sessions in asessionstable (new framework migration00010101000210), the cookie holds only an id; a new id at each login and logout; rows keyed by the id's SHA-256;user_idcolumn. Cookie sessions carry over when switching.session:prune,Session::prune_expired(&db).- Every app gets the
sessionstable (with either driver): runmigrateafter upgrading. serveaccepts a listening socket from systemd (socket activation,LISTEN_FDS).rnx make:deploywritesdeploy/<app>.socket; its README explains deploys without refused connections, migrations that old and new code both accept, and two copies behind Caddy.ToastandToastKindare#[non_exhaustive](build toasts withToast::success(…)and friends).
#M21f · Form requests, more rules, auth pages on the kit
- Form requests:
Validategains optionalprepare,authorize(403 before the rules) andafter(async checks once the rules pass, errors shown on the field), withFormContext. - Rules:
alpha,alpha_num,alpha_dash,lowercase,uppercase,starts_with,ends_with,uuid,ip,size,required_without,prohibited_if,Validator::distinct; English and Indonesian messages. - Renox's sign-in and account pages use the UI kit (and have stacks). Deleting the account asks
in a kit sheet instead of a browser dialog. The kit's
inputtakesid=. - examples/teams adds members through a form request.
- Changed: apps that override
renox/auth/*.htmlkeep their files; apps that styled the old markup (.card,.error,.statusinrenox/auth/layout.html) should restyle for the kit's classes.
#M21e · Tailwind, stacks and typed commands
- Tailwind CSS without Node:
rnx new --tailwind; an app withresources/css/app.cssgets Tailwind inrnx serve(watch) andrnx build(minified) intopublic/css/app.css.rnx tailwind [--watch] [--minify],rnx tailwind:install. The standalone CLI (v4.3.3) is downloaded once and checked by SHA-256;TAILWIND_BINandRNX_CACHE_DIRoverride. - Stacks:
{{ stack('scripts') }}in the layout;{% call push('scripts') %}…{% endcall %},prepend, andonce='key'from pages, blocks and components.rnx new's layout hasstack('head')andstack('scripts'). - Typed commands:
impl AppCommandon a clapParser,App::typed_command::<T>();--help, argument errors with the usage.renox::clapis re-exported.rnx make:commandwrites one. renox::prompt:ask,ask_or,secret,confirm,choice;answering(…)for tests.- examples: shop's
shop:make-adminis typed and asks for a missing email; crud's form pushes into the head.
#M21d · Errors, logs and debugging
- A request id per request: kept from a proxy's
X-Request-Idwhen it looks like one, else generated; in the log span, the response header, error reports and theRequestIdextractor. LOG_FORMAT=json(one object per line) andLOG_FILE(append to a file).App::report(|report: ErrorReport, state| async { … })for 500s, jobs that failed for good and failed scheduled tasks, run in the background.- Error pages use the app's layout:
errors/{status}.html, thenerrors/default.html(new inrnx new), with every page global plusstatus,reasonanddetail. Renox's own error page uses the UI kit. route('name', id, q=…, page=2): named arguments become the query string.- Named rate limiters:
App::rate_limiter("api", |req| Limit::per_minute(60).by(…))andRoutes::throttle_by("api");renox::rate_limitis public. /_renox/debugwhile developing (APP_DEBUGandAPP_ENV=local): the last 50 requests with status, time, view and SQL, flagging likely N+1 queries.- Fixed: JSON error responses dropped the headers of the error, e.g.
Retry-Afteron a 429. - examples/crud has an error page in its layout; examples/api uses a named limiter (per user, per IP for guests).
- Changed: in the error template's context, the debug request line is
request_line(it wasrequest, which hid therequestglobal). An app's ownrenox/error.htmlthat printed{{ request }}should print{{ request_line }}. An existingerrors/default.htmlis now used for every error status.
#M21c · Scaffolding and test tools
Routes::resource(path, name, Resource::new().index(..).create(..).store(..).show(..).edit(..) .update(..).destroy(..))with Laravel's route names.rnx make:module products --resource --fields "name:string price:money …": model, factory, migration, validated form, handlers, UI-kit views and tests. New generators:make:factory,make:seeder,make:test,make:notification,make:event,make:rule,make:middleware.rnx newapps use the UI kit (a navigation bar, an account menu, toasts).TestApp:travel,travel_back,at_travelled_time;fake_events(emitted,assert_emitted,assert_not_emitted);fake_notifications(notifications,assert_notified,assert_notified_to,assert_nothing_notified);assert_session_has,assert_session_missing,session_get,assert_authenticated,assert_guest;serve()for browser tests.TestResponse:assert_view,json_path,assert_json_path,assert_json, and aviewfield. Changed: code building aTestResponseliteral must addview.- docs/testing.md.
#M21b · Views, components and the UI kit
- Components see the request:
old,error,errors,t,can,auth,request,flash,csrf_fieldwork inside imported macros;once(key);rnx make:component name. - The UI kit
renox/ui.htmlafter Apple's Human Interface Guidelines, with its styles and script ({{ renox_ui() }}):- fields (
input,textarea,select,checkboxwithswitch) andbutton/link_button; card,group,alert,badge,form_errors;sheet/open_button,confirm,menu,tabs/tab_panel,table,empty;- dark mode, WCAG AA contrast, 44 pt targets, keyboard support, reduced motion.
rnx make:component --ui(ui:publish) copies the kit into the app. See docs/ui.md.
- fields (
Toast(success,info,warning,error) as a response part, and{{ toasts() }}.View::also(block)for out-of-band fragments;HxRetarget,HxReswap,HxPushUrl.- Live validation:
<form data-live-validate>checks fields againstValid<T>without running the handler. - Built-in
ui.*texts in English and Indonesian. - examples/crud uses the kit and toasts.
#M21a · Rough edges
User::has_role/User::has_permission(the current request's roles) for policies andgate_before;permissions::users_with_role(&db, role)./confirm-passwordreturns a guarded POST/PUT/DELETE to the page its form was on;TestApp::confirm_password().Db::retrying(n, || async { … }): retried on conflicts, borrows from the caller, may roll back and return a value.- A batch's
then/catch/finallyjobs see the batch inJobContext::batch_id(framework migration00010101000120_add_callback_of_to_jobs: runmigrate);TestApp::run_all_jobs(). renox::anyhowre-exported;Error::permanent_message.renox::db::capture_queries(future)returns the SQL a future ran (requests throughTestAppincluded).Morph::count_many;renox::random_token();renox::context::Current<T>as a handler argument; seeders run in the app's context (renox::context::app()).- A
ValidationErrorfrom a model hook or a handler keeps the old input on plain forms. - Changed: the prelude's
Pathisrenox::Path: a route value that doesn't parse (/orders/abcforPath<i64>) is a 404 page instead of a plain-text 400. - Examples: shop's checkout uses
db.retrying; relations counts queries withcapture_queriesand likes withMorph::count_many; jobs usesrun_all_jobs.
#M20c · Dashboard, localized mail, HTTP client, storage
renox::http(state.http): get/post/put/patch/delete with query, headers, bearer/basic auth, JSON/form/raw bodies, timeouts, retries;Response::json/text/error_for_status. A new default featurehttp(server-eventsneeds it).TestApp::fake_http():on(pattern, FakeResponse),sent(),assert_sent,assert_not_sent,assert_sent_count; requests without a fake fail.- Schedule pings:
ping_before,then_ping,ping_on_success,ping_on_failure. - The queue dashboard:
.module(renox::queue::Dashboard)at/_renox/queue, gated byview-queue-dashboard;Queue::stats()(QueueStats) andQueue::recent_batches. - Localized mail and notifications:
t()andapp.localein mail views,Recipient::in_locale/Recipient::locale()(ausers.localecolumn is read if present),Notification::channels_for(to),state.mail_view_in(locale, …),state.lang(locale),state.current_lang(),renox::i18n::{current_locale, with_locale, set_current_locale}. - Mail components:
renox/mail/components.htmlwithbutton,panel,table,divider. - Storage:
list(prefix)(FileInfo),copy,rename,size,delete_all(prefix), on the local disk and S3. - examples/jobs: the charge job calls the payment gateway with
state.http(basic auth, an idempotency key, 402 → permanent, 5xx → retried); unset,PAYMENT_GATEWAY_URLis a sandbox route in the example; the tests fake it withapp.fake_http(). The admin gets the queue dashboard. README, docs/queue.md, docs/operations.md, llms.txt and the new-appAGENTS.mdcover the HTTP client, the dashboard and localized notifications.
#Guides and examples for M18–M20b
- Guides, compiled as doctests: docs/authorization.md (gates,
policies, roles and permissions,
gate_before, token abilities, tenants, password confirmation, audit) and docs/queue.md (retries,dispatch_in, priority, unique jobs, middleware, encrypted payloads, chains, batches, testing). - New example
examples/teams: a multi-tenant SaaS with a default scope that fails closed, the current team inrenox::context, per-team roles on the pivot,unscoped()for admin code, names unique per team,gate_before, and an encrypted secret behind password confirmation. - examples/jobs: payment as a chain with an encrypted, rate-limited job and a
failedhook, ahighqueue, unique reminders, and statements as a batch with an htmx progress bar. Changed: the receipt goes out after payment, not when the order is placed. - examples/crud: model hooks (a slug, a cached count) and
save_changes. examples/relations: likes throughMorph, and a query-count test. examples/shop: product delete behindrequire_password_confirmed, checkout intransaction_retrying. - Fixed:
User::get::<bool>on an app's own BOOLEAN column returnedNoneon SQLite (stored as 0/1), so agate_beforebuilt on it never fired. - CLAUDE.md §4.11 and CONTRIBUTING ask every milestone to keep README, the guides, the agent stub and the examples in step; the parity review got a status note; the gap report PDF covers M20b; ROADMAP M21 lists the rough edges the examples ran into.
#Docs and examples catch-up after M20b
- Docs brought in line with M18–M20a: the README's feature tour, Laravel table and status;
docs/operations.md (what
APP_KEYnow protects, which failure rows the chaos test really checks, the scheduler's time zone, tables to prune, cache locks across servers); docs/postgresql.md (row locks); docs/stability.md (chrono-tz); SECURITY.md (raw SQL fragments); the new-appAGENTS.md(traps from M18–M20a); llms.txt; CLAUDE.md. - ROADMAP: M18/M19 items ticked but not built are corrected (no
exists_many, no savepoints, no breached-password check yet) and API names fixed (EmailVerified,load_with_pivot,transaction_retrying,create_token_with). User::set_password's doc says it ends this session too (useauth::change_password).- Examples use the new APIs:
- shop: roles from the
Permissionsmodule (require_role), theAuditmodule on order status changes,save_only. Changed: theusers.rolemigration is removed; recreate a local shop database (migrate:fresh --seed). - api: tokens with abilities and an expiry (
create_token_with,require_ability), a nightly token prune,cursor_paginate,DELETE /api/products/{id}. - jobs: daily (weekdays) and weekly (
cron) reports inAsia/Jakartawithon_failureand a cache lock; counts and sums in SQL. The mail template is nowmail/sales.html. - relations:
count_many,where_has, agroup_by+select_asreport, pivot columns (pinned, timestamps) withattach_with/update_pivot/load_with_pivot. - hello: the account page (
Auth::new().account()).
- shop: roles from the
- Tests: M20a's cache APIs are routed in
send_handlers.rs; the read-only storage test skips when run as root (root ignores directory permissions). docs/audit/2026-09-laravel-gap-report.pdf: what Renox still lacks against Laravel after M20a (in Indonesian).
#M20b · Queue
- Priority:
queue:work --queue high,defaultdrainshighfirst;dispatch_on(queue, job). - Unique jobs:
const UNIQUE_FORandfn unique_id; a second dispatch returns the queued id. - Encrypted payloads:
const ENCRYPTED: bool = true. - Middleware:
fn middleware(&self) -> Vec<Middleware>withMiddleware::without_overlapping(key)andMiddleware::rate_limited(key, max, per); a held-back job keeps its attempts. async fn failed(self, state, error)onJob, run once a job fails for good.- Chains (
state.queue.chain().then(a).then(b).dispatch()) and batches (state.queue.batch(name).push(job)…then/catch/finally/allow_failures().dispatch(),batch_statuswithprogress(),cancel_batch). state.dispatch_sync(job);queue:forget,queue:prune-failed,queue:prune-batches.JobContexthasidandbatch_id.- Migration: a new framework migration adds
chain/batch_idcolumns andjob_batches; runmigrate.
#M20a · Scheduler, locks and cache
APP_TIMEZONEtakes IANA names (Asia/Jakarta,Europe/Amsterdam) with daylight saving time, as well as offsets andUTC;renox::timezone::Zone. Thedatefilter uses it too.- Schedules:
cron("30 9 * * 1-5"),weekly_on,monthly_on; per task.weekdays(),.weekends(),.days(&[…]),.between("08:00", "17:00"),.timezone("…"),.on_failure(|state, err| …),.on_success(…).schedule:run NAMEandKernel::run_scheduled. A duplicate task name is a boot error. - Changed: schedule methods return
ScheduledTask(derefs toSchedule);Schedule::upcomingtakes aZoneand returns(name, at, zone). - Cache:
add,pull,increment/decrement(atomic),pruneandcache:prune; the database store prunes expired rows hourly on its own. - Locks:
state.cache.lock(name, ttl)withtry_acquire,block(wait)(423 on timeout),is_held,force_release;LockGuard::releaseor drop.
#M19b · Model features
- Model hooks:
#[model(hooks)]andimpl renox::db::ModelHookswithsaving(may stop the save or fill fields),saved,deleting(may stop the delete) anddeleted. Bulk queries don't run them. renox::context::app(): theAppStateof the current request, job, scheduled task or command.Model::save_only(&db, &["col"])andModel::save_changes(&db, &original)(the columns that differ; returns whether anything was written).AppState::encrypt/AppState::decrypt(AES-256-GCM underAPP_KEY).- Pivot data:
Pivot::with_timestamps(),attach_with,update_pivot,toggle,load_with_pivot::<T, PivotRow>. - Polymorphic relations:
relations::Morphwithof,load_manyandparents. - Deferred: non-integer primary keys and an
Encrypted<T>field type (ROADMAP explains why).
#M19a · Query builder
where_raw(sql, values),order_by_raw(sql),group_by(col),having_raw(sql, values)andselect_as::<T>(db, "col, COUNT(*)")into aFromRowstruct or tuple;countcounts groups.to_sql(dialect)returns the SELECT and its values.lock_for_update()/shared_lock()(PostgreSQL);Db::begin_immediate()is public (SQLite).where_has(children, fk)/where_doesnt_have(EXISTS),where_not_in_query.relations::count_manyandsum_many(withCount / withSum, 0 for rows without children).simple_paginate(SimplePage,simple_paginationmacro) andcursor_paginate(CursorPage, keyset on id).first_or_new,update_or_create,Model::refresh.Db::transaction(|tx| Box::pin(async move { … }))andtransaction_retrying(n, …);DbError::is_retryable,Error::is_retryable.
#M18b · Accounts and security
Auth::account():/accountwith profile (a new email is verified again), password change, "log out other devices" and account deletion; overridablerenox/auth/account.html.Auth::password_rules(Password::min(12).mixed_case().numbers().symbols())for the register, reset and account forms;Field::password(&policy)for any form;Password::min(8)stays the default.Routes::require_password_confirmed()and/confirm-password(three hours, like Laravel).- Behaviour change:
auth::logoutends this device only (a copied cookie dies too); newauth::logout_other_devicesandauth::change_passwordkeep this session;User::delete_account. - Auth events in
renox::auth::events(Registered,LoggedIn,LoginFailed,LockedOut,LoggedOut,PasswordReset,PasswordChanged,EmailVerified,ProfileUpdated,OtherDevicesLoggedOut,AccountDeleted). - The
Auditmodule (renox::audit): anaudit_logstable recording every auth event,audit::record(Entry::new(..).user(..).subject(..).data(..).ip(..)),latest,for_user,for_subject,prune, andrnx audit:prune --days N. - Users imported from Laravel log in with their bcrypt hashes and are rehashed to Argon2id
(
auth::needs_rehash);User::attemptrehashes too. TestApp::session_cookie/use_session_cookieto play several devices in tests.- New apps from
rnx newturn the account page on and link it from the layout. - New framework migrations:
…000006_create_revoked_sessions_table(Auth) and…000600_create_audit_logs_table(Audit).
#M18a · Tenancy, roles, gates, token abilities
renox::context: values for the current request, job, task or command (e.g. the current team), withset,get,removeandscope.- Default scopes:
#[model(default_scope = "team_only")], applied byquery(),find,where_eqand the relation loaders;Model::unscoped();Query::none()to fail closed. unique/existsrules take.where_eq(col, value),.where_null(col)and.where_not_null(col), e.g. unique per team and ignoring soft-deleted rows.Routes::require_gate("admin")(async gates too),App::gate_before(|user, ability| …)for super-admins, applied to gates, permissions and policies.- The
Permissionsmodule: roles and permissions tables,permissions::define_role,grant,revoke,delete_role,roles;user.assign_role,remove_role,sync_roles,roles,permissions;AuthUser::has_role,has_permission,role_names;Routes::require_role,require_permission;can('…')andauth.rolesin templates. - API token abilities:
create_token_with(&db, name, &["orders:read"], expires),AuthUser::token_can,Routes::require_ability,AccessToken.abilities;tokens:pruneandauth::prune_expired_tokensdelete expired tokens. Can::newtakes aUseror anAuthUser(the latter appliesgate_before).- examples/shop guards
/adminwithrequire_gate("admin")instead of its own extractor. - New framework migrations:
…000005_add_abilities_to_personal_access_tokens(Auth) and…000500_create_roles_and_permissions_tables(Permissions).
#Laravel parity review
- docs/audit/2026-09-laravel-parity.md: every Laravel feature area compared with Renox, the verdict, and the gaps; ROADMAP plans them as M18–M21 before v1.0.
- CHEATSHEET:
set_passwordends every session, this one too (log it in again).
#Docs refresh
- The
AGENTS.mdthatrnx newwrites links the cheat-sheet and llms.txt of the Renox commit the app is pinned to (notmain), points to examples/shop, htmx-recipes, relations and the guides, and lists the traps agents hit ({id}routes,require_authorder, handlers that aren'tSend). - CHEATSHEET: path parameters, route guards,
unique(…).ignore(id), signed URLs, queues and delays, plurals and language switching, auth options, seeders and factories, template globals and overridable pages, more test helpers, every.envsetting. - README: a "Coming from Laravel" table, a Documentation section, an updated status and upgrade path, prerequisites, and a corrected comparison with Loco.
- CLAUDE.md rewritten for accuracy (pipeline, CI jobs, env vars, conventions), without personal or session details. ROADMAP: finished items ticked, a "Not planned" section.
- Crate descriptions and keywords mention PostgreSQL.
#M17b · More examples, a README for each
examples/htmx-recipes(modal form, inline edit, toggle, dropdown with delete, infinite scroll, tabs,HxRefresh,HxRedirect) andexamples/relations(belongs to, has many, many to many withsyncandinverse, no N+1, SQL reports).- A README for every example.
AuthUser::token_id(): the API token the request logged in with, to revoke just that one.rnx key:generatecreates.env(from.env.examplewhen there is one) instead of failing.- examples/api:
DELETE /api/tokens/currentrevokes only the token used;DELETE /api/tokensrevokes all.
#M17a · examples/shop
examples/shop: a whole online shop with its README (catalog, cart, checkout in one transaction, queued mail and notifications, a daily task, an admin with photo uploads, en/id, deploy files).- Fix:
relations::belongs_to,has_many,Pivot::load/load_forandQuery::first_or_createcouldn't be awaited in a routed handler ("implementation ofSendis not general enough"). They now returnimpl Future + Sendand hold no closure across an.await; a test routes every data API so this can't come back unnoticed. first_or_create's closure must beSend(closures that capture only data are).
#Fixes
- Stale schema in pooled connections. After a migration added a column, a connection opened
before it could run
SELECT *on that table with the old column list. sqlx-sqlite then panicked, and the query returned no rows; PostgreSQL could refuse its cached plan. Pools now drop connections opened before the last migration batch run in the process. Seen as a flaky macOS failure of the multi-server test.
#M16b · CI and trust
- CI builds and tests an app made by
rnx newwith everymake:*generator, on SQLite and PostgreSQL, and builds and starts the Docker image frommake:deploy. - CI also checks the MSRV (Rust 1.94, now
rust-versioninCargo.toml), every feature on its own (cargo hack), S3 storage against a real S3 server (SeaweedFS), licenses and advisories (cargo deny), and reports line coverage. rnx make:jobandrnx make:commandregister what they create in the module'sregister.rnx make:migrationnever gives two migrations the same timestamp, so migrations made in the same second run in the order they were made.Config::from_varsbuilds a config from any source of variables (tests, secrets managers).- Fix:
STORAGE_DISK=s3with anhttp://S3_ENDPOINT(a local MinIO or SeaweedFS) failed on every request. - The macros' docs compile, and their misuse is tested to fail with a clear error.
- SECURITY.md, CONTRIBUTING.md and this changelog.
#M16a · Lighter builds (#40)
- No C crypto library in a default build: reqwest and lettre use rustls with ring.
fakeandserver-eventsare features (on by default).asset()adds?v=<content hash>, and versioned assets are cached for a year.- The Dockerfile from
make:deployuses cargo-chef: a code change rebuilds in seconds. - With
CACHE_STORE=database, rate limits and the login lock hold across servers. - Breaking:
renox::fakeandanalytics::ServerEventneed their features.
#M15b · Validation and requests (#39)
- Rules: regex, dates (
before/after), digits,in/not_in,required_if/required_with,same/different, customRules, per-item rules for lists. - Several files in one field (
Vec<Upload>), cookies (plain and encrypted), downloads and streamed responses.
#M15a · Query builder and relations (#38)
select,where_any/where_all,where_between,where_not_in,where_in_query,when, aggregates,pluck, bulkupdate/increment,insert_many/upsert,first_or_create,chunk.sql(…).fetch_as::<T>()into a#[derive(FromRow)]struct, a model or a tuple.- Explicit relations without N+1:
belongs_to,has_many,Pivot(seedocs/relations.md). - Breaking:
Model::from_rowmoved to theFromRowtrait (only manualimpl Modelblocks change).
#M14c · Mail and notifications (#37)
- Mail with cc, bcc, reply-to and attachments; custom notification channels, notifications to people who aren't users, and queued notifications.
- Breaking:
Mail.tois aVec<String>;Notificationmethods take&Recipient.
#M14b · Extension points (#36)
- Template filters and functions, data shared with every view,
App::layer, typed state, async gates, extraUsercolumns and a registration hook. - Templates are strict about undefined variables while debugging, and the debug error page shows the error chain and the request.
#M14a · API foundations (#35)
#[non_exhaustive]on public structs and enums (seedocs/stability.md),Error::Statusandabort, route groups with prefixes, app commands (App::command,rnx make:command), andrnx newpins the app to a Renox commit.- Breaking: struct literals and exhaustive
matches on those types no longer compile; database methods returnDbError;renox::sqlxmoved torenox::db::sqlx.
#Earlier milestones
- M13 (#31–#33): fixes from the pre-1.0 security and resilience audit
(
docs/audit/2026-09-pre-1.0.md), a chaos test in CI,docs/operations.md. - M12 (#29): every field type from the form to the database and back.
- M11 (#23, #25, #26): CSP with nonces, security headers, CORS per route; webhooks (verify, store once, process in the queue); SEO, sitemaps and analytics.
- M10 (#21, #22, #27, #28): examples, CHEATSHEET.md, llms.txt, compiled doc examples.
- M9 (#19, #20): Renox's own database layer, and PostgreSQL next to SQLite.
- M0–M8: the framework itself: routing, views, database and migrations, validation,
authentication and policies, queue, scheduler and events, mail, cache, storage, i18n,
the
rnxCLI, test helpers, and single-binary deploys.