For application developers moving an app between rustango versions. Maintainers cutting a release want RELEASING.md instead.
rustango is 0.x. Every minor bump is allowed to break something, and
several have. What follows is a method that survives that, and then the
per-version notes.
The method came out of a real 0.52.1 → 0.57.6 upgrade of a production app. That upgrade needed one line of application code — not because the range was gentle (it carries six breaking changes) but because none of them touched API that app used. The work was establishing that, and the establishing is the reusable part.
-
Find out whether the target is published.
curl -s https://crates.io/api/v1/crates/rustango/versions \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["versions"][0]["num"])'
If the version is absent it is a git pin, and you need a commit:
git -C …/rustango rev-parse origin/release/vX.Y.Z
Pin a rev, not a branch. A release branch can be force-pushed while its PR is open, and a
branch = …dependency would silently rebuild against different code. Every entry for the package — including[dev-dependencies], which is wheretestkitusually goes — carries the same rev; cargo requires one source per package.When the version is published, both entries go back to
version = "X.Y.Z"and thegit/revkeys come out. -
Check your feature list still exists. A renamed or removed feature fails to resolve with a message that never names the version boundary, so check before you build: pull
crates/rustango/Cargo.tomlat the target ref and diff the[features]keys against the ones you ask for. -
Read CHANGELOG.md across the whole range, not just the target. Do not grep for "breaking" alone — the phrasing that matters is also "you must", "no longer", and "can stop a working app booting".
-
For each breaking change, grep your tree for the API it names, and write the verdict down. "Not used — 0 references" is a finding and deserves recording; next time you will not have to re-derive it.
-
Build, clippy, test — in that order, because the first two are fast. Run the suite with the feature set production runs, not the default one.
--no-default-features --features sqliteand--all-featurescan disagree. -
Boot it. A framework upgrade can compile and still refuse to start — the session-secret rule below does exactly that. Run
migrate, thenrunserver, and read the log rather than only the exit code. Warnings here are the point. -
Look for a generated system migration.
migratewrites one when the framework's own tables change. It is untracked until you commit it, nothing prompts you, and it has to reach production. See below. -
Check the production environment against the new rules before deploying, not after.
Every reader of RUSTANGO_SESSION_SECRET goes through
SessionSecret::from_b64, which requires valid base64 decoding to ≥32
bytes. A passphrase or a hex string used to sign fine and now fails at
startup. Note that a 32-character base64 string is only 24 bytes.
Check the target box without printing the secret:
ssh <host> 'systemctl show <unit> -p Environment' \
| tr ' ' '\n' | grep -o 'RUSTANGO_SESSION_SECRET=.*' | cut -d= -f2- \
| python3 -c 'import base64,sys; v=sys.stdin.read().strip().strip("\"");
raw=base64.b64decode(v, validate=True); print(len(v), "chars ->", len(raw), "bytes")'You want 44 chars -> 32 bytes. If it raises, or the count is under 32,
regenerate with openssl rand -base64 32 before deploying —
rotating the secret signs everyone out, so do that deliberately rather
than discovering it as a boot failure.
SessionSecret::from_bytes enforces the same floor and panics below
it. That only affects apps constructing a secret from a custom source
(Vault, KMS, a secrets manager); apps that only consume
ctx.session_secret are unaffected.
migrate generates and applies a system chain in
system/migrations/ (ledger __rustango_system_migrations__) owning
every rustango_* table. When a release adds framework tables, the
first migrate on the new version emits a new file there.
Two consequences:
- Commit the generated file. Otherwise the repo and the running registry disagree about what the chain is.
- Make sure
system/is in your deploy. A deploy that rsyncstemplates/ migrations/ config/ locales/ assets/does not carrysystem/. Either add it — deterministic, and production applies the same file development did — or letmigrateregenerate it on the box. Regeneration works, but then production writes its own copy of a file the repo also has, and the two agree only for as long as generation stays deterministic.
Your tenant-scoped chain under migrations/ is unaffected by framework
upgrades.
0.57.x warns when a pool is constructed before [database] settings are
applied, because such a pool silently runs on environment defaults:
1 database pool(s) were opened before settings were applied and are running on
environment defaults — move `.with_settings(…)` ahead of any pool construction
If a pool is built early — a translation-overrides pool, a health probe — install the tuning before it:
if let Ok(s) = rustango::config::Settings::load_from_env() {
let _ = rustango::sql::configure_pools(s.database.pool_tuning());
}configure_pools is first-call-wins, so the builder's own later call is
a no-op for tuning and everything else with_settings does is
untouched.
A custom form posting to the bare admin /logout must send _csrf
(or X-CSRF-Token); without it the POST gets 403.
A model with a global_scope served through ViewSet or the template
views now hides the scoped-out rows there too, and a PK request for one
is a 404 (#1746). An endpoint that must reach them should use
Model::objects().without_global_scopes() in its own handler. A
ViewSet PUT/PATCH that moves its row out of a scope or filter backend
answers 204 No Content; a create whose row lands outside answers
201 with no body (null at that index in a bulk create).
Existing SSO users are refused until they are linked. Run
makemigrations + migrate: it adds allow_email_link to
rustango_sso_providers / rustango_shared_sso_providers and creates
rustango_sso_links (until then email linking reads as off and SSO is
refused). Then either turn on allow_email_link (a normal user is linked
on the next login; for a shared provider it applies to every tenant), or
have a superuser add an SsoLink row. Superusers, staff and every
bare-admin account need the row: provider_source tenant/shared/
admin, provider_id the provider row id, issuer kind or
kind|issuer_url without a trailing slash, subject, user_id; the
admin computes key_sha256. The sso refused log
line carries provider_id, issuer and subject. Only superusers can
now add, change or delete SsoProvider and SsoLink rows in the admin.
A read-only operator console can no longer change shared providers.
(Pre-release soak databases built from an earlier 0.58.0 draft have a
subject_sha256 column instead of key_sha256: drop and re-migrate
rustango_sso_links. Released versions never had it.)
find_or_provision_member(pool, email, profile, auto) is now
(pool, &ProviderKey, allow_email_link, profile, auto) and returns
MemberSignIn: map NotLinked (an existing account, not linkable by
email) apart from NoAccount, or every existing member looks "closed".
update() / delete() now honour limit, offset and order_by
(#1666); a queryset that relied on them being ignored now touches fewer
rows. With a composite or missing PK, or after union(), a bounded one
returns QueryError::BoundedDmlUnsupported. The atomic closure now
gets &AtomicTx, not &mut PoolTx: write insert_tx(&mut *tx.lock().await?, &q).
A nested atomic(&pool, …) on the same pool is now a savepoint on the
outer transaction, and its on_commit callbacks wait for the outermost
commit. Drop the TxGuard before nesting, or get ExecError::NestedAtomic.
Nested writes that used to survive an outer rollback (an audit row, say)
are now rolled back with it, silently. For an independent commit, use a
different pool or tokio::spawn. Nesting is per pool object: pass the
request's pool down instead of looking it up again. On MySQL before
8.0.21 and MariaDB before 11.1 a bounded update/delete may scan the whole
table. On MySQL, DDL / TRUNCATE / LOCK TABLES inside atomic commit
implicitly: atomic returns ExecError::AtomicEndedEarly with writes
already committed, so a retry can write twice. On MySQL and SQLite a
failed statement undoes only itself; if the closure ignores it, the rest
commits (PG aborts the whole transaction).
rustango_admin_totp gains a nullable last_used_step column (#1672).
totp_store::ensure_table, or the first code accepted after the upgrade,
adds it to an existing table. AdminTotp literals need the new field. A
code that already signed in is refused, so users wait for the next one.
Lockout::counter_ttl is now a fixed window from the first failure. The
lockout cache keys changed, so failure counts in progress at the
upgrade start again from zero; active locks are kept.
Page cache keys now include the tenant (#1674), so cached pages miss once after upgrade.
Under tenancy, a CachePageLayer that cannot see the tenant context
no longer caches. Mount it on a router passed to the server builder,
or add .tenant_agnostic(true) for routes that are the same for every
tenant. A CDN in front must vary on the tenant header itself.
DatabaseCache keys over 255 bytes, or ending in # plus 64 hex, are
now stored hashed, so those entries miss once. Run cache.clear()
after upgrading to drop the old rows.
Behind trust_proxies, TrustedRealIp and RealIp are now the
rightmost hop that is not a trusted proxy (#1673). List every proxy hop
(CDN egress, load balancer, nginx) in trust_proxies, or the client IP
will be one of your proxies. If your proxy sets X-Real-IP,
CF-Connecting-IP or Forwarded instead of appending to XFF, name
that strategy; Auto behind a trusted proxy reads only XFF.
BodyLimitLayer now also limits bodies without Content-Length; over
the limit a body extractor answers axum's plain 413.
Model::sum/avg/min/max/destroy/delete_where now apply global scopes
(#1675); to act on every row use Model::objects().without_global_scopes().
delete_where type-checks its value like update_where
(QueryError::TypeMismatch). audit::insert_one_with_audit takes
(pool, &query, &mut model, |m| entry) and sets the PK on model. The
hidden Model::__aggregate_one_pool and
sql::model_shortcuts::aggregate_one_pool are removed.
A locked or throttled login answers 429 with Retry-After (#1609);
it used to re-render the form. Lockout counts the submitted username,
not the user id. Behind a reverse proxy set RealIpLayer::trust_proxies,
or every client shares one per-IP bucket (20 a minute). The admin
feature now enables cache. passwords::verify_dummy_async and
tenancy::password::verify_dummy_async return Result; handle Busy
as on the known-user path. New variants: PasswordError::Busy,
TenancyError::Busy, tenancy::auth_backends::AuthError::Refused.
LoginThrottle::begin takes a &LoginScope; LoginThrottle::account
is gone (use LoginScope::TenantBasic); call
LoginAttempt::resolve(stored_username) after the user lookup.
login_throttle::configure_shared, account_lockout::configure_shared
and passwords::configure_hash_wait now win over [auth] values in
either order. login_ip_limit and login_global_limit count failed
logins only; the global limit applies per scope. ModelBackend and
ApiKeyBackend refuse without a TenantSlug.
Bare-admin, tenant, member and operator sessions sign out once after
the upgrade (#1338). TenantSessionPayload::new,
tenancy::session::SessionPayload::new and MemberSessionPayload::new
take a PasswordFingerprint
(PasswordFingerprint::of(&secret, &user.password_hash));
HandoffPayload::new and TenantSessionPayload::impersonation take the
operator's. member_auth::mint_cookie takes &User instead of uid
and password_hash. TestClient::force_login_tenant_user and
force_login_operator take &User / &Operator. SessionPayload is
no longer Copy.
The key format changed (#1668). Add your auth layer after
.idempotency(..) so the caller is resolved first. A keyed request
with a body over body_cap (4 MiB by default) now gets 413, with or
without Content-Length; raise body_cap for large uploads.
template_views POST routes return 403 without a token (#1669): put
{{ csrf_input | safe }} in every form. The feature now enables csrf.
urlize escapes its input, so stop passing it pre-escaped text or &
shows as &amp;.
Receivers on localhost, private or link-local addresses (tests,
intranet) now fail unless the subscription calls
.allow_private_targets(true) (#1670). Delivery ignores
HTTP(S)_PROXY. WebhookEvent gained a public field,
allow_private_targets; a hand-built WebhookEvent { .. } must set it.
admin::inlines::apply_post and apply_post_generic wrote child rows
with no permission check (#1667). The admin's parent update applies
inlines itself; there is no public replacement.
Tenants created before #1645 may have a framework FK into public. This
lists them (your own cross-schema FKs are left out):
SELECT n.nspname, s.relname, c.conname
FROM pg_constraint c
JOIN pg_class s ON s.oid = c.conrelid
JOIN pg_namespace n ON n.oid = s.relnamespace
JOIN pg_class t ON t.oid = c.confrelid
JOIN pg_namespace tn ON tn.oid = t.relnamespace
WHERE c.contype = 'f' AND n.nspname <> 'public' AND tn.nspname = 'public'
AND s.relname IN ('rustango_api_keys', 'rustango_role_permissions',
'rustango_user_roles', 'rustango_user_permissions');Drop each one (ALTER TABLE "<schema>"."<table>" DROP CONSTRAINT "<name>").
For the three permission tables, manage seed-permissions --slug <slug>
then re-creates them inside the tenant. For rustango_api_keys, re-add it
by hand:
ALTER TABLE "<schema>"."rustango_api_keys"
ADD CONSTRAINT "rustango_api_keys_user_id_fkey" FOREIGN KEY ("user_id")
REFERENCES "<schema>"."rustango_users" ("id") ON DELETE CASCADE;Every POST to the tenant admin (#1713) and the operator console (#1710)
now needs the rustango_csrf cookie echoed as _csrf (form) or
X-CSRF-Token (header), and a same-host Origin. Browsers get this
from the rendered forms. A script or test that posts directly gets
403: GET a page first to receive the cookie, then send it back.
Under Cli::tenancy() they now wrap every route (#1700). A load
balancer that probes /health by IP needs that host in
allowed_hosts, and /health in secure_redirect_exempt if it
probes over plain HTTP. The single-tenant server already worked so.
The admin and tenant logins now require Origin to match Host
(#1695); csrf_trusted_origins does not apply. Forward the original
Host from the proxy. A custom handler calling verify_form_token
gets the same check.
Was no-referrer, which makes browsers send Origin: null on every
POST, so forms were refused (#1695). If you need no-referrer, set it
per response on pages without forms.
Its arguments ran the other way round from the one that stays (#1663):
// before: shortcuts::redirect_to_login(next, "/login")
rustango::auth_decorators::redirect_to_login("/login", "next", next)Filter, Assignment, SelectQuery, InsertQuery, BulkInsertQuery,
UpdateQuery, BulkUpdateQuery, DeleteQuery, CountQuery and
AggregateQuery (#1661). A struct literal outside the crate, including
..SelectQuery::new(m), now fails with E0639; use X::new(..):
let f = Filter::new("status", Op::Eq, "draft");
let q = InsertQuery::new(Post::SCHEMA, cols, vals).returning(vec!["id"]);
let mut s = SelectQuery::new(Post::SCHEMA).where_clause(f.into());
s.limit = Some(10);A destructuring pattern needs .. (let Filter { column, .. } = f), or
it fails with E0638. Reading and assigning fields still works. Code
that only uses the QuerySet API or #[derive(Model)] is unaffected.
OrderClause, Join and the other clause structs are not changed yet.
FieldSchema, ModelSchema, AdminConfig, IndexSchema,
GlobalScope, Fieldset, PrepopulatedField, the relation and
constraint structs, and Relation::Fk / Relation::O2O (#1661). A hand-built schema now fails with E0639. Start from new
(or AdminConfig::DEFAULT) and assign fields:
const ID: FieldSchema = {
let mut f = FieldSchema::new("id", "id", FieldType::I64);
f.primary_key = true;
f
};
let rel = Relation::fk("user", "id");A pattern on a variant needs .. (Relation::Fk { to, on, .. }), or
it fails with E0638.
SqlError and ExecError are also #[non_exhaustive]; add a _ =>
arm. #[derive(Model)] users are unaffected.
Any migration file with a {"callback": …} op now needs
"atomic": false, including files already applied (#1626). Add it;
the ledger stores only names, so editing an applied file is safe.
Embedded migrations need a rebuild.
atomic: false means a failed callback does not roll back the schema
op before it. To keep that, split the file: the schema op in one
migration, the callback alone in the next.
SqlValue, FieldType, Op, WhereExpr, Expr, Relation,
OnDeleteAction, QueryError and the other enums in rustango::core
(#1661). A match without a _ => arm now fails with
error[E0004]: non-exhaustive patterns; add the arm. Weight and
NullsOrder stay exhaustive.
Only affects clients that parse error bodies (#1193). The shape is
{"error": "not_found", "message": "not found", "status": 404}details appears only when there is something in it.
| Was | Now |
|---|---|
ViewSet {"error": "<sentence>"} |
sentence in message; error is a code |
Serializer 400 {"title": [...]} |
422, details.title, error: "validation_failed" |
Admin {"error": "form", "detail": …} |
400 bad_request, reason in message |
Tenant / Principal rejections, plain text |
JSON, same shape |
auth_routes / require_bearer handler errors, plain text |
JSON, same shape (#1684); axum's own body-parse rejections are unchanged |
limit_bytes, retry_after, admin table / pk |
under details |
| 5xx carrying the driver message | generic message unless RUSTANGO_DISCLOSE_ERRORS; cause logged at rustango::error |
ViewSet create/update constraint 400 with driver text |
400, generic message |
A MaintenanceLayer with a custom .body(…) is unchanged.
The JWT config no longer lives in a process global (#1190).
jwt_router(cfg) becomes JwtAuth::new(cfg).router():
let auth = JwtAuth::new(Config::default());
api.layer(middleware::from_fn_with_state(auth.clone(), require_bearer))
.merge(auth.router())auth_routes::verify_for_tenant(token, slug) is now
auth.verify_for_tenant(token, slug). Use the same JwtAuth for the
router and the middleware, or a logout will not revoke for the middleware.
Only affects code that matches exhaustively on it. Add a _ => arm:
match err {
MigrateError::Driver(e) => …,
MigrateError::Io(e) => …,
_ => …, // <- add this
}Anything that only propagates the error, or formats it with {} / ?,
is unaffected — which is most code.
The marker went on together with a new variant, PartiallyApplied, so
that this is one break rather than two: every future variant is now
additive. See #1513, which wants the same treatment across the other
public error enums.
PartiallyApplied is raised when a migration fails on MySQL after
committing DDL. MySQL commits DDL immediately, so the transaction around
an atomic: true migration cannot undo it — the schema moves and the
ledger row is never written, and re-running then fails differently
because the work is already done. The error now names how much committed
and the way out (manage migrate --fake <name> once the schema
matches), which was previously folklore. A failure with no committed DDL
still surfaces as Driver, unchanged, because that one rolled back
cleanly and should simply be re-run.
Published 2026-09-18.
The security pass. One change can break a working deployment, and it does so at runtime rather than at build time — read the first row even if you skip the rest.
| Change | How to check |
|---|---|
media_router is deprecated and now refuses every request with 403. Build the router with media_router_with(manager, authorizer) and supply a MediaAuthorizer. |
Grep for media_router(. You get a deprecation warning, not an error — cargo build still succeeds, so a noisy build carries this to production, where the symptom is every media route answering 403. This is deliberate: the constructor used to mount 16 routes that took no authentication, authorization or tenant extractor at all, so the alternative was leaving an open bucket open. |
The shortest fix, with the tenancy feature on, is the shipped
policy — reach for it before writing a trait impl:
use rustango::media::router::{media_router_with, MediaPerms};
let app = axum::Router::new()
.nest("/media", media_router_with(manager, MediaPerms::new(pool)));MediaPerms checks the {table}.{action} permission codenames the
admin already uses: rustango_media.view to read a row or a listing,
rustango_media.add for an upload ticket,
rustango_media_collections.add / rustango_media_tags.add for the two
creates, rustango_media.change / .delete for the media row. Deleting
a collection needs both rustango_media_collections.delete and
rustango_media.change, because that route re-parents every media row
underneath it. Superusers skip the check.
Mount it inside require_auth — that middleware is what injects the
AuthenticatedUser it reads. Without it every request is a 401, which
is the symptom naming its own cause.
Not optional_auth: it compiles and then 401s anyway, because
MediaPerms has no anonymous path. If what you wanted was a public
page showing an uploaded image, this router is the wrong tool
entirely — render manager.public_url(id).await? from your own route.
docs/files.md has both delivery models.
Reading a collection's contents needs
rustango_media_collections.view and rustango_media.view, because
that route answers media rows with a presigned download URL each — it
is a media read that happens to be addressed by collection id.
Three things to know before you rely on it.
The codenames are seeded by auto_create_permissions, which runs during
provisioning and on migrate; an app upgrading into this can re-seed
without a migrate cycle with the seed-permissions manage command.
It is table-level, not row-level: rustango_media.view grants
reading any media row by id, so a multi-tenant deployment still scopes
rows in its own MediaAuthorizer. MediaPerms is the floor.
And every registered disk is writable until you say otherwise.
disk is caller-supplied on POST /uploads/begin and the
StorageRegistry is process-wide, so pool-per-tenant isolates the
database and not the object store — a bare rustango_media.add grant
mints a presigned PUT into any bucket the process knows about. A
codename cannot express "this disk", so the allow-list is a builder:
MediaPerms::new(pool).allow_disks(["user-uploads"])Set it on any deployment with more than one disk. Prefixes within a
disk are still not expressible — for "your own prefix on a shared
bucket", implement MediaAuthorizer, which is handed key_prefix.
Write the trait impl when you need per-row decisions:
// before (0.57.6) — served anyone who could reach it
let app = axum::Router::new()
.nest("/media", media_router(manager));
// after (0.57.7)
use rustango::media::router::{
media_router_with, MediaAction, MediaAuthorizer, MediaDecision, MediaTarget,
};
struct MyPolicy;
#[rustango::media::async_trait]
impl MediaAuthorizer for MyPolicy {
async fn authorize(
&self,
parts: &axum::http::request::Parts,
action: MediaAction,
) -> MediaDecision {
// No identity at all is 401, not 403.
let Some(user) = current_user(parts) else {
return MediaDecision::Unauthenticated;
};
let allowed = match action {
MediaAction::Read(MediaTarget::Media(id)) => user.may_read_media(id).await,
// Listings enumerate the whole library; NewUpload mints a
// presigned PUT for a caller-chosen disk and key prefix.
// Both are explicit decisions, not defaults.
MediaAction::Read(MediaTarget::Listing) => user.may_browse_library(),
MediaAction::Add(MediaTarget::NewUpload { disk, key_prefix, .. }) => {
user.is_trusted_uploader()
&& disk == "user-uploads"
&& key_prefix.starts_with(&user.prefix())
}
MediaAction::Add(MediaTarget::NewCollection { .. }) => user.is_editor(),
MediaAction::Add(MediaTarget::NewTag { .. }) => user.is_editor(),
_ => false,
};
allowed.into()
}
}
let app = axum::Router::new()
.nest("/media", media_router_with(manager, MyPolicy));End on _ => false. MediaAction and MediaTarget are
#[non_exhaustive], so a route added later reaches your policy as a
variant you have not written an arm for — and should arrive denied.
authorize returns MediaDecision, not bool. false.into() is
Forbidden, which is exactly the old behaviour, so a policy that
already computes a boolean only needs .into(). Return
MediaDecision::Unauthenticated where there is no principal at all
— the client is then told 401 so a token client refreshes rather than
treating the refusal as final. A signed-in user who lacks the permission
stays Forbidden.
NewUpload carries what the caller asked for — disk,
key_prefix, collection_id, uploaded_by_id — read out of the
request body before the handler runs, so the grant can be "this disk,
under your own prefix" rather than "anywhere in any bucket, attributed
to anyone". They are unvalidated caller input, not facts; a body that
does not parse arrives as empty strings and None rather than a 400,
because authorization is decided before validation is. Match it with a
trailing .. (MediaTarget::NewUpload { disk, .. }) — the variant
stays #[non_exhaustive] so more of the body can be surfaced later
without breaking your policy.
The gate buffers that one body, capped at 16 KiB; nothing legitimate sends an upload-ticket JSON larger than that, and a request that does is refused. No other route's body is read.
NewCollection and NewTag are empty struct variants of the same
shape, for POST /collections and POST /tags. Those two used to
arrive as the same Add(Listing), so "may label things" also granted
"may create folders" — and collections nest, so it granted a foothold
under someone else's tree. Listing now means a read.
DELETE /collections/{id} arrives as
Delete(MediaTarget::CollectionSubtree(id)), not
Delete(MediaTarget::Collection(id)) — so an arm written for the
latter does not grant it, and the route answers 403 until you add the
subtree arm. That is the intended reading: the route soft-deletes every
descendant collection and orphans the media at every level, and the
nesting is not the deleting caller's to control, since POST /collections takes parent_id in the body. Grant it where a caller
owning the root may take the whole tree, and keep it on _ => false
where they may not.
The router needs the admin feature as well as media, and
media_router is removed in 0.59.0 — the deprecation is not
open-ended.
DELETE /collections/{id}now takes the whole subtree. In 0.57.6 it orphaned the media in that one collection and soft-deleted that one row; children were left pointing at a deleted parent, which madecollection_pathon the subtree a permanent error. Fixing that made the route recursive. If anything in your app deletes a collection that has children, its blast radius changed — check that before upgrading, not after.GET /collections/{id}/contentscaps at 100 rows. It was unbounded.?limit=is clamped to1..=1000, so a client that used to receive a whole large collection in one response now receives a page, with nothing in the body saying there is more. Page with?limit=and?offset=.popular_tagsno longer counts soft-deleted media.GET /tags/popularandGET /tagsboth serve it, so their numbers drop on upgrade. The new numbers are the correct ones — the old ones contradictedGET /tags/{slug}/media— but a dashboard tracking them will show a step change.
url_codec::percent_decode_pathis new:%XXonly,+left literal, for comparing a path segment against what a router decoded.url_decodekeeps form semantics (+→ space) and is unchanged for that use.- Both decoders stopped treating a signed hex pair as an escape.
%+5used to decode to byte0x05, becauseu8::from_str_radixaccepts a leading sign. Only affects malformed input. MediaManager::purgenow fails when the storage object cannot be deleted, instead of deleting the row and returningOk(()). If your scheduledpurge_orphansstarts returning an error, it is reporting a storage failure it was previously hiding — check thewarnlines, which name the disk and key. The rows it could not purge stay soft-deleted and are retried on the next sweep; the rest of the sweep still runs. A disk missing from theStorageRegistryis nowMediaError::UnknownDiskrather than a silent skip.rustango::storage::async_traitis re-exported, so implementing the publicStoragetrait no longer needsasync-traitin your ownCargo.toml. Themedia::async_traitre-export is unchanged; it sits behind theadminfeature, which a crate implementing onlyStoragemay not have on.
Every #[rustango(fk = "…", on_delete = "…")] was being discarded when
a schema snapshot was built, and system migrations render from
snapshots, so the clause reached no database at all. A declared
cascade arrived as NO ACTION, which does not merely fail to cascade
— it makes the parent delete a hard refusal (ERROR 1451 on MySQL).
What you need to know about upgrading:
| A new database, migrated from nothing | gets the correct ON DELETE. Nothing to do. |
| An existing database | keeps the constraints it already has. migrate reports nothing to migrate and writes no file — correctly, because a changed on_delete is not a schema operation this release can emit. |
So migrate exiting 0 after the upgrade does not mean your
constraints were corrected. If you rely on a declared cascade — and
you may not have noticed you did, because it has never worked — the
constraint has to be rewritten by hand:
-- PostgreSQL / MySQL. Check first:
-- PG: SELECT conname, confdeltype FROM pg_constraint WHERE contype='f';
-- 'a' = NO ACTION, 'c' = CASCADE, 'n' = SET NULL
-- MySQL: SELECT constraint_name, delete_rule
-- FROM information_schema.referential_constraints;
ALTER TABLE child DROP CONSTRAINT child_parent_id_fkey;
ALTER TABLE child ADD CONSTRAINT child_parent_id_fkey
FOREIGN KEY (parent_id) REFERENCES parent (id) ON DELETE CASCADE;SQLite has no ALTER TABLE … DROP CONSTRAINT, so correcting one there
means rebuilding the table.
This affects apps that never touched media: eleven framework foreign
keys declare cascade, ten of them in tenancy (roles, permissions,
agent skills), and fold_in_framework_tables puts them in every
project's snapshot.
Because the clause is finally emitted, a model declaring
on_delete = "set_null" on a non-nullable column now produces DDL
MySQL rejects:
ERROR 1830 (HY000): Column 'x' cannot be NOT NULL: needed in a foreign key constraint 'y' SET NULL
Make the column Option<…>, or change the action. PostgreSQL and SQLite
accept the DDL and fail at delete time instead, which is worse — so this
is the loud one.
Published.
rustango = "0.57.6"resolves.
| Change | How to check |
|---|---|
The access log's field names are now OpenTelemetry conventions. method → http.request.method, path → url.path, status → http.response.status_code, ip → client.address. url.path is the path alone; the query moved to url.query. |
Grep your dashboards, alerts and collector mappings — not your code. This breaks log consumers, and nothing in your build will tell you. |
duration_ms is f64 microseconds, not u64 milliseconds. |
Elasticsearch/OpenSearch rejects a document whose field type conflicts with an established mapping. Update the mapping before the first line lands. |
LoggingSettings, Format and Color are #[non_exhaustive]. |
Struct literals stop compiling — and so does ..Default::default(), which #[non_exhaustive] also forbids across crates. Build the default and assign: let mut s = LoggingSettings::default(); s.level = …; |
Dialect gained drop_check_constraint_sql / drop_foreign_key_sql. |
Only affects a downstream impl Dialect. Defaults unimplemented! rather than falling through to PostgreSQL's form, so you get a loud panic naming the method rather than invalid SQL. |
RUSTANGO_SESSION_SECRET must be base64 ≥32 bytes (#1396). |
See above. This one can stop a booting app. |
| Admin mutations require a CSRF token. | Only if you mount the rustango admin and have a Method::POST admin view or custom admin template. |
JwtBackend needs with_jti_store for logout revocation. |
Only if you use jwt_router. Session-signed API auth is unaffected. |
allow_any_origin() + credentials no longer reflects the origin. |
Only if you build CORS through that helper. |
db_dump_cmd no longer takes a writer. |
Rare; grep for it. |
- The request span and
X-Request-Idnow mount by default on every serving path. Expect a new response header and span context on log lines.[logging] access_log = falseturns off the log line only — the span and request id stay, because a service logging at the edge still wants trace context. - Query-string credentials are redacted from both the access-log
event and the span, using your configured
[audit] redact_query_paramsplus defaults that now include the OAuth2/OIDC names (code,client_secret,id_token,code_verifier,state). confirm_password_reset_poolstampspassword_changed_atand applies the real password policy. Only matters if you call it.- Migration DDL for
RENAME TO/RENAME COLUMN/DROP CONSTRAINTis centralised on the dialect.quote_ident's default is unchanged, so PostgreSQL and SQLite output is byte-identical; MySQL is fixed (it previously received"-quoted identifiers and answeredERROR 1064).
testkit::matrix — tri_dialect_test!, fresh_table::<M>,
Backend::pool(), by_dialect!. It costs one line
(rustango = { features = ["testkit"] } as a dev-dependency) and
replaces hand-rolled per-dialect harnesses.
It is worth the change specifically if your suite has a test that gates
on a bespoke MY_APP_TEST_PG_URL-style variable and prints skip: when
it is unset. That shape reports ok while testing nothing.
Backend::pool() owns the policy in one place: unset skips,
set-but-unreachable panics, set-to-the-wrong-engine panics.
The table in the version section is the valuable artefact, and it is cheap to produce while you are already looking. Write the verdict for every breaking change, including the ones that did not apply — "not used, 0 references" is a finding. The next person upgrading past that version starts from your table instead of re-deriving it.