Changelog
Newest first. Every crate in the workspace shares one version number.
Upgrading an existing project’s starter-kit files is
a separate step from bumping the dependency —
rustlavel upgrade merges them rather than overwriting what you wrote.
0.9.1 — 2026-11-01
A scaffolding change; no API changes, so rustlavel = "0.9" picks it up on its own. Nothing but the CLI differs from 0.9.0. The commit before this version bump was verified by CI: the whole workspace against PostgreSQL, MySQL and Valkey, the MySQL step, the skip check and the nightly Elasticsearch, OpenSearch and Collector jobs all passed.
Changed
rustlavel newwrites a[profile.release]—lto = true,codegen-units = 1,strip = true— into the application'sCargo.tomland the microservices kit's workspace root, which is whatrustlavel buildruns. Measured on the blog example: the binary went from 6.5 MB to 4.2 MB and the release build from 34 s to 45 s; runtime speed was not measured. Existing projects are untouched.panic = "abort"is left out on purpose: the server turns a panicking handler into a 500 withcatch_unwind, and under abort the first panic would end the process and every open connection. A test fails if the scaffold ever sets it.
0.9.0 — 2026-10-22
Log shipping. Application logs can now go to Elasticsearch, OpenSearch or any OpenTelemetry collector, and both are off until asked for. A minor version because 0.x treats it as one: rustlavel = "0.8" does not pick this up, so change it to "0.9". No existing API was removed or changed. Verification is recorded in the commit that makes this release.
Added
- Logs can be shipped to Elasticsearch and OpenSearch.
rustlavel-searchgainsLogShipping, a plugin (andLogShipperunderneath it) that listens to the framework's event bus — so everyinfo!,warn!anderror!is shipped without the code that logs changing — and writes one document per line to a daily index,logs-2026.10.21, in_bulkbatches. The documents use a subset of the Elastic Common Schema's fields (@timestamp,message,log.level,service.name,event.dataset), so Kibana's and OpenSearch Dashboards' log views find them, and@timestampis read by the cluster as a date with no mapping written beforehand. Off unlesssearch.logs.enabledis true. Logging must not be able to take down what it observes, so recording is a mutex and a push; the queue is bounded and drops the newest line, counted; lines the cluster rejects for load (429) are put back and tried again, and lines it rejects for being wrong are counted and not retried; and the shipper's own trouble goes straight to stderr and is never shipped — without that, a down cluster would produce a warning that was shipped to the down cluster, which would fail, and warn. The application flushes it when the server shuts down. Tested against Elasticsearch 8.15 and OpenSearch 2.17, running: a flush, a mapping conflict that must not stop the rest of the batch, a dead cluster, and an ordinaryinfo!call all the way through the process-wide bus. The re-entrancy guard was proven by removing it and watching its test fail. - OpenTelemetry logs.
rustlavel-otelexports a third signal, OTLP logs, to/v1/logs(orOTEL_EXPORTER_OTLP_LOGS_ENDPOINT), in protobuf or JSON. A line written while a request is handled carries that request's trace id, span id and sampled flag, so a collector shows it beside the trace; an incomingtraceparentis honoured. Off unless asked for —.logs(true),otel.logs.enabled, or the specification's ownOTEL_LOGS_EXPORTER=otlp— because logs can carry anything an application prints. It shares the exporter's bounded queue, retries and backoff, and the same guard against shipping its own complaints: with the guard removed, a collector that refuses logs made the exporter send four requests for one line. Checked against a real OpenTelemetry Collector 0.160 rather than only against this crate's own encoder. That found two gaps no unit test had: the observed timestamp was left unset and showed as 1970, and the sampled flag was zero for a sampled trace. Both are fixed.
Changed
rustlavel new --with searchregistersLogShippingin the generatedmain.rs, as--with oteldoesOpenTelemetry. It does nothing untilsearch.logs.enabledis true. A test that every package shipping a plugin is named in the writtenmain.rscaught the omission the moment the plugin existed.- The server flushes the log shipper and the OpenTelemetry exporter on shutdown. The exporter's last few seconds of spans and metrics used to be lost on a restart unless the application flushed it by hand, which is exactly the moment somebody is watching.
0.8.3 — 2026-10-21
A fix to the money packages that MySQL users could hit, and a development quality-of-life change. No API changes, so rustlavel = "0.8" picks it up on its own. Verified on this exact tree against live PostgreSQL 16, MySQL 8.4 and Valkey: the whole workspace with --no-fail-fast (111 test binaries, 3,244 passed, 0 failed), then the queue, ledger, billing, payment and OAuth-provider packages again on MySQL (19 binaries, 367 passed, 0 failed), with check-skips.sh finding no suite that skipped itself. SQL Server was not run on this tree; the ledger fix was run against it before this release.
Added
rustlavel serverefreshes the browser. It already rebuilt and restarted the application on a change; switching to the browser and pressing refresh was left to the developer. Now the page follows the code, in development only: a saved template reloads the page, a saved stylesheet is swapped in place — scroll position, form input and open menus survive — and a code change reloads the page once the new process is serving. It lives inside the application, with no second port: a server-sent-events stream, a small client script served from the application's own origin (so the auth kit'sscript-src 'self'policy admits it), and a tag added before</body>in HTML responses.--no-reloadturns it off; production never turns it on, whatever the environment says. Restarting for a template or stylesheet was also dropped: views and static files are read from disk on every request in development, soserveused to pay a cargo invocation and lose every in-memory session to show a change the running application could already serve. Those files are now watched by the application itself; undersrc/they still restart, because there they may be compiled in withinclude_str!. Checked in Chrome against a running application: a stylesheet edit changed the colour with the document intact, a template edit and a code edit each reloaded it.
Fixed
- The ledger failed on MySQL when two first-time callers raced for one account — and billing with it, since every payment credits through the ledger. Found by running the ledger, billing, payment and OAuth suites against MySQL, SQL Server and SQLite for the first time; they had only ever run on PostgreSQL.
concurrent_first_use_creates_one_accountfailed withMySQL error 1305: SAVEPOINT account does not exist, 120 runs in 120 under load; payment and OAuth, and every other database, were clean. The ledger created an account *inside* the transaction that would use it: read, insert on a miss inside a savepoint, read again. PostgreSQL aborts the whole transaction on the duplicate-key error a lost race produces, which the savepoint was there to survive. MySQL does something different and worse. A transaction's first read fixes its snapshot, so after losing the insert the re-read cannot see the row the winner just committed — however plainly the unique index says it exists — and InnoDB can deadlock the racing inserts, which rolls the entire transaction back and takes the savepoint with it. Every operation now makes sure the account exists *before* its transaction begins, as a statement of its own: a failure there ends one statement and nothing else, a few short retries cover the loser reading before the winner commits, and the transaction takes its snapshot afterwards, when the row is committed and visible. The savepoint is gone. An error from the ledger here was a failed call, never a double credit, so no balance was ever wrong — but a customer's first two simultaneous payments could fail, and a gateway that retries would have hidden it. Measured, not argued: the old code failed 120 runs of that test in 120 on MySQL under full CPU load, the new code 0 in 120; it passes 60 runs on PostgreSQL and SQLite and 40 on SQL Server under the same load. Billing's sixteen-concurrent-webhooks test, which exercised it through the ledger, is clean 60 of 60 on MySQL and 30 of 30 on each of the others. All four packages' suites now pass on all four databases.
Changed
rustlavel-wsrejects invalid UTF-8 in a fragmented text message as soon as the bad fragment arrives, instead of when the message completes. RFC 6455 allows waiting, so this was never a bug and the Autobahn TestSuite graded it NON-STRICT (cases 6.4.1 and 6.4.2) — but waiting meant buffering the rest of a message the server could already know was doomed, up to the size limit, for a peer that had sent one bad byte. Validation is incremental: each byte is checked once, behind a marker for how far the message has been confirmed, so a message of any number of fragments costs time linear in its length rather than quadratic. The difficulty is that a character may be cut anywhere — "é" can arrive as two fragments of one byte each, and neither is valid UTF-8 alone — so an unfinished character at the end is *not* a failure. Only bytes that can never become valid are, andfrom_utf8says which is which. A message that ends in the middle of a character is still refused when it ends. Run through the full Autobahn suite (301 cases, permessage-deflate cases excluded) against the previous report: 6.4.1 and 6.4.2 move from NON-STRICT to OK, nothing else moves except 7.9.3, fixed in 0.8.2. 6.4.3 and 6.4.4 stay NON-STRICT, and they are a different thing from what 6.4.1 and 6.4.2 test: one *frame* arriving in three TCP packets, not three frames. Failing those early means validating a payload while the frame is still arriving, which is a deeper change to the read path for a strictness the suite itself accepts.
Development
- CI runs what it used to skip. The ledger, payment, OAuth and billing suites were not in CI at all, so each skipped itself there and went green; the queue was run only against PostgreSQL. CI now sets their variables, runs the database-backed packages against MySQL as well, and runs the workspace with
--no-fail-fastso a red run shows every failure rather than the first.check-skips.shthen fails the job if any suite it promised to run printed a skip message — matching whole variable names, and refusing a log with no test results in it, because a detector that read nothing has proved nothing. - The queue's integration tests are renamed
liveandlive_chain_progressfrompostgresandpostgres_chain_progress: they are database-agnostic and run against all four, and the old name was how nobody thought to point them anywhere else. - A guide example used
where_eq, which does not exist,order_bywith a string, andget()without a database. It could never have compiled; it is fixed, and the guide now says which database a URL selects.
0.8.2 — 2026-10-03
Five fixes, one of them to a claim 0.8.1 made that was not true. Nothing here changes an API: rustlavel = "0.8" picks it up on its own. Anybody running the queue on MySQL should take it now — on 0.8.1 it does not run at all.
Fixed
- 0.8.1 said the queue runs on all four databases. On MySQL it did not run at all. Every job reservation failed with a syntax error: the claiming statement put
for update skip lockedbeforelimit 1, and MySQL requires the opposite order. PostgreSQL accepts either, SQL Server spells the lock as a table hint, SQLite has none — so the one database that cares was the one never run. The 0.8.1 fix had been *asserted* for MySQL and SQL Server, not executed, and the assertion encoded the same wrong order, so it passed. Now executed. The queue's integration suite — the same eleven tests, including the two that prove no row is ever handed to two workers — passes against a live PostgreSQL 16, MySQL 8.4 and SQL Server 2022, and SQLite's suite passes too. The suite had one literal$1of its own, in a helper that backdates a reservation; it now asks the dialect like the queue does, so it can be pointed at any database. The whole-statement assertions stay, with a comment saying what they cannot do: pin a shape somebody believed, which is not the same as a shape a database accepts. rustlavel-wsaccepted and echoed back close code 1004, which RFC 6455 §7.4.1 reserves. It sat inside the1000..=1014range thatis_sendabletreated as legal, so a peer sending it was answered in kind rather than with 1002. Found by the Autobahn TestSuite — case 7.9.3, the one failure out of 298 cases run against a rustlavel-ws echo server by a project using it. 1012–1014 stay sendable: IANA registered them after the RFC. The tests now take their lists from Autobahn's own 7.9.x (invalid) and 7.7.x (valid) cases rather than from a range that looked right.- The queue's own orphan-reclaim query deadlocked against its workers on MySQL, so even after the syntax fix above the race test failed seven runs in fifteen with error 1213.
reclaim_expiredwas oneupdate … where reserved_at + retry_after < ?, and an InnoDB update locks every row it *scans*, not only the ones it changes — while each worker held its own row underfor update skip locked. Two transactions each holding what the other wanted; MySQL killed one. It is now aselect … for update skip lockedfollowed by an update of just those ids, in one transaction, so every locking read in the queue steps over rows somebody holds instead of waiting for them, and a transaction that never waits cannot be in a cycle. A row skipped is not an orphan anyway: somebody holds it right now. Bounded at 500 per pass, because the update names each id and SQL Server refuses more than 2,100 parameters; a bigger backlog clears over a few polls. Measured on the same machine against the same live MySQL: the old query failed 6 runs in 20, the new one 0 in 40. It passes the full eleven-test suite on PostgreSQL 16, MySQL 8.4 and SQL Server 2022, and SQLite's. - A queue worker exited the moment its database blinked.
Worker::runreturned any error from the queue straight out of its loop, andrun_pooljoined its tasks with?, so one deadlock on one poll ended that worker and the first worker to hit it ended the wholequeue:workprocess, with jobs still waiting. A *job* failing was always handled; the *queue* failing was not — until the reclaim deadlock above put one in front of a worker. A failure to reach the queue is now logged and retried with a pause that starts at the poll interval, doubles, resets the first time the queue answers, and stops at 30 seconds — not at the job backoff cap, which is an hour, because a failed job is waiting on something of its own while a database that deadlocked is back in seconds. Shutdown ends the pause at once, so a worker whose database is down does not hang a deploy. This is a behaviour change. A worker whose database is gone for good used to exit and let a supervisor restart it; it now keeps retrying, logging at error level, until somebody notices. The signature is unchanged, butrunno longer returnsErrfor anything the queue did. - A pool could open more connections than it was allowed, on every database.
PooledConnection'sDrophanded its connection back throughtokio::spawn, but the permit was released as soon asdropreturned — so the next caller could win it, find the idle queue not yet refilled, and open another connection, which then joined an idle queue with no bound. Measured: a pool allowed two opened four and five, three runs out of three, none of them closed. Against a server this is waste until enough pools overshoot at once to reach the server's ownmax_connections, which then refuses whoever asks next. It was first seen as SQLite's:memory:losing tables, because there a second connection is a second, empty database — and 0.8.1 worked around it in the SQLite driver rather than fixing the pool. Fixed in the pool. The idle queue is guarded by a synchronous lock, soDroppushes the connection back itself, before the permit goes; closing — the one slow step — happens outside any lock. A test hammers a pool of two with sixteen tasks and 3,200 borrows and fails if it ever opens a third; putting the old ordering back makes it fail again. Every driver suite passes against a live server with the change, as does the conformance suite on all three.
0.8.1 — 2026-09-20
A fourth database and a charts package, both additive: rustlavel = "0.8" picks them up on its own and nothing below needs a person to read it first.
- The queue only ever worked on PostgreSQL, and nothing said so. Every statement in
DatabaseQueuewrote$1literally — PostgreSQL's spelling, where MySQL, SQL Server and SQLite all want?or@P1— claimed its rows withfor update skip locked, which SQL Server spells as a table hint and SQLite does not have at all, and ended withlimit 1, which SQL Server has no word for. The table names were quoted"like this", which MySQL reads as a string rather than a table. Four independent reasons the one package that needs a row lock could not run on three of the four databases the framework supports. It survived because the only integration test was against PostgreSQL, so the assumption tested itself.Dialectgrewskip_locked(), which returns a table hint *and* a suffix because SQL Server's form is not a suffix, and SQLite grewbegin immediate— its deferred transactions deadlock two workers outright, and no clause on theselectcan fix that. The claiming statement is now built by a function whose output is asserted whole for all four databases, so a wrong *position* is caught as well as a wrong word, and PostgreSQL's statement is byte for byte what it was. Two guards fail if a placeholder or a lock clause is ever written by hand again. Checked against a live PostgreSQL as well as by assertion — includingmany_workers_racing_for_the_same_jobs_each_run_exactly_onceandonly_one_reservation_can_win_a_single_row, the two that prove no row is ever handed out twice — and against SQLite, which now has a queue integration test that needs no container at all. - SQLite, behind the
sqlitefeature. A fourth database, and the only dependency on C in the workspace. It is not a wire protocol like the other three — SQLite is a library that reads a file in this process, so there was nothing to write from scratch and the only question was whose binding.rusqlitewithbundledcompiles SQLite from the vendored amalgamation, so every machine and CI run the identical engine. Off by default: a build that does not enable it compiles no C and needs no C compiler. The dialect compiles unconditionally, so the generated SQL is asserted in builds that cannot open a database — the same way the other three are tested. Two behaviours were found by running it rather than reading it. Foreign keys are switched on for every connection, because SQLite leaves them off and the other three do not, so a migration proved against SQLite would otherwise behave differently the moment it reached a server. And:memory:is opened through a shared-cache URI with a keepalive connection, notopen_in_memory(): the obvious call gives each connection its own blank database, and the pool opens more than one, becausePooledConnection::dropreturns a connection throughtokio::spawnwhile releasing its permit immediately — so the next caller can win the permit, find the idle queue not yet refilled, and open a second. Against a server that race is invisible; againstopen_in_memorythe table you just created is intermittently not there. rustlavel-chart— charts, drawn in the browser, described in Rust. A typed builder (Chart::line,bar,horizontal_bar,doughnut,pie,scatter, withSeries,Axisand aPalette) produces the configuration Chart.js reads; theChartsplugin serves the library and a small init script from the application's own origin. It vendors somebody else's library, which is unusual here and deliberate. Chart.js is 69 KB gzipped; writing an axis scaler, tick formatter, legend layout and hit-tester from scratch would still leave no hover, tooltips or zoom. The copy carries its own MIT licence and copyright banner, and a test fails if either goes missing or the version drifts from the constant. The configuration travels in an *escaped*data-chartattribute, so there is no inline<script>and no CDN: both are refused bydefault-src 'self', the policy the auth kit ships, and a policy withunsafe-inlinedoes not stop injected script. Measured against that exact policy in a browser: four charts drawn, no violations, and a label readinga" onmouseover="alert(1)arriving in Chart.js as that string. The same run found that the policy blocks an inline<style>just as firmly, so the theme hook's CSS custom properties must come from an external stylesheet — now documented rather than discovered. Three defaults differ from Chart.js's, each because the default lies: the value axis begins at zero, lines are straight rather than curved through points nobody measured, and aNonereading breaks the line instead of being drawn as zero.Chart::problems()reports a series shorter than its labels or a doughnut given several series, which Chart.js draws silently.
0.8.0 — 2026-09-19
The minor number rather than the patch, and deliberately: for a 0.x version Cargo treats the minor as the major, so rustlavel = "0.7" would have picked this up on its own and one signature below would not compile. Four new packages, two additions for applications built as several services, and a dozen fixes found by running the result rather than testing it.
Breaking
Claims::subjectis nowOption<String>. Aclient_credentialstoken has no resource owner behind it, andrustlavel-oauth-provideromitssubfor that grant on purpose — the absence is how a resource server tells a machine's token from a person's. The reader requiredsub, so the framework's two halves disagreed and machine-to-machine authentication could not work at all. Code that readclaims.subjectas aStringnow callsclaims.owner()for the person, orclaims.is_service()to ask whether there is one. It is deliberately not defaulted to the client id: a *user's* token whosesubwent missing would then be attributed to the client, and every user of that client would share one identity.
Fixed
Everything in this section was found by scaffolding the microservices kit, starting its four services against a real PostgreSQL, and asking for a token. None of it was visible from the test suite, which passed throughout.
SIGTERMwas ignored, so graceful shutdown never happened in production. The server waited onctrl_c—SIGINT, the signal a person sends from a terminal. Kubernetes, systemd anddocker stopall sendSIGTERM, and under every one of them the default handler killed the process immediately: the ten-second drain never ran, and in-flight requests died with the process. Both signals now stop the listener.App::on_shutdown, because advice nothing follows is not a feature. There was nowhere to put work that has to happen on the way down, so the documentation recommended deregistering from a service registry and nothing could. Hooks run after the drain, not before it: a service that deregistered first would still be serving the requests already in flight.- A
client_credentialstoken was refused by the framework's own verifier.rustlavel-oauth-providerdeliberately omitssubfrom an introspection response for that grant — there is no resource owner behind it, and the absence is how a resource server tells a machine's token from a person's.rustlavel-oauth's reader requiredsuband refused anything without one. So the two halves disagreed, and machine-to-machine authentication — the thing a microservices deployment is built on — could not work at all.Claims::subjectis nowOption<String>, withClaims::owner()andClaims::is_service(). Deliberately not defaulted to the client id, which is the tempting fix: a *user's* token whosesubwent missing would then be attributed to the client, and every user of that client would share one identity. Gateway::require_token()closed the token endpoint too. It applied to every route including/oauth/*, so obtaining a token required a token — a door that only opens from the inside.Upstream::open()excepts a route, and it is opt-in per route so opening one is a line somebody wrote rather than a hole in a default.migrateanddb:seedignored the database the application had opened and reconnected fromDATABASE_URL. Right for an application with one database and wrong for every other arrangement — a service reading its own key, a handle opened from a vault lease, a tenant chosen at boot. The console now usesApp::registered::<Database>()and falls back to the configuration, so an application that never calls.state(db)is unaffected.- The authorization server lost every token on restart.
rustlavel-oauth-providershipped with in-memory stores only, and an empty store answers "unknown token" to everything — so a deploy signed out every session and broke every integration at once. Clients, codes, access and refresh tokens and consent are now backed by a table each, behind the crate'sdbfeature.database::schemacreates them, so a migration and the code that reads it cannot drift apart: the kit's hand-written schema had nofamilycolumn, put access and refresh tokens in one table, and had no consent table at all — it migrated cleanly and nothing could use it. The two operations whose contract is atomicity are atomic. Spending a code and rotating a refresh token are each oneUPDATE … WHERE <still unspent>and a check of the row count, so the database picks the winner. Proved rather than argued: sixteen tasks present the same code at once and exactly one is toldFresh. Rewritten as a read followed by a write — which passes every single-threaded test — 8 of 16 spend the same code and 9 of 16 rotate the same refresh token. Those are the replay and the stolen-token race, measured. Three types gainedfrom_hashconstructors, without which no store outside the crate could rebuild a token from a row. - "Always current" was not true of introspection. The kit told you to choose introspection over local signature checks because revocation is immediate. It is not: the answer is cached for thirty seconds, so a revoked token keeps working for up to that long. Measured by revoking one and timing it. The choice is thirty seconds against an hour, and it is now described in those terms.
rustlavel upgradewrote raw placeholders into a real project. The four{{module_*}}slots added for the discovery dashboard were filled bynewand unknown toupgrade, which rendered them literally:{{module_nav_flags}}landed inside a Rust array inpage.rsand{{modules_nav}}in the navigation. Found by upgrading a copy of a production 0.7.4 project with 648 views and 1,313 tests. The slots now come from one function both commands call, and a guard renders every template with the *upgrade's* values — the existing guard had only ever been givennew's, which is how a bug in the other writer stayed invisible. With the fix the same upgrade merges cleanly, zero conflicts; the one conflict seen before was caused entirely by this.@routein the layouts breaks a test that renders a view with noApp. Every kit page includes a layout, every layout now resolves@route, and a bareEnginehas no route table — so 25 of that project's 1,313 tests failed with "an engine with no route table". That message is correct and the behaviour is deliberate (an unresolved name must not becomehref=""), so the fix is to give tests the table:rustlavel::engine_with_routesfills an engine from a router, and the kit'ssupport::views::engine_for_testsreplaysmain.rs's registration — both route files and every module — then calls it. Tests only:Appfills the table in production and a route added only inmain.rslives in that one, so hand a production engine through this and@routeto that name is a500. After the switch, all 1,313 pass.@routehad no reader, and neither did thirty route names. The directive was built and tested, the auth kit named thirty of its routes, and every link in every one of its templates was a hardcoded path — so changing a route's URL meant finding it in twenty-two files. All thirty-seven are now@route("…"), five more routes gained names to make that possible, and the two that are not routes at all (a stylesheet) are left alone. A name that does not exist is a loud500naming it rather thanhref="", which is a link that looks like a link and reloads the page; every converted page was loaded to confirm it.- Eight test fixtures were shared between processes. Each was name-spaced per test, which is what the project's own rule asks for, but not per *process* — so two
cargo testinvocations at once, two terminals, or a CI matrix on one machine wrote into the same directory and counted each other's files. One mail test failed once in a full workspace run and passed on every rerun, which is what a shared fixture looks like from the outside. Running the old binary eight-at-once failed 24 of 40 times; with the process id in the path, 0 of 40. The other seven carried the same hazard and are fixed the same way, including the one in the auth kit's own template, which shipped it into every scaffolded project.
Fixed in the microservices kit
The kit shipped in a state where nothing could start, and each fix uncovered the next.
- Neither service could start.
.envwroteAUTH_DATABASE_URLandAPI_DATABASE_URL; both services readDATABASE_URL. Setting that one key to make them boot would have put both services in one database — the coupling the kit is shaped to avoid, arriving through a configuration file. GATEWAY_PORTwas read by nothing (the framework readsSERVER_PORT), and four services sharing one.envcannot each take a port from it. Replaced with the four commands that actually work.migrateregistered the service with the discovery server and exited, leaving a registration nothing was heartbeating; the registry went on handing that address to the gateway for the length of a lease.- The authorization server never mounted
OAuthProvider. It migrated three OAuth tables, seeded a client, and served no token endpoint at all — while its own first paragraph said the provider was mounted there. - The seeded client secret was hashed with argon2, which the authorization server never compares against: it digests client secrets with SHA-256 on purpose. The two never matched, so the one credential the kit gave you could not authenticate once.
db:seedcreated no client at all unless two environment variables were already set, and said so in one line of yellow text among the migrations. Secrets are now generated and printed once, with the.envlines to paste.- Two clients now, not one. The introspection credential lives in the resource server's
.env, so giving it the scopes the domain checks would let anybody who reads that file mint a token that writes — a privilege escalation out of a configuration file. It carries no scopes; a separate demo client carries the domain's. - The authorization server had no
/health, so the gateway's aggregate check probed it, got a 404, and reported the whole systemdegradedwith a 503 on a deployment where nothing was wrong. A load balancer reading that takes the gateway out of rotation. - The resource server demanded a database, opened it, and never read it — no migrations and no route that touched it. It now owns an
orderstable with a read and a write, each scope-checked, rows scoped to the caller and idempotent on a caller-supplied key. - Nothing said goodbye on the way down.
deregisterwas named only in a doc comment. Both services now deregister throughApp::on_shutdown, so a deploy is a clean handover rather than ninety seconds of502. - The registry service answered no health check either, which a guard now catches for every service in the kit rather than for the three somebody remembered.
- A failed token check said nothing. Every step of introspection was an
.ok()?, so a wrongAUTH_URL, an authorization server that was down, and introspection credentials the authorization server refused all produced the same bare401with an empty log. An operator could not tell a forged token from a typo in a settings file — and the two are fixed in different files. The three now log aterrorwith the address and the reason, while a token that was genuinely refused logs atdebug, because an expired token is ordinary traffic rather than an incident. - Two guards, so the first two of these cannot come back. Every key the kit's
.envdefines must be read by its code, and every key its code reads must be in that.env— both directions, because the kit shipped broken in both. And every service must answer/health. Each was written to fail first, and the second caught the registry service immediately.
Added
- A microservices starter kit.
rustlavel new <name> --with microservices-kitscaffolds a cargo workspace: a gateway, an OAuth 2.1 authorization server, a resource server, an optional service registry, and one crate for the types that cross between them.rustlavel buildproduces a binary per service, so each deploys on its own — which is the point, and the reason this is a workspace rather than one crate with four[[bin]]entries. A database per service, and none for the gateway. The authorization server owns users, clients, codes and tokens; the resource server owns its domain and never reads the other's schema. The moment a gateway owns a table it stops being a gateway and becomes a fourth service that can fail — the one every request already passes through. The resource server checks tokens itself, both ways and with the same tests: introspection against the authorization server (RFC 7662, briefly cached) and a local ES256 signature check. Chosen over picking one, because a security path that is rarely used is the one that is wrong without anybody knowing. The gateway also refuses a request with no credential, and that saves a hop and nothing else — a gateway is not the only door. The gateway's rate limit is keyed per client and per feature. The default key is the address plus the matched route, and at a gateway nothing matches a route — everything arrives through the fallback, so the path is used instead and/api/1,/api/2,/api/3each get a bucket of their own. That is a limit that never fires. The key is now the bearer token's hash (never the token) or the address, plus the first path segment, so a client that exhausts its budget on the API can still reach the authorization server to refresh. rustlavel-gateway— a route table, hop-by-hop headers dropped in both directions,X-Forwarded-Forset rather than appended (what the client sent under that name is a claim; the address that opened the socket is a fact), an unreachable upstream answered as502rather than500, and one health endpoint that asks every upstream and names which is unwell. Registers as the router's *fallback*, not a wildcard route, so the application's own health and metrics endpoints stay reachable when the services behind it are the problem.rustlavel-discovery— a service registry and the client that uses it, Eureka-compatible on the wire. A Spring Cloud service registers against it with its stock configuration and nothing else changed. On Kubernetes you do not need it, and the crate's own documentation opens by saying so: the platform already knows which instances are ready and already balances across them, so a registry there is a second copy of a fact, and two sources for one fact disagree eventually. Three resolvers sit behind one trait —Static,Dnsand the registry — so moving between them is a line of configuration rather than a rewrite. The registry is in memory and never persisted: every instance re-registers within a heartbeat of a restart, so the state rebuilds itself faster than it could be loaded, and a stored copy would only ever be wrong on the way back up. Nothing is evicted on a timer — instances carry alast_seenand a read decides who is alive, which is what lets the registry notice that most of itself went quiet at once and keep serving rather than emptying itself during a network blink. A registry holding fewer than eight instances never does this: a proportion threshold applied to a handful fires on the first ordinary failure. Replication is peer forwarding, not consensus. A registry is a cache of where things are, so consensus would buy agreement about a fact that is stale anyway and would mean a node that loses quorum stops answering — the outage the registry exists to survive. Every forwarded write carries a marker (?isReplication=true, and a header for anything that strips query strings) and a marked write is applied and never forwarded on: one hop, never two. The client's cache has no expiry. When the registry cannot be reached, lookups go on being served from what was last read, for however long that lasts. An outage costs you changes — a new instance is not noticed, a departed one is still offered — and the caller's own retries cover the second. A cache that expired would turn a registry outage into a total outage on a timer.rustlavel-ledger— a double-entry book for credits. Every movement is a transfer between two accounts writing two equal and opposite entries, withsystem:topup,system:consumptionandsystem:expiryas the other side, so every balance in the book sums to zero andaudit()can say whether it does. Top-ups and consumes are idempotent on their reference; holds reserve and then capture or release exactly once; credits expire by batch, oldest spent first; the history carries its running balance. The debit isUPDATE … WHERE balance - held >= amountand the row count is the answer — the check and the lock in one statement. Measured against PostgreSQL: twenty consumers racing for a balance good for ten, exactly ten succeed; remove the guard and twenty do. Sixteen captures of one hold, one charge. Eight first-time callers, one account — and that last one found that PostgreSQL aborts the whole transaction on the unique-index violation an idempotent insert courts on purpose, so every such insert sits in a savepoint.- Speech to text in
rustlavel-ai—Transcription, over aTranscribertrait with two wire formats:Whisper(OpenAI's/v1/audio/transcriptions, which a self-hosted faster-whisper server also serves —Whisper::local(url), no key) andDeepgram(/v1/listen). The answer is aTranscript: every word with its start and end in seconds, its speaker when diarized, its confidence when reported; segments for sentence edges;words_between(start, end)for what a clip says;to_srt()andto_vtt()for burning in. Asking Whisper for speakers is an error, not a quietNone. Configured byai.transcribe.*orTRANSCRIBE_*, withOPENAI_API_KEY/DEEPGRAM_API_KEYas the conventional fallbacks, and no key demanded for a server that is not OpenAI's.FakeTranscriberfor tests; anai.transcribeevent with sizes and timings and never the words. Measured againstfedirz/faster-whisper-serverwith a spoken clip. rustlavel-billing— plans and subscriptions for a market where a subscription cannot pull money. Each cycle it raises an invoice and a charge throughrustlavel-payment— a VA number, a QR — and waits; when the webhook says paid,on_paidextends the period by one cycle and credits the plan's allowance throughrustlavel-ledger, idempotently under the invoice's id.tickruns on a schedule: renewals raised seven days ahead,Duereminders at three days and one, grace when the period ends unpaid (still served, toldOverdue), suspension when grace runs out. It returns the reminders that fell due; the words and the sending are the application's.change_plantakes effect at the next cycle and reissues a renewal already on the table;cancelends at the period end;renewbrings a suspended subscription back;reissuemoves an invoice to another channel. A renewal paid in grace starts where the last period ended; one that ends a suspension starts now. Safe under two schedulers: guarded status updates, reminders claimed through a unique index on(invoice, kind), renewals through one on(subscription, period). Eight concurrent ticks over five subscriptions raise five renewals and ten reminders — without the reminder index, eighty; without the period index, a sixth renewal, because "is there one already" followed by an insert has a gap another tick's insert fits in. Sixteen concurrent "paid" webhooks extend once and credit once.- Query builder inside a transaction.
get_in,first_in,count_in,insert_in,update_in,delete_inrun the same statements on aTransactionrather than the pool. Anything that is several statements or nothing needed these, and until now wrote SQL by hand with the placeholder style of whichever database it happened to be on. - Job progress, readable from outside the worker, and job chains.
handle_with(&self, ctx)— a default that callshandle, so nothing existing changes — gives a jobctx.progress(percent, "stage"), and a request handler in another process reads it back throughProgressStore.queue:workreports to the database automatically when there is one. The worker closes the record itself on success or final failure, because a job that crashed at 40% is not at 40%.Chain::new(a).then(b).then(c)runs steps in order, advancing only on success and keeping the unrun steps in the dead-letter entry; the chain rides inside thepayloadcolumn so a 0.7 jobs table needs no migration. Schema::create_if_missing, becausehas_tablethencreateis a race two processes booting at once lose — and so, it turns out, is PostgreSQL's owncreate table if not exists, which is not serialised against a concurrent create: the loser gets42P07, or23505on the type catalogue. Measured, not read about: the queue's own suite booting against an empty database passed 1 of 11 tests the first time, 8 the second, 6 the third. The loser's error is now accepted on every dialect; five runs from empty, 13 of 13 each.- Server-Sent Events.
Response::events(receiver)is a200that stays open and writes eachSseEventas it arrives — a job's progress to a UI, one direction, with the browser'sEventSourcereconnecting on its own and sendingLast-Event-ID. Multi-line data is split acrossdata:lines so a newline cannot end an event early; a comment is written every fifteen seconds so a proxy does not close an idle stream; andResponse::streamingunderneath it is the general form — a body produced over time, the same hand-over WebSocket uses without the101. Proved over a real socket, not just by rendering: the client reads each event before the next is sent, and EOF when the sender is dropped. That test caught what the unit tests could not — the server took the upgrade out of the response *before* rendering the head, socontent-length: 0was written on every stream and the browser would have closed it before the first event. rustlavel-payment— gateways behind one trait: charges over virtual account, QRIS, e-wallet and retail; transfers out, singly and in batch; and a webhook receiver.Moneyis an integer of minor units,Channelan enum, so a rounding error or a typo in a bank code is impossible rather than unlikely. The receiver verifies the signature over the raw bytes, records the event under the gateway's id, and runs the handler — in that order. A duplicate is acknowledged without the handler running; a failed handler withdraws the record so the gateway's retry is processed rather than dropped. The table-backed log is oneINSERTagainst a unique index: sixteen deliveries racing, exactly one told it was first — and sixteen of sixteen without the index, measured.FakeGatewayis the only driver. It signs its callbacks properly and refuses a reused transfer reference, so a test cannot pass on behaviour production lacks. A driver for a real gateway is written from its specification, not guessed.- Uploads that never touch the server.
S3Storage::presigned_putandpresigned_getsign a request into a URL, so a browser can upload or download with no credential of its own and the application is never in the path — the only sane shape for a video.create_multipart,presigned_part,complete_multipartandabort_multipartdo the same for files of any size, a part at a time and in parallel;expire_aftersets the bucket's lifecycle so raw uploads and abandoned parts stop being billed. The query-string signing is checked against the worked example in AWS's documentation, byte for byte. Base64 moved torustlavel-corefor this, with a re-export fromrustlavel-authso every existing path still works. - Valkey, as the cache package under the name of the server you run. Valkey is the Linux Foundation fork of Redis 7.2 and speaks RESP unchanged, so a second client would have been this one with the name changed — and two copies of a protocol drift apart exactly once, at the worst moment. Instead the one client answers to both:
valkey://parses asredis://does,CACHE_DRIVER=valkeyselects the same driver,VALKEY_URLis read whereREDIS_URLis, andfeatures = ["valkey"]enables the package. The whole Redis integration suite passes against Valkey 8.1 unchanged, and the Redis session store — which had never been run against a live server by the suite at all — now is, through avalkey://URL. Every message echoes the scheme the operator wrote. A Valkey deployment told "cannot connect to redis://…" goes looking for a Redis configuration that does not exist; found by pointing a scaffolded project at a closed port. --with cachenever wroteconfig/cache.json, soCACHE_DRIVER=redisin.envreached nothing and the application ran on the memory driver — silently, because the memory driver works. The same gapconfig/mail.jsonclosed forMAIL_*. Both--with cacheand--with valkeynow write the file and a.envblock, andmain.rsis told the one line that reaches the store.- A service registry dashboard, and
rustlavel newasks whether you want one rather than assuming. The registry serves its own at/discoverywith no authentication in front of it — it lists every host and port you run, so that is survivable on a private network and nowhere else, and the documentation says so. Answering yes to the question adds a page to the auth kit instead, behind adiscovery.viewpermission. That page reads the registry's JSON document over HTTP rather than embedding a registry: a dashboard that went down with the thing it is there to tell you about would be a dashboard nobody could use at the only moment it matters. A registry that does not answer is a panel naming the address it tried, not a 500. Upstream::serviceroutes a gateway by service name, resolved for every request and balanced round-robin across whatever is registered. A service that resolves to nothing is503— the gateway is working and there is nothing behind it, which is not the502that means an instance refused the request. Behind a feature, so a gateway with a table of fixed addresses compiles no registry client at all.
0.7.4 — 2026-09-05
Security
- Request smuggling. Duplicate
Content-Lengthwas accepted and the first won;Content-LengthandTransfer-Encodingtogether were accepted and the length quietly ignored;Transfer-Encodingwas matched by substring, soxchunkedpassed andidentity, chunkeddid not; andContent-Length : 5parsed as a header. The connection buffer lives for the whole connection and the body reader drains exactly its length, so whatever a front-end thought was the body and this server did not is read as the start of the next request. Behind any proxy that resolves the ambiguity differently, an attacker prefixes a request onto a pooled connection and captures or rewrites the next victim's — and none of the session or CSRF work above helps, because the request the router sees was never the one the visitor sent. All four shapes are refused now, per RFC 7230 §3.3.3, and a second test holds that ordinary messages still parse: a security fix that refuses real traffic is a denial of service wearing a patch's clothes. - The CSRF token survived signing in, so session fixation was only half defended.
regeneraterotated the id and left the data, andtoken()returns the value already there — so an attacker who plants a session cookie knows the token the *authenticated* session then answers to, and can drive state-changing requests from a page of his own for its lifetime. Rotating the id stopped him riding the session and not much else. The token goes with the id now, as Laravel'sStore::regeneratehas always done. - Nothing throttled the second factor. The password form checks the address lockout in three places; the two-factor and recovery forms checked it in none. Somebody who already holds a password had an unlimited-rate oracle against six digits — three of which are accepted at any moment, because of the step window either side — and a second factor guessed in minutes is not a second factor. Worse for the recovery form, where a miss runs one argon2 verification per stored code, so an unthrottled attempt is also half a second of this server's CPU that anybody can spend. Both forms check it now, and — this is the half the audit did not name —
refusecounts the failure. Adding the check alone would have read a counter nothing ever incremented: a throttle that reads like a throttle and never fires. A test holds both halves for every form that checks a secret. javascript:in a menu link, and the Content-Security-Policy that was never sent. The menu URL was free text rendered straight into anhref, so a holder ofmenus.managecould plant a link every signed-in person — super administrators included — sees in the sidebar and runs on click. It has to be a path inside the application now,//hostrefused with the rest, which is the rule the dashboard field always had and this one never did. The other half is worth saying plainly: five places in the kit explain that they avoid an inline<style>or anonclickbecause the pages are served under a policy with nounsafe-inline, and no policy was ever set. The discipline was real and held — nostyle=attributes, noon*handlers, no inline script, no external origin — and it bought nothing, because a browser enforces the policy it is given. It is given one now, and that discipline is exactly what makes turning it on safe: this is a policy the pages already satisfy.nosniff, a referrer policy andX-Frame-Optionscome with it.- Response splitting. Header values were never checked for CR or LF, on insertion or on the way out. A newline ends the header and two end the head, so a value an attacker reaches becomes headers of their choosing and then a body of theirs — a
Set-Cookiethat fixes a session, or a second response a cache in front will serve to somebody else. The shape it arrives in is ordinary: a redirect built from a form field where%0d%0asurvived being decoded. Control characters are stripped from names and values now; a tab, which is legal, survives. - Every secret fell back to a clock-seeded mixer when
/dev/urandomcould not be opened. It was the only entropy source in the framework, and the fallback — a xorshift seeded from the nanosecond clock and a heap address — sat behind a log line. It fed session ids, CSRF tokens, API token secrets, argon2 salts, AES-GCM nonces, WebAuthn challenges andAPP_KEY. A session id drawn from 64 bits of clock is one that somebody who knows roughly when you signed in can enumerate; anAPP_KEYdrawn from it makes every signed cookie forgeable. The trigger was not hypothetical — file-descriptor exhaustion is something an attacker can induce. There is no fallback now: a process that cannot obtain randomness stops rather than issue a secret that can be guessed. - A response that changed nothing rewrote the session and re-sent its cookie, which made signing in a race. Static files are the router's fallback and the fallback runs the global middleware, so every CSS, JavaScript and font request reached the session layer — and for a signed-in visitor the guard
!dirty && !existedwas always false.Guard::loginrotates the session id and destroys the old record; the asset requests a browser fires alongside the sign-in are still holding the *old* id, and each one wrote that record back and handed the browser a cookie pointing at it. Whichever response landed last decided which session the visitor kept. When an asset won, they were returned to a pre-login session and the next protected request answered 401 — signed out seconds after signing in, intermittently. The resurrected record was also a live credential after the rotation meant to destroy it. - "Other devices have been signed out" was not true. Three controllers wrote
users.session_epochand two also wrote the matching session key, and nothing anywhere read either. Every other session stayed exactly as valid as it had been — after a password reset, which is what somebody does when they believe their account has been taken.support::epoch::SessionEpochnow compares it, in all four authenticated groups, and a test refuses any group that installsAuthenticatewithout it. - A password reset signed somebody in without checking whether they may sign in, or asking for their second factor. The password form and the magic link both check; this did not. So an account an administrator had deactivated signed itself back in through "forgot password", and anybody holding the victim's mailbox walked past the victim's enrolled authenticator. Activation had the same gap. Clearing a lockout on reset stays — owning the mailbox is a legitimate way out of one.
users.updatealone was enough to become a super administrator. The form offers every role,super-adminincluded, and nothing stopped somebody posting it at their own id;destroyhad refused to act on your own account since it was written. Editing your own name is still fine — editing your own grants is not, and a super role holder is no longer editable by somebody who is not one.roles.updatehad the matching hole:destroyrefused a super role,updatedid not, so the role could be renamed tosuper-adminor the real one renamed out of existence.- The SQL Server driver ignored
sslmodeentirely. It was parsed and validated forsqlserver://URLs and then read by nobody: every connection used the default, "encrypt and trust whatever certificate turns up", soverify-fullgot no verification at all andsslrootcert=was discarded. Anyone on the path could present a self-signed certificate and read the session, including the LOGIN7 packet whose password is only obfuscated.preferkeeps the documented compromise, because SQL Server's own startup certificate cannot be verified; asking for verification now gets it. - The PostgreSQL driver sent the password in the clear whenever asked. With
sslmode=prefer, an attacker answers "no TLS" to the SSLRequest, then asks for a cleartext password, and receives it. The MySQL driver in the same crate already refused the equivalent with a comment explaining why; one crate should not hold two policies on one threat. It is refused on an unencrypted socket now, and still allowed inside TLS, which is how PostgreSQL authenticates against LDAP and PAM.
Added
- Named database connections and a budget across them, for database-per-tenant applications.
rustlavel::Connectionsholds a connection per name — central and tenant coexisting, wherestate::<Database>()keyed by type could only ever hold one — andget_or_openis the call a tenancy middleware makes per request, reusing the pool the last request opened. The budget is the part that is easy to leave out, and the first design of it was wrong in a way worth recording. A shared semaphore across pools is the obvious answer and it fixes nothing: a permit is released the moment a connection is handed back, while the socket stays open in the idle queue. The semaphore counts connections *in use*; the server counts sockets *open*. Fifty resting pools hold five hundred sockets while every semaphore reads zero, and PostgreSQL ships withmax_connections = 100. So the registry asks each poolopen_count()— idle plus borrowed — and callsclose_idle(), least recently used first, before opening more. Never a borrowed one: that connection is in the middle of somebody's query, and taking it away turns a capacity problem into a failed request. When everything is borrowed there is nothing to free and the pools' own semaphores make callers wait, which is the right answer to "genuinely busy". The default budget is 80 against a default server allowance of 100, because a client that claims the whole allowance leaves nothing for psql, a backup, or the next deploy. Worth knowing for anybody who hits this wall: the refusal comes from the server, and it does not land on the tenant that caused it. The busy tenant reuses connections it already holds; the one refused is whichever happened to need a *new* one, very likely a quiet subsidiary. The company that suffers is not the company responsible, and the error points at the database rather than the code. - Migrations against any database, from application code — which turned out to already be true.
Migrator::new(&db, migrations)takes any database and any list and is public and re-exported; it was looked for inApp::migrationsand the CLI, where it is not. An API nobody can find is an API nobody has, so it now carries a worked example of provisioning a tenant. In the kit,modules::migrations_for(&["sales", "accounting"])answers "only the modules this tenant enabled", withmodules::names()for a screen that offers them. - A service can be overridden for one request, which is what makes database-per-tenant multi-tenancy a middleware rather than a rewrite.
Request::state<T>reads the request's own extensions first and falls back to the application's, so a tenancy middleware resolves the tenant, opens or reuses itsDatabaseand callsreq.extend(db)— and every controller underneath goes on sayingreq.state::<Database>(), talking to the right database without knowing tenants exist. Asked for by an application porting a Laravel ERP: a holding company and its subsidiaries with a database each, and about 550 controllers once the port is done. Threadingtenant::db(&req)through every handler is the same program written five hundred more times, and one missed call site reads another company's data. It is a lookup order, not discovery — the rule against runtime magic is about values appearing with no line you can find, and the middleware that overrides a service is an ordinary explicit line inmain.rs. A request given nothing gets whatmain.rsregistered, which is every request in an application with no such middleware. A test holds the property that matters most: an override does not outlive its request, because a tenant connection leaking into the next visitor's would be the worst failure this could have. The rest of the tenancy work — a named connection registry, migrations runnable against an arbitrary database from application code, and a cap across tenant connections — is planned inROADMAP.mdrather than guessed at here. @route("name")in templates, and a home that is a route name. The kit declared thirty named routes and hard-coded fifty-four paths in its templates:Router::url_forexisted and was tested, and nothing outside its own tests ever called it. The names were used byroute:listand nothing else, so moving a route meant a search-and-replace whose misses announced themselves as 404s.@routeresolves at render time, like@lang, and the filling-in still happens in one function thaturl_forand the directive share. A name nobody registered is an error rather than an empty string —href=""is a link that looks like a link and reloads the page it is on. Where the application opens ismenus.home, and it holds a route name rather than a path. It used to bemenus.dashboard_urland it drove the logo only:GET /and the landing after sign-in both hard-coded/dashboard, so an administrator could point "Dashboard opens" at/reportsand still arrive at the dashboard every morning. All three read one function now. A name that no route carries is refused when it is saved, which a path could never be.- Sessions in Redis, behind
redis-sessions, for an application running more than one process — the file store keeps a session on local disk, which is right for one machine and wrong for two. Redis owns the expiry, so a session that stops being touched disappears on its own and there is no directory quietly filling with one dead file per visitor. The client is the one inrustlavel-cache; a second Redis implementation would have drifted from it. - A changelog on the documentation site, generated from this file by
docs/build-changelog.pyso the two cannot disagree. It refuses to render a construct it does not understand, and refuses to write a page with unpaired markup — both of which it caught while it was being written. - A taken port no longer stops a run.
Address already in useis the most common way starting an application fails, and the operating system's answer to it —Io(Os { code: 48, kind: AddrInUse })— names the struct holding the problem rather than the problem. The server now walks up from the port it was asked for and says which one it landed on:WARN port 8399 is in use, so this is serving on 8400 instead INFO Rustlavel serving on http://127.0.0.1:8400Not in production. There the default is a single attempt, because there something does depend on the number — a load balancer, a health check, a firewall rule — and a server that quietly moved to 8301 while traffic still went to 8300 would look like an outage with no cause in the logs. It refuses to start and says why instead.
server.port_attemptsoverrides the default in either direction, and onlyAddrInUsewalks on: a privileged port or an address that is not on this machine is still that error.doctornow reports a busy port as a warning rather than a failure, and names the port the server will most likely use.
Fixed
rustlavel serveleaked a process on every reload, and that is what made a port stay busy. It ran the application throughcargo run, so the application was a *grandchild*: the handleserveheld was cargo's. Killing it on reload killed cargo and left the application running — still holding the port — so the restart failed withAddress already in use. Changing the port did not help, because the process holding the new port was the orphan started with that same new port a moment earlier.servenow runscargo buildand then executes the binary, so the handle is the application.killkills it, Ctrl-C still reaches it because it stays in this process group, and there is one less process in the tree. Where cargo put the binary is asked ofcargo metadatarather than assumed, sincetarget/moves for a workspace member andCARGO_TARGET_DIRmoves it for everybody. Measured across three reloads: one application process throughout, noAddrInUse, and no restart when nothing is touched.- The error a failed
mainprints named the variant, not the problem. Rust formats it withDebug, so the derived form is what people actually saw:Error: Io(Os { code: 48, kind: AddrInUse, message: "Address already in use" }).Debugnow reads exactly likeDisplay, so it is the sentence that was written for them. Test output gains the same thing:unwrap_err()on a bad config says which file and line rather than which variant.
Fixed
rustlavel upgradeleft a project that could not build. It merged every kit file and then leftCargo.tomlnaming the version the project started from, so the first thing anybody saw after a clean merge was a wall of compiler errors about items that did not exist — for a reason the command could have fixed itself. It now bumps the dependency and *unions* the feature list with what the kit needs, which matters because the kit gains flags over time: a 0.5.0 project has noi18n, and@langdoes not compile without it. Features a project added of its own accord are never removed, and apath =dependency is left alone — that is somebody working against a checkout, and a version number would break them. Measured: a project scaffolded by the published 0.5.0 CLI upgrades, reportsCargo.toml: 0.5.0 → 0.7.2, and gained i18n, and — after resolving the one six-linesrc/lib.rsconflict the report explains — builds.- The ten packages the kit's code imports were written out in
new, and nowhere else, so nothing could check them. They areauth_kit::REQUIRED_PACKAGESnow, read by both the scaffold that writes them and the upgrade that repairs them.
0.7.2 — 2026-09-05
Added
rustlavel upgrade. A starter kit is a hundred files copied into an application, and from the moment they land they are the application's. That is what makes them useful and it was also a dead end: a release that fixed one of them could reach nobody, because reaching them meant overwriting whatever had been written on top. Every project created with 0.5.0 was still running 0.5.0's kit, with no way forward but a manual diff.upgradereconciles three versions of each file instead of two — what the version in.rustlavel/manifest.jsonwrote, what is in the project now, and what this CLI would write today. Where one side moved, that side wins and nobody is asked. Where both moved to the same place, so does the merge. Where both moved differently, the file is written with<<<<<<<markers and the project stops compiling until a person decides — an upgrade that could not decide must not be able to pass unnoticed. The base comes from crates.io: every release carriestemplates/inside its.crate, so any version a project was created with is a download away, and downloads are cached. Fetching and unpacking iscurlandtarrather than an HTTP client, a TLS stack and a gzip decoder added to a CLI that otherwise depends on nothing;doctornow says so if either is missing.--dry-runreports without writing.--from <version>supplies the base for a project older than manifests. A dirty git tree is refused, becausegit checkoutis the way back and a clean tree is what makes it available. Measured rather than assumed: a project scaffolded by the *published* 0.5.0 CLI, edited in three places, upgrades with 60 files merged, 16 added, 36 already current, and one conflict — in the one file that both the editor and the release had changed.
Changed
- Every file the kit writes is now a file. Eight of them —
main.rs,lib.rs, the two registries, the seeder and the three config files — were Rust constants written to a project without ever existing as templates. That split is what let 0.7.0 ship a seeder with{{crate_name}}still in it, and it also meantupgradehad nothing to use as a base for them. They are template files like the other ninety-nine now, in one manifest, written by one loop. The guard that used to check eight hand-listed constants now renders every file in the manifest, so a template added tomorrow is covered the day it is added.upgradecan still read those old constants out of a published crate's source, so a project created before this change gets a proper merge rather than a conflict over a file nobody touched.
Fixed
- The starter kit's own tests failed in a scaffolded project. Translating the auth pages replaced the literal "Sign in" with
@lang(…), and@langasks the *engine* for its translator — but the test built its application withApp::bare()and no translator, so the page rendered the key. The wiring lives insupport::viewsnow, whichmain.rsandtests/web.rsboth call: a test that renders a page stands in for a visitor, and a visitor never sees a key. - The TLS test containers could not read their own key on Linux.
docker/certs.shleft the PostgreSQL key at mode 600 owned by whoever ran it. On macOS that works by accident; on a CI runner the ownership is real, so postgres gotPermission deniedand the container exited before a single test ran.
0.7.1 — 2026-09-05
Fixed
rustlavel new --with auth-kitdid not compile on 0.7.0. The seeder was written to the project straight from its constant while every other generated file went through the placeholder renderer, so it arrived carrying the literal text{{crate_name}}. It iscrate::modules::permissions()now — the seeder compiles *inside* the crate it belongs to and cannot name it from outside — and a test refuses any constant that is written unrendered while still holding a placeholder, or any rendered one that has a placeholder left after. Worth saying plainly: the templates were tested and the *writing* of them was not, so nothing in the suite looked at what a real project ends up with. This was found by scaffolding a project from the published crates and building it, which is now the last step of a release rather than an afterthought.- Every crate now has a README on crates.io. All thirty-three shipped without one, so each page said so. The root
README.mdcould not serve: cargo refuses a readme outside the package it belongs to, and[workspace.package] readmeresolves against the workspace root, which lands outside every crate. So each declares its own. The meta-crate and the CLI are written by hand because those are the pages people actually land on; the rest carry what the reader of a package page needs — what it is, that it belongs to Rustlavel, and the feature flag that turns it on.
0.7.0 — 2026-09-05
Added
- Modules: a feature owns its routes, permissions and settings. The kit was laid out by technical layer — every controller together, every model together — and the cost is that changing one feature means opening six files in four directories with nothing in the tree saying those six belong together.
src/modules/is the alternative, andbackupis the first to move: its dump format, schedule arithmetic, controller, four permissions, three settings and four routes are one directory now, andmod.rsis the whole of what the application has to know about it. Nothing is discovered.modules::all()is a hand-written list, the waymain.rsis. The seam is the existingPlugintrait, which already carried routes, middleware and state;Moduleextends it with the four things a plugin could not register because the application collected them centrally. The middleware comes with the routes. Those four routes lived insider.group("/admin", …), which appliedAuthenticateandIdleTimeoutto everything in it. A module registers on the bare router and inherits neither, so the move states them — a version that forgot would have put four unauthenticated routes into the application, one of which restores the database. make:moduleandmake:service, androute:listgained--path,--methodand--namewith a count under the table. Ninety-six routes do not fit a screen, and piping throughgreploses the header row that says what the columns are.@langin the template engine, and the sign-in pages and sidebar translated. Settings → Language documented alang/<code>.jsonconvention and, until this release, promised that "adding one is the whole of adding a language". It was untrue three times over: the kit did not enablerustlavel-i18n, created nolang/directory, and no template anywhere asked a translator for a word. A file could hold a thousand phrases and not one word on screen would change.@lang("auth.sign_in")now exists, with@lang("key", "name", value)for a phrase carrying:nameplaceholders. Two decisions in it are worth stating. The key is a literal fixed at parse time, so a template still cannot compute what it renders — this is not the "calls in templates" the engine rejects. And the words are looked up when the page is written, never when it is parsed: oneEngineserves every request and a parsed template is cached and shared, so resolving early would have served the first reader's language to everybody after them. There is a test for exactly that. The translator sits on theEngine, which is application-wide; the locale travels in the view context underapp_locale, which is per page and whichpage::shellalready set.rustlavel-viewdefines a smallTranslatetrait andrustlavel-i18nimplements it, so the template engine keeps its single dependency and the HTTP stack stays out of it. Translated so far: the sidebar, the header, and the sign-in pages, in English and Indonesian. The settings tabs and administration screens are not, and the Language tab says so rather than implying otherwise. Two guards keep the rest honest: every@langkey a template writes must have an English phrase, and every language file must carry exactly the same keys.- Twelve more icons for a menu item, and the Dashboard entry is first in the sidebar and points where you say. The rail's first entry is the one people reach for without reading, so adding a custom menu no longer pushes it down — and
menus.dashboard_url, edited on the Menus screen rather than in Settings because it is navigation, decides where it and the logo above it go. A path inside the application only: the home button is not a place to send everybody off-site. - Bahasa Melayu is off the language list. Two languages with files, not three with one that would never have had one.
- Notifications have a page and a table of their own. The bell in the header was the audit trail wearing a different label: it read
/admin/notifications, which was a filtered view of who-did-what, and "See the whole trail" took you to the audit log. So it showed nothing addressed to you, and it was hidden entirely from anybody withoutaudit.view— which is most people, all of whom have notices of their own. There is now anotificationstable where a nulluser_idmeans everybody: a notice for one person and an announcement to all of them are the same row, because they are the same thing to a reader. Read state is a separate table, per person, so the first reader of an announcement does not mark it read for the rest./notificationslists yours with the unread ones marked, "Mark all as read" clears them, and anybody holding the newnotifications.sendpermission can write one — to a person or to everybody — from the page itself, because the alternative is an announcement feature nothing can reach.
Security
- Removing a passkey now asks for one. It was an ordinary form post with nothing behind it but the session cookie and a CSRF token, which travel together — so stripping every passkey off an account was the first and easiest thing to do with a stolen session, and what it left behind was a password the thief already had. The removal now carries a WebAuthn assertion, checked against this account's credentials and against the user handle in the assertion, before anything is deleted. It is refused when the proof is missing, blank, or not an assertion at all — the three shapes a forged request takes, each with a test. The assertion may come from any of the account's passkeys, not the one being removed. Requiring that one sounds stricter and is worse: the reason to remove a passkey is usually that its device is gone, and a rule only the lost device can satisfy leaves a dead credential on the account for good.
Fixed
declare_moduledestroyed anymod.rsthat held more than declarations. It collected every line, dropped the blanks and sorted the lot — correct for a file that is nothing butpub modlines, and fatal for one that also holds a trait and a function. The firstmake:moduleturnedsrc/modules/mod.rsinto sorted fragments and the project stopped compiling. It inserts now, in order, leaving every other line where it was.- Test fixtures shared a directory between runs. Seven tests across four crates named a temp directory after the test and then deleted it on the way in — enough for one run, and not for two: a second
cargo teston the same machine wiped the first one's tree half-way through, and both failed somewhere unrelated. The process id is in the name now. This is the rule the project already states, applied to the places that were missing it. Appreplaced a view engine the application had built for itself. It registered its own wheneverresources/viewsexisted — which is whenever an application has views — so.views(...)silently did nothing. An engine carrying a translator was swapped for one that could not translate, and@langrendered its keys. It now defers to an engine already registered.- The Language tab listed translation files from a different directory than the translator reads. The tab asked config for
view.lang;i18nreadsapp.lang_path. A project that moved its language files would have had the two looking in different places. - The Language tab looked broken, and one third of it was. Changing anything there appeared to do nothing, for three different reasons.
app.currencygenuinely did nothing:money()was called only by its own tests, and the one caller ofpreferences()wrotelet (number, _) = ...— it fetched the currency and threw it away. The setting is gone andmoney()takes the symbol from its caller, since there is no money in an account and a role.app.number_formatdid work, invisibly: a fresh install counts 1 user and 2 roles, and1is1in every format there is — the tab now prints a worked example (1.234.567) beside the dropdown, and a test requires the three formats to render differently.app.localealso worked, invisibly: it setslangon every page, which the tab now says, rather than implying it translates the interface. Worth recording about the guard rather than the bug: the catalogue test passedapp.currencyall along, because reading a key and discarding the value counts as reading it. That test proves a key is *fetched*, not that it is *used*. - The Menus screen wrote rows the sidebar never read. It has always saved menu items — and told a person, in its own empty state, that "the application falls back to its built-in navigation until you add something". Nothing read the table, so adding something changed nothing:
partials/nav.rl.htmlwas a hard-coded list. The sidebar now draws thesidebarlocation when it has items, honouring order, nesting and each item's permission, and treating a parent that points at#as a heading rather than a dead link. If that leaves the viewer with nothing, the built-in navigation comes back — a custom menu is a convenience, not a way to edit yourself out of the application: the custom menu sits *above* the built-in list rather than replacing it. Replacing it was the first attempt and it was wrong — adding one item took Users, Roles, Settings and the audit trail off the rail for an administrator who never asked to lose them. Each built-in entry checks its own permission already, so an administrator keeps everything and everybody else keeps what they are granted. Which items appear is a pure function with tests; the viewer's permissions are fetched once rather than once per item. - A menu item naming a permission that does not exist saved silently and then never appeared. The field is free text on purpose, since a menu may point at a feature whose permission has not been created yet. The silence was not on purpose: an item guarded by a permission nobody holds is drawn for nobody, so it looked saved, listed correctly, and vanished. Saving one now says which permission is missing and what to do about it.
- Signing in with a passkey and no authenticator app answered 419. The scripts read the CSRF token out of the hidden
_tokenfield, which exists only where a form rendered — and on the two-factor page that form sits behind@if(has_totp). Somebody enrolled in a passkey and nothing else got a page with no token anywhere on it, so "Use a passkey" posted an empty one and was refused. It worked in the configuration it was built in (passkey *and* authenticator) and in no other. The token is now a<meta>in both layouts, so every page carries one whether or not it renders a form, and the scripts read that first. Measured: the empty token still answers 419, the meta token reaches the handler. - Settings → Appearance did not change the colours until a minute later.
/css/theme.cssis generated from those settings and changes the moment somebody clicks Save — and it was servedCache-Control: public, max-age=60with noETag, so the browser went on painting the old colours out of its own cache. The one file in the application that changes on every Save was the one told to be cached, whileapp.css, which changes only on a rebuild, already revalidated. It now sendsno-cacheand a weakETag, and answers the revalidation it invites: unchanged colours come back304with no body, and a changed colour comes back200. Measured both ways, not assumed.
Removed
- The "Check against breached passwords" switch is gone from Settings → Security. It was never implemented: the lookup needs an outbound HTTPS call behind a feature flag a project may not have enabled, so the honest thing at the time was to refuse every new password while the switch was on and say so on the tab. That is still worse than not offering it. A starter kit should not ship a control whose only working state is off.
0.6.0 — 2026-09-04
Changed
- The starter kit looks like the dashboard it was drawn from. 0.5.3 took the *behaviour* out of that mock-up — a Backup tab, a Language tab, a header search, notifications, toasts — and none of its look, which is why the pages still read as a default Tailwind scaffold. The rest of it is here:
- A blue-grey palette rather than a neutral one. Every surface, border and label sits on a hue of about 250, which is what keeps a screen of tables and forms from reading as photocopier grey. The
ink-*numbering keeps its meaning, so a template written against the old scale still says what it meant. - A dark navy sidebar in both schemes, 240px, with the application's mark and description at the top and the signed-in person at the foot. A pale rail beside a pale page was one border away from not being there.
- A header that says where you are, breadcrumb over title, with the search box centred and tinted so it reads as a control rather than as a hole in the bar.
- Page titles sit on the page, not inside a card. A page that opened with two nested boxes before any content now opens with its own name.
- Flatter cards, denser type, pill badges, quieter table headings, and the primary action in the design's own blue — which is this palette's 700 rather than its 600.
- A split sign-in screen: a flat navy panel on the left, the form on the right on the same pale ground as every other page, and the panel dropped rather than stacked on a narrow screen, because a decorative half-page above a form is something a person scrolls past. A second way in gets a second button under a rule, not a line of small print under the first.
- Inter, self-hosted. The face the design is set in ships with the kit in
public/fonts/under its SIL Open Font License, rather than being fetched from a font CDN — which breaks behind a firewall, sends a request per visitor to a third party, and would need the policy this kit is written against opened up. One variable file per subset withunicode-range, so a page with no accented characters never asks for the 85K Latin Extended half; the Latin half is 48K. - A mark inside each address and password field — an envelope, a padlock — on every sign-in, activation and reset form, positioned rather than laid out beside the box so the field keeps its width, and inert to the pointer so clicking the icon still focuses the field.
- The menu button collapses the rail to its icons on a wide screen and opens the drawer on a narrow one — one control, in one place, at every width. The choice is remembered, and applied before the first paint so a reload does not flash the wide sidebar. In the rail the group headings become the dividers they were standing in for, because a heading clipped to "ADMIN" is worse than no heading. What is *not* here is the fourteen domain screens in that mock-up. They belong to an application, not to a scaffold.
- A blue-grey palette rather than a neutral one. Every surface, border and label sits on a hue of about 250, which is what keeps a screen of tables and forms from reading as photocopier grey. The
Added
- A second file manifest, for what is not text. Everything the CLI writes went through the placeholder renderer, which is right for a template and fatal for a woff2: braces that happened to line up inside the compressed stream would be rewritten and the file would arrive the right size and unreadable.
BINARY_FILEScarries those byte for byte, and a test compares what is embedded against what is on disk, refuses a binary extension in the text manifest, and fails if a file undertemplates/auth-kit/is in neither — because a template nobody listed reaches no project, silently.
Fixed
- The stylesheet the kit shipped was stale, and 58 classes had no rules.
public/css/app.cssis a Tailwind build committed so a project needs no Node toolchain, and that is the trap: adding a class to a template is free, and the build only learns about it when somebody remembers to rebuild. 0.5.3 added a search box, a notification list, toasts, pagination, tab pills and row avatars, and shipped every one of them unstyled. Rebuilt — and a test now fails if a class the templates write is missing from the stylesheet beside them. - Twelve colours on Settings → Appearance changed nothing.
theme_controllerhas always generated--sidebar-bg,--sidebar-text,--sidebar-active-bg,--sidebar-active-text,--login-fromand--login-tointo/css/theme.css, and no rule anywhere consumed one. The sidebar and the sign-in panel are drawn from them now, which is what makes that tab do what it says. - Three buttons labelled "(Default)" restored something else. Settings → Appearance offers quick presets, one named for the value the catalogue declares — and it kept handing back the colours of the palette this release replaced. A test now derives each of those presets from the catalogue and fails when the two drift.
- Checkboxes and radios rendered as a dark square.
border-ink-300and friends half-style a native control: the browser goes on painting its own box and the declared border turns it into something neither styled nor default. The classes are gone andaccent-colorin the base layer does the whole job, which is what the design does too. - The menu button did nothing above
lg. It waslg:hidden, so the only control for the sidebar disappeared exactly where there was room to want one. - Sixteen settings were declared, drawn on a tab, saved to the database and read by nothing. The Email tab wrote six keys the mailer never consulted — it was built once at boot from
config/mail.json— so a host, port, encryption, username or password typed there changed nothing at all. The General tab's timezone, date format and description, and the Language tab's locale, were the same. Each is now wired:support::mail::mailer_forbuilds the mailer per send from the store,support::format::Datesrenders every timestamp on the audit and backup pages through the chosen timezone and formats, and the name, description and locale reach both layouts — the description as a<meta>and the locale aslangon<html>. backup.pathnow decides where a backup is written, and where one is read back from. It was a text field beside a destination dropdown, and the writer called a helper that ignored both. Every path in the controller goes through one place now, because wiring only the write half would have left backups that could be made and not downloaded — and a reader falls back to the built-in directory, so pointing the field somewhere new does not strand the backups already on disk. The traversal check that stops a name climbing out of the directory applies to whichever directory is configured.- The backup destination, S3 bucket, fallback locale and first day of week are gone from the tab. Nothing uploads a backup, this kit does not enable
rustlavel-i18n, and there is no calendar to start on a Monday. A control that cannot be honoured is worse than no control. - The date formatter matched the dropdown's labels, not the values it saves.
DD/MM/YYYYis what the tab shows;d/m/Yis what it stores, so three of the four date formats fell through to the fourth — and the test written beside it, against the same labels, agreed with the bug. The formatter now takes its cases from the stored values, and a test takes its inputs from the catalogue and fails if two formats render alike. - Half the timezone list was UTC. The offset table knew Kuala Lumpur and Bangkok, which the tab does not offer, and not Europe/London or America/New_York, which it does. It also had one fixed offset per zone, which is wrong for those two for part of the year, so it now takes the instant and applies the British and US summer-time rules; a test asserts the exact Sundays the clocks move, and another fails on any zone the tab offers that the formatter answers UTC for all year.
- Every date on every page goes through that formatter.
tokens::humanisewas a second, absolute, UTC formatter that ignored all three settings, and it was still rendering the users list, the dashboard, the profile, search, passkeys and half the audit page. It is gone, and the line under a timestamp now says how long ago — which is what it is for and what it did not do. - The profile page's "Joined" was the literal
—for everybody. - The password minimum a form advertised and the one the server enforced were read from different places. The form asked
Config, the check asked the settings store, so raising it on the Security tab produced a field that accepted twelve characters and a server that refused them. One function reads that key now. - A test walks the catalogue and fails on a key nothing reads. This is the bug that kept coming back and never showed up in a review — the code that declares a setting and the code that ignores it are in different files, and both look right. Enumerating the catalogue is what finds it.
0.5.3 — 2026-09-03
One line per crate, and it is the line that makes 0.5.2's headline fix real.
Fixed
rust-versionreached no published manifest. 0.5.2 declaredrust-version = "1.88"in[workspace.package]and none of the thirty-three crates inherited it: workspace package fields are opt-in, and a member takes one only by asking withrust-version.workspace = true. crates.io reportedrust_version: nullfor every crate in that release, and a consumer on Rust 1.87 still got `letexpressions in this position are unstablefrom insiderustlavel-core` — the exact error the field exists to replace. Found by exercising the published artifact rather than the working tree:cargo add rustlavelon 1.87, which still locked 0.5.2 and still failed the old way. With the inheritance in place cargo stops at resolution instead:error: rustc 1.87.0 is not supported by the following package: rustlavel-core@0.5.3 requires rustc 1.88Nothing else changed. If you are on Rust 1.88 or newer, 0.5.2 and 0.5.3 are the same code.
0.5.2 — 2026-09-03
An interactive rustlavel new, a Backup tab that does what it says, and a minimum toolchain that is measured rather than assumed.
Added
rustlavel newasks. With no--with, it now asks what kind of application, which database, and what else to put in. It answers itself when nobody is there — every prompt falls back to its default when stdin is not a terminal, and--with,--alland--yesskip it — so a scaffold in CI produces the same project it always did rather than hanging on a question.- The Backup tab has a schedule, a retention window and a destination. The retention is live: after a successful backup the ones past the window go, row and file together. The schedule is a statement of intent — this application has no clock, so something outside it has to act on the answer, and the panel says so, loudly, when a schedule is set and nothing has ever run one. The destination offered local disk and S3-compatible — and that was wrong; see Unreleased. Nothing here uploads a backup anywhere, so the dropdown was the very failure its own note described, and it is gone.
- The Language tab's number format and currency are read by something. Every count on every administration page and the size column on the Backup tab go through one formatter. (The first day of the week was listed here too and read nothing; see Unreleased.)
- A search box and a notification list in the header. Search covers people, roles, permissions, menu items and settings, and a group is included only when the person may open what it links to. The notifications are the audit trail rather than a second table kept in step by hand, filtered to what is worth interrupting somebody for — sign-ins are left out, because a list that reports every sign-in is a list nobody reads. Flash messages become toasts, server-rendered first so they survive with scripting off.
Fixed
rust-versionwas wrong, and wrong in the direction that matters. 0.5.1 did not declare one; the first attempt at this said 1.85, reasoned from edition 2024. The code uses let-chains (&& let) in twenty-one places across eighteen crates, and those stabilised in 1.88. Measured by installing the toolchains: 1.87 refuses, 1.88 builds the whole workspace with every feature and every test target. A declared floor that is too low is worse than none — cargo tells somebody on 1.86 the crate is compatible and rustc then refuses it.- The plugin lines never reached an auth-kit project.
auth-kitwrites its ownmain.rsand that template had no{{plugins}}in it, so the fix shipped in 0.5.1 did nothing for the shape most people scaffold. The test passed because it called the helper directly; it renders all three templates and reads the output now. - Every
MAIL_*variable was inert. There was noconfig/mail.json, andConfighas no automaticMAIL_HOSTtomail.hostmapping, so the mailer used its defaults while the Email tab — which reads the environment directly — reported that the environment was in charge. - Three ways the Backup screen produced something that was not a backup:
Createcould not succeed on a stock scaffold, because the exporter paginated withorder by "id"and the RBAC pivots have no surrogate key; the dump covered a hardcoded list that three tables added in one afternoon never reached; andRestoreleft the settings cache alone, so an administrator restoring a backup to undo a bad change saw the bad change still there. QueueDashboard::new(db.clone())in the scaffold's own comment did not compile — the signature takes anArc<dyn Queue>.rustlavel-openapi's header example called anOpenApitype that has never existed. The replacement is a compiled doctest, which caught the first attempt at the fix.cache.pathdefaulted tostorage/framework/cachewhile the scaffold createsstorage/cache.
0.5.1 — 2026-09-03
Three fixes, two of them to things 0.5.0 claimed were already done.
Fixed
- The scaffold registered eight of the twelve packages that ship a plugin.
otelhas a no-argument constructor and was left out of the list — andOpenTelemetrywas missing from the prelude as well, so the line could not have compiled had it been written. That is the same bug 0.5.0 fixed forMetrics, still present forotel.mcp,oauthandoauth-providerwere not even named in the comment. The lists are maintained by hand, so the test no longer trusts them: it reads the crates directory, finds everyimpl Plugin for, and fails on any package the scaffold offers but says nothing about. - A cache miss cost two queries.ModelCache::findcalledModel::findand then read the same row again to cache it — becauseModel::to_jsoncannot be rehydrated, so the model in hand was not enough to store. Two round trips on every cold read made the cache worse than no cache on the path it exists to help. Fetching the row once and hydrating from it gets both from a single query. A cache hit whose stored row no longer parses was also counted as a hit *and* fell through to the database, which overstated the hit rate by exactly the entries that were costing a query. The hit is recorded once a model has actually come back. - A test inrustlavel-aifailed on whichever runs interleaved: it collected everyai.callevent on the process-global bus and asserted there was exactly one, while ten other tests in the same file make a call. Being the only test that subscribes is not the same as being the only test that emits.
Added
OpenTelemetryis re-exported from the meta-crate and its prelude, likeTelescope,DebugBarandMetrics.
0.5.0 — 2026-09-03
A second-level cache for models, and three things that were switched on and doing nothing.
Added
rustlavel-model-cache— Hibernate's second-level cache, in this framework's vocabulary. Entities kept by primary key and query results kept by the SQL that produced them, over theCachetrait that already exists, so memory, file and Redis all work with no new configuration. Caching an entity is the easy half: one key, and the write that changes it knows which key to drop. Caching a *query* is the hard one, because a write to one row invalidates an unknown number of cached result sets and there is no way to enumerate them — a cachedWHERE role = 'admin'is invalidated by an insert whose shape the cache never sees. The answer is Hibernate's: a generation counter per table, recorded on every cached result and bumped by every write. One counter invalidates every stale result set at once. Blunt on purpose — a write to any row ofuserscosts you every cachedusersquery, which beats serving a list that is missing a row somebody just added. The query key is a 64-bit fingerprint *and* the statement, stored beside the rows and compared on the way out, so a collision is a miss rather than one query's rows served as another's. A model with no registered region is not cached at all: a package that quietly starts holding every table the moment it is registered is a cache nobody chose. Six integration tests run against PostgreSQL 16, MySQL 8.4 and Azure SQL Edge, and prove the two things unit tests cannot — that a second read does not reach the database, and that a write makes it reach the database again.
Fixed
- A model with a
boolfield could not be read on MySQL. MySQL has no boolean:BOOLEANis an alias fortinyint(1), and nothing on the wire tells a flag from a small counter, so the driver hands back an integer. The model does know — it declared the fieldbool— and that is the layer where the two are now reconciled. Zero and one only: a counter column declaredboolis a mistake, and guessingtruefor 7 would hide it. - A package the scaffold was asked for was never registered.
rustlavel new app --with telescopecompiled the dependency in and leftmain.rssaying nothing about it — the application ran,/telescopeanswered 404, and the generated file gave no hint a line was missing. The scaffold writes the line now, for the plugins that can be built from nothing; the ones needing a database handle are named in a comment rather than emitted as code that would not compile. DebugBarwas not re-exported from the meta-crate at all, and neither it,MetricsnorTelescopewas in the prelude — which is the one thing a generatedmain.rsimports.- One OTel test failed a few runs in a hundred for no reason. It asserts that a tag byte is absent from an encoded span, and reasoned carefully about the identifiers while forgetting that
Span::newtimestamps itself from the clock, so the nanoseconds supplied a stray0x7awhenever they contained one. The times are pinned.
0.4.0 — 2026-09-03
A new package, two bugs that made features look like they worked when they did not, and the administration area the starter kit was missing.
Added
rustlavel-audit— who did what, to which record, from where. Different from the application log on purpose: a log line is for whoever is debugging, an audit entry is a record somebody may be asked about a year later.req.audit("users.deleted").on("User", id).describe(...).record(). The address and the user agent are read off the request rather than passed in, because an entry that says "someone updated the settings" answers nothing. The actor column is nullable and deliberately not a foreign key: an entry outlives the account it describes, and a cascade would delete the evidence along with the subject.- RS256 in
rustlavel-webauthn. TPM-backed Windows Hello and a number of older security keys sign with nothing else, so a relying party that does not offer it turns them away at registration — which is what Chrome'spubKeyCredParamswarning is about. The arithmetic is written here, and the crypto rule survives intact: verifying an RSA signature involves no secret at all. The padding is checked by re-encoding the block a valid signature would produce, never by parsing the recovered one — a lenient parser is how Bleichenbacher's 2006 forgery worked. - Menu management and an audit page in the starter kit, plus a brand colour on Settings → Appearance that reaches every page. The hue comes from the choice and the lightness ladder stays, which is what keeps white-on-brand legible when somebody picks an unusual colour.
QueryBuilder::or_filter_opandor_filter_like:or_filtercould only ever mean equality, so a search across three columns withlikehad no way to say so.Permissions::users_in_role, which answers "is anybody still holding this role" with a count rather than a list.
Fixed
- Passkeys could not be registered at all. The starter kit's script sent an invention of its own —
raw_id,client_data_json, flat rather than nested underresponse— where the parser reads whatPublicKeyCredential.toJSON()produces, so every credential arrived with norawId. - Settings → Appearance had never coloured anything.
/css/theme.csswas served astext/plain, becauseResponse::with_textsets its own content type and was chained after the header that set the right one. A browser refuses a stylesheet served as plain text and says so only in a console warning, so nothing in the application noticed. Filessentcache-control: public, max-age=3600on everything, with no way to change it. Nothing it serves is fingerprinted, so that is a promise the filename cannot keep: rebuild a stylesheet and the browser keeps showing the old one for the rest of the hour. The default is nowno-cache— cache, but ask first — andFiles::cache_controlis there for URLs that do carry a content hash.BoxFuturewas not reachable outsiderustlavel-http, so a middleware written in an application could not spell its own signature. The trait was a documented extension point that could not be extended.rustlavel-authcalledGenericArray::from_slice, deprecated in newer generic-array releases. The framework's lockfile pins an older one, so clippy here stayed clean while every freshly scaffolded project got three warnings out of a dependency it never asked for.- The Roles page's Users column had been empty since the page was written: the handler filled it with a literal null because nothing in
rustlavel-rbaccould answer it. - Sign-in tab order. "Forgot?" sat between the address and the password in the document, so everybody tabbing out of their email landed on it.
Changed
- Five settings on the starter kit's Security tab were stored and read by nothing, which is worse than not having them. Magic-link sign-in, email verification, password-reuse prevention, the idle session timeout and the From address on the Email tab all do something now.
auth.require_mfawas enforced by the login form and by nothing else, so an activation link, a password reset and a magic link were three ways around it.
0.3.2 — 2026-09-03
Three bugs that only appear when the thing is run rather than compiled.
Fixed
FLAGS_ONandFLAGS_OFFdid nothing. The crate readflags.onfrom configuration and nothing mapped the environment into it, so the documented incident switch was inert. It now reads the environment when the configuration key is absent.- The starter kit's seeder logged the first administrator's activation link at
info, soLOG_LEVEL=warnhid the only way into a new application. It is printed now, with the full URL. - The four OTLP collector tests share one container and one log, and could interleave under a full workspace run. They take turns.
docker/env.shprintedKEY=valuewithoutexport, soevalset a shell variable thatcargonever saw and every integration suite skipped while reporting a pass. It also now setsDATABASE_URL, without which fifteen more did the same.
0.3.1 — 2026-09-03
Fixed
rustlavel new --with rbacand--with flagswere refused: both are features of therustlavelcrate, and neither was in the list the scaffold accepts.rbachad been missing for a release. A test now reads the meta-crate's manifest and fails if the two lists disagree in either direction, so this cannot drift again.
0.3.0 — 2026-09-03
The minor number, because Errors gained a field and a Validator built from a request now behaves differently for a browser: it redirects instead of rendering text. Everything else is new.
Changed
- A failed HTML form now redirects back to itself with the messages and the submitted input, instead of answering a plain
422with the page gone. JSON clients are unaffected and still get Laravel's shape. An application with no session middleware falls back to the old plain-text422.
Added
rustlavel-flags— runtime feature switches resolved per user or tenant, with percentage rollouts that are stable across processes, a store for operator overrides, andFLAGS_OFFas an incident switch that is read before the store is touched.rustlavel make:crud,make:packageandtinker.- A cookbook at advancedynamic.github.io/rustlavel/cookbook.html.
Flash, inrustlavel-http: somewhere to leave a value for exactly one further request. Implemented by the session, used by validation and the view layer, so neither has to depend on the other.Request::errors,old,old_field,has_errorsandprevious_url.- A nightly CI workflow and a
docker/set that stand up the eight servers the integration suites need — including the TLS certificates, which is why database TLS was untested even though CI already had the databases.
0.2.2 — 2026-09-02
Two bugs that met anybody who ran rustlavel new --with auth-kit on 0.2.1. Nothing else changed.
Fixed
- The starter kit shipped a
matchthat clippy rewrites asmatches!, socargo clippy -- -D warningsfailed on a freshly scaffolded project. A lint in the kit is a lint in an application's own build. - The scaffold's default test asserted a public home page, which the kit replaces with a redirect to the sign-in page, so
cargo testfailed immediately afterrustlavel new --with auth-kit. The kit now ships four tests of its own covering the guard and the CSRF check.
Added
- A CI job that scaffolds a project with the kit, builds it, runs its tests and runs clippy over it. None of the kit's ~57 files are compiled by the workspace, so a rename in the framework used to break them silently.
0.2.1 — 2026-09-02
The patch number rather than the minor, and that is the right slot: for a 0.x version Cargo treats the minor as the major, so a breaking change would have to be 0.3.0. Nothing here breaks anything. Everything below is new, so rustlavel = "0.2" picks it up on its own.
Added
rustlavel new app --with auth-kit— a starter kit, in the shape Laravel Breeze has: the controllers, views, routes and migrations are written into the project and belong to it, so the login page is a file you edit rather than a template you override. The library half stays in crates, so a security fix arrives withcargo update.- Sign-in with an authenticator app (TOTP), passkeys, and single-use recovery codes; a sign-in audit log; per-account and per-address lockout.
- Self-registration (switchable off) and admin invitation, both ending at a link where the person chooses their own password — so an administrator never knows one.
- Roles and permissions with a management area, direct per-user grants and denies, and "view as this user" for support.
- Eleven pages of Tailwind v4, built to a committed stylesheet so no Node toolchain is needed, under a Content-Security-Policy with no
unsafe-inlineanywhere.
rustlavel-rbac— roles, permissions, wildcard matching, a cached check andCan::permission(...)route guards. Tested against PostgreSQL, MySQL and SQL Server.rustlavel-auth::totp— RFC 6238, verified against the full RFC 4226 and RFC 6238 test tables, with recovery codes.rustlavel-auth::qr— a QR encoder, checked byte for byte against libqrencode across 291 payloads. It exists because posting a TOTP secret to a chart API is not acceptable and a JavaScript QR library would need a looser policy than the kit ships with.rustlavel-auth::impersonation— acting as another user, with the real operator kept for the audit trail.CoseKey::to_bytesand a CBOR encoder, without which a passkey could be verified and never stored.RouteHandle::middleware, for a resource whose verbs need different guards.Request::inputs, for the repeated fields a checkbox group sends.Config::list,Request::body_bytesin tests,sha256_hex.
0.2.0 — 2026-09-02
The minor number rather than the patch, and deliberately: for a 0.x version Cargo treats the minor as the major, so rustlavel = "0.1" would have picked this up on its own. Two changes below need a person to read them first.
Breaking
Request::ip()no longer readsX-Forwarded-Forby itself. An application behind a load balancer must add the newTrustProxiesmiddleware, or it will see the balancer's address for every request. See below for why this is worth the interruption.Errorhas a new variant,Unavailable. Code that matches onErrorexhaustively will not compile until it handles it.
Security
Request::ip()no longer trustsX-Forwarded-Foron its own. It did, unconditionally, which meant any client could choose its own address by sending a header — and the rate limiter and idempotency scoping both key on it, so both were escapable by varying one header per request. The address is now the peer that opened the socket unless the newTrustProxiesmiddleware establishes that the connection came from a proxy named in advance. Applications behind a load balancer must addTrustProxies::from_config(or::at([...])) to keep seeing real client addresses.
Added
TrustProxies, with CIDR matching for IPv4 and IPv6, readingtrustedproxy.proxies. Forwarded hops are stripped from the right, so a value the client wrote before the first proxy appended to it is never taken as the client address.Request::scheme(),is_secure(),forwarded_host(),forwarded_port(), from a trusted proxy'sX-Forwarded-Proto/-Host/-Port.Request::with_peer, so a test can say where a request came from.- A circuit breaker for outbound calls, in
rustlavel-client:Client::new().breaker(CircuitBreaker::new()). It trips on a failure rate over a sliding window rather than a raw count, keeps one breaker per host, and probes with a few calls before resuming. A 5xx counts against the upstream; a 4xx does not. Error::Unavailable, raised when a call was refused rather than attempted, so a caller can safely fall back. It renders as a 503.
0.1.1 — 2026-09-02
The API release: everything an application that only serves JSON needed and did not have, plus five packages that existed in the repository but had never been published.
Added
- CORS (
Cors), configured with the keys of Laravel'sconfig/cors.php; every list accepts a comma-separated string so it can come from.env. - Response compression (
Compress): gzip and deflate, with a DEFLATE codec written in the framework — fixed and dynamic Huffman blocks and a complete inflater, checked against zlib's own output. - ETag and conditional requests (
ETag):If-None-MatchandIf-Modified-Sinceanswered with304. Static files now carryLast-Modified. - API resources (
JsonResource,attributes()): one JSON shape per type, with Laravel'sdata/meta/linksfor pagination. - Request identifiers (
RequestId), on the request, the response, a task-local, and thehttp.requestinstrumentation event. - Idempotency keys (
Idempotency, inrustlavel-cache): a write with anIdempotency-Keyruns once and is replayed thereafter. - Health probes (
Health):/upfor liveness,/up/readyfor readiness with concurrent, time-limited checks. TimeoutandBodyLimitmiddleware, per group.- API versioning:
Router::version,VersionHeader, andDeprecation/Sunsetheaders from.deprecated_at()and.sunset(). #[rustlavel::main]and#[rustlavel::test], withtokiore-exported, so an application's dependency list is one line.Config::list, for values that are an array in JSON and a comma-separated string in.env.SearchClient::from_configandRelyingParty::from_config.rustlavel-clientasks for and transparently decodes gzip and deflate.- First publication of
rustlavel-debugbar,rustlavel-ldap,rustlavel-otel,rustlavel-searchandrustlavel-webauthn.
Fixed
- A warning from the framework's own code no longer appears in an application's build when the
dbandqueuepackages are both off.
0.1.0 — 2026-08-30
First release.