Preparing an application for v13
The current source is 13.0.0-alpha.4, a prerelease, not a stable v13
release. The first prerelease, 13.0.0-alpha.1, was published on October 11, 2026.
The 12.x line, on the frozen v12 branch, reached end of life on 16 October
2026 with 12.3.0 as its last release; main develops v13. The development packages use the same v13
version and internal requirements. rullst-privacy, rullst-supervision,
rullst-media and rullst-labs join the candidate package inventory and were
first published in 13.0.0-alpha.1. Privacy
has explicit privacy-* umbrella features; Supervision, Media and Labs remain
standalone opt-ins.
All twenty packages are published together as each v13 prerelease; the current
one is 13.0.0-alpha.4. Cargo never selects
a prerelease on its own, so cargo add rullst still selects the end-of-life
12.3.0: opt in with
an exact requirement such as rullst = "=13.0.0-alpha.4". A plain
"13.0.0-alpha.4" is a caret requirement that also accepts later alphas, which
may break the API. From 13.0.0-alpha.2 the CLI writes the = form; a project
generated by the 13.0.0-alpha.1 CLI has the caret form, so add the = to its
rullst and rullst-orm lines. Since 13.0.0-alpha.3 rullst and
rullst-orm have no default features and compile no SQLx driver on their own,
so a hand-written manifest selects the database profile it uses, as
cargo rullst new does (see the Minimal default features row):
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["strict-postgres"] }
This inventory covers the 13.0.0-alpha.4 development source. Revisit it as the remaining
delivery priorities land. It does not promise that every
existing application, provider account or deployment works without review.
To find the rows that affect an application, run cargo rullst upgrade --dry-run
with the v13 CLI: its rullst-upgrade-rules-v4 catalog reports MUST-CHANGE
and REVIEW findings at file:line, each naming the first-column title of its
row below, with the guidance to apply by hand. The
assisted upgrade tutorial
describes the workflow and classifies every row.
Rows without rules still need a reading. An application already on a 13.0
prerelease gets only the rules introduced after its version, and the plan says
how many earlier rules it skipped; --all-rules applies every rule
(rules by release).
An application that already runs 13.0.0-alpha.3 follows the shorter
alpha.3 to alpha.4 checklist;
one on 13.0.0-alpha.2 follows the
alpha.2 to alpha.3 checklist
first.
Changes from the published 12.1.0 source
| Area | Application impact |
|---|---|
| Existing runtime APIs | Source preparation changes supported dependency requirements; it invents no Rust rewrites. Review the explicit account-session schema migration below even when existing Rust calls still compile. |
| Account/session registry | Before serving requests with the new Auth recovery store, explicitly run SqlRecoveryStore::migrate() to add session details and the expiry index. Existing opaque sessions remain valid with unknown historical metadata. Configure expired-session maintenance and authorize new inventory/logout routes; see session management. The CLI manifest updater does not apply this SQL migration. |
| Private object storage | The optional storage-s3 candidate requires explicit signing credentials and production validation. Existing cloud constructors stay unconfigured; no bucket, IAM policy or credentials are inferred. See private storage. |
| R2 public URLs | Storage::r2(...).url(key) (and TenantStorage::url over R2) now returns StorageError::Unsupported instead of https://<account>.r2.cloudflarestorage.com/<bucket>/<key>. That S3 API endpoint requires a signature for every request, so the URL never loaded in a browser. Build public URLs from your r2.dev or custom domain in the application, or use a signed download with storage-s3. |
| Tenant local storage | TenantStorage reads, metadata and deletes on local storage return StorageError::Io when the tenant directory lies below a regular file (for example a file named tenants in the base path), on every platform. On Windows, 12.x and 13.0.0-alpha.1 returned NotFound there, as if the object were missing. |
| Recoverable Live UI | Existing LiveComponent and make:live output keep their original protocol. Opt in to live::recovery with explicit authenticated scope, periodic authorization, persistent revisions and atomic domain updates; mount its separate browser module. Full snapshots replace child DOM and never replay uncertain actions. See Live recovery. |
| Distributed tracing | Opt in to telemetry::distributed with approved operation names, upstream trust, a collector and owned flush/shutdown. The legacy initializer now uses compatible threaded OTLP/HTTP export and defaults to 127.0.0.1:4318/v1/traces; OTEL_EXPORTER_OTLP_ENDPOINT is a base URL, while OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is exact. Redirects and request bodies over 1 MiB are unsupported; the legacy initializer exports at most 64 spans per request (unless OTEL_BSP_MAX_EXPORT_BATCH_SIZE is set), honours HTTP_PROXY/HTTPS_PROXY/NO_PROXY and reports dropped batches on stderr, while the minimized profile ignores proxy variables. Review existing collector environment configuration; the tracing guide distinguishes both metadata policies. |
| Core queue | SQLite claims jobs in due-time order, so a due scheduled or handed-back job no longer waits behind later immediate jobs, and retry_failed_job keeps the attempt counter (it fences claims) instead of resetting it to 0. A job no worker has a handler for is failed with HandlerNotFound at its 720th claim attempt instead of being handed back forever. dispatch/dispatch_at reject empty names and names over 256 bytes, and Worker::run rejects job_timeout(Duration::ZERO), all with QueueError::InvalidConfiguration. Redis queue commands wait up to 10 s for a reply instead of 500 ms. Stalled recovery of a claim made without a lease (SQLite pop) may now happen up to two seconds after stalled_after, never before. Failed jobs that a 12.x process records in a Redis queue, including 12.x workers during a rolling upgrade, are not in the new failed-job index: they are neither counted nor evicted by the 10,000-entry retention, so retry them with retry_failed_job or remove them with purge_failed_jobs once every worker runs 13. |
| Core scheduler, cache and realtime | Scheduler::start returns the new SchedulerError::InvalidConfiguration for a zero task timeout; exhaustive matches in other crates are unaffected because the enum is #[non_exhaustive]. The Redis cache treats a zero TTL as an already-expired value (the key is removed) and a TTL beyond Redis’s expiry range as no expiry, as the memory driver does. PresenceTracker counts connections: call user_left once per user_joined, since a user stays online until the last leave. Channel::new clamps the capacity to 65,536. The Traffic Shield database probe gives up at max_db_latency and records at least that latency. A Live recovery session no longer counts its own callback and send time as peer silence. TenantCache now writes % as %25 and : as %3A in the tenant segment of its keys (tenants:a%3Ab:<key>), so entries of tenant IDs containing those characters start cold, and while 12.x and 13 instances share the cache a forget on one version does not remove the other’s entry for such a tenant; rely on TTLs or finish the rollout before depending on invalidation. |
| Core memory cache sweeps | Cache::memory() sweeps expired entries after a number of operations equal to a quarter of the entries the previous sweep kept (at least 256), instead of every 256 operations, so the sweep cost per request no longer grows with the cache. Reads still never return an expired value; unread expired entries may stay in memory longer in a large cache. |
| Studio queue monitor | Studio lists queue records with the new QueueDriver::list_job_previews (at most 2 KiB of each payload and error) instead of list_all_jobs. Custom drivers keep working through the default projection of list_all_jobs; override list_job_previews to cut values in the store. |
| Studio API playground | Studio::with_openapi now takes a utoipa 6 OpenApi. Applications that build their document with utoipa 5 must upgrade to utoipa 6 (and utoipa-axum 0.3 when they use its router helpers). Studio no longer embeds Swagger UI (its utoipa-swagger-ui build dependency needs a zip release that crates.io has yanked): /studio/api is now a server-rendered, read-only reference of the operations, /studio/api/ redirects to it and /studio/api/openapi.json still serves the document. There is no in-page “Try it out”; send requests with an external client. |
| Studio local access and table view | LocalStudioAccess::loopback_only() now accepts an IPv4-mapped loopback peer (::ffff:127.0.0.1, reported by a dual-stack HOST=:: listener) instead of returning 403; other mapped addresses stay rejected. The table view, and the public get_any_value_as_string, now report a present value that no text, integer, float or Boolean codec decodes (for example a non-UTF-8 BLOB) as unreadable instead of NULL, and a stored text NULL is no longer styled as SQL NULL. |
| LMS blueprint | --blueprint lms now generates a small starter: catalog, modules, lessons, an accessible player, enrollment, owner-bound progress, login and a Nexus admin. The complete Academy scaffold (schools, roles, quizzes, activities, scores, automation, notifications, publication, privacy lifecycle) and --lms-modules were removed; existing generated applications keep their code. Registration no longer generates the school-membership hook, and the learner dashboard no longer claims a default demo school: it points to the course catalog. Since 13.0.0-alpha.4 this starter is the examples/lms application instead (see “CLI starters moved to the examples”). |
| Consumer generators | make:supervision was removed with the complete Academy LMS it targeted; integrate rullst-supervision directly. make:privacy and make:age-gate now target only the SaaS starter (--blueprint saas, which requires --tenant-ref). |
html! event handlers | A dynamic value in an on*, hx-on or hx-on-* attribute (for example onclick={handler}) no longer compiles, because escaping cannot make data safe inside JavaScript. Static handler strings still compile; move data-driven handlers into a nonce’d script with addEventListener and pass values through data-* attributes. The default CSP already blocked such inline handlers. |
html! returns Markup | Since 13.0.0-alpha.2, html! returns rullst::html::Markup (escaped HTML) instead of String. Interpolating a Markup inserts it as HTML, so a fragment built by another html! call is no longer shown as escaped text and needs no RawHtml; String, &str and numbers are still escaped. A function declared -> String that returns html! no longer compiles: declare it -> Markup. RawHtml(html! { .. }) no longer compiles: interpolate the Markup directly. Collect lists with .collect::<Markup>() (collecting Markup into a String does not compile) and use Option<Markup> for optional parts. render_page/render_page_with_lang take impl Into<Markup> (wrap a String of trusted HTML with Markup::from_trusted), and Live::mount and native #[island] functions return Markup. A handler can return Markup (text/html; charset=utf-8), Html(html! { .. }) still compiles, Markup compares with and reads like a &str, and .into_string() returns the String. See html! returns Markup. |
| Core request validation | ValidatedForm/ValidatedJson return 413 for oversized bodies and 415 for a wrong content type (HTMX requests receive the same code in X-Rullst-Validation-Status); other read failures stay 400. Nested errors are keyed address.zip and items[0].name, and ValidationError formatting no longer includes submitted values. Update clients or tests that expected 400 for these cases. |
| Core feature flags and scheduler | A user outside a declared A/B split now resolves to "disabled" instead of falling through to the next driver, and a negative database rollout_percentage means 0%. Scheduler::task uses POSIX weekday numbering (0 and 7 are Sunday) with the day-of-month OR day-of-week rule in UTC; review numeric weekdays in existing schedules. RullstConfig::environment() now reports the environment the Server enforces, including a value read from .env. Percentage rollouts and A/B variants of the Env, TOML, Memory and DB drivers use a versioned SHA-256 bucket hash instead of std’s DefaultHasher, so upgrading reassigns users to buckets once (percentages and variant weights are kept). End experiments whose assignment must not change before upgrading, and keep the period in which 12.x and 13 replicas both serve short, because they assign the same user differently. |
| Memory feature-flag splits | A MemoryFeatureDriver::override_variants flag (including FeatureManager::overrides()) no longer reports enabled/enabled_for as true for everyone. Like the Env, TOML and DB drivers, enabled_for is true only for identifiers assigned a variant named "enabled"; like the Env and TOML drivers, enabled is false. Gate A/B code on variant. |
| Database feature-flag splits | A rullst_feature_flags row whose variants column holds an A/B split now makes DbFeatureDriver::enabled (and therefore feature::enabled when the DB driver answers) false, as in the Env, TOML and Memory drivers. It was true when rollout_percentage was empty. enabled_for and variant are unchanged; gate A/B code on variant. |
| Security headers | Core and both Security header layers preserve an endpoint’s exact Referrer-Policy: no-referrer, including when it occurs among duplicate values. Other endpoint values still yield to SecureHeadersLayer and CspSecurityLayer; the Core baseline now keeps them (see Core security header precedence). Review handlers that intentionally set this more restrictive policy. |
| Core WAF signatures | waf_middleware, which Server mounts in staging and production, now refuses injection structure instead of SQL and shell keywords. Ordinary text such as “Please select an option”, “Delete my account” or “curl the API with your token” in a query, Referer, cookie or body is no longer rejected with 403. A quote followed by or/and and a comparison or by a SQL comment, a ; followed by a SQL statement, union [all] select, SQL probe functions (sleep(, waitfor delay, information_schema…) and a shell metacharacter (;, |, &&, a backtick, $() followed by a command name are still refused, also when /* */ comments separate keywords; XSS and traversal signatures are unchanged. JSON bodies are inspected key by key and string by string after decoding, so a \u003c escape no longer hides <script and JSON quotes no longer join a signature. The WAF remains a coarse baseline: keep parameterized SQL and shell-free process APIs. |
| Core security header precedence | headers_middleware (the staging and production baseline in Server) now adds each security header only when the response has none, so an explicit value from a handler or an inner layer (Content-Security-Policy, X-Frame-Options, HSTS, Referrer-Policy, Permissions-Policy, COOP, CORP, COEP, X-XSS-Protection) wins over the baseline instead of being replaced. When rullst_security::SecureHeadersLayer ran inside it, which now marks its responses with the new rullst::security::SecurityHeadersApplied extension, the baseline leaves all of those headers alone, so the headers its SecureHeadersConfig omits (None) stay absent. Cache-Control: no-store for responses without a cache policy and the shared CspNonce are unchanged. Review handlers and layers that set a weaker value (for example Referrer-Policy: unsafe-url or a CSP with 'unsafe-inline') and relied on the baseline to replace it. |
| AI provider streaming and stop reasons | AnthropicProvider and GeminiProvider now implement StreamingAiProvider, and their capabilities() report streaming: true and explicit_cancellation: true; code that chose a transport from those flags now streams them. Gemini’s non-streaming chat/prompt now fail with AiError::ApiError when the candidate’s finishReason is MAX_TOKENS or a safety-class reason (SAFETY, RECITATION, LANGUAGE, BLOCKLIST, PROHIBITED_CONTENT, SPII, IMAGE_SAFETY) instead of returning the partial text, matching the Anthropic and OpenAI behaviour; raise the output limit or handle the error. The OpenAI-compatible stream (OpenAiCompatibleProvider with with_streaming(), used for OpenAI, DeepSeek, local servers and loopback Ollama) likewise fails with AiError::ApiError when a chunk reports finish_reason length or content_filter instead of completing with the text received so far. |
| AI image-beacon guardrail | The data_exfiltration check inspects every Markdown image even when the text contains no literal http:// or https://: scheme-relative (//host), slash-less (http:host), backslash and reference-style beacons (with the definition anywhere in the text), unterminated destinations and labels containing a code span, raw HTML or an autolink that could hide a ] (or a < followed by a bracket before the next >) are blocked; bracket-free constructs such as Vec::<u8> or Box<dyn Fn()> inside vec![...] no longer make a label ambiguous. It decodes CommonMark backslash escapes and entity or numeric character references in destinations, so  is blocked like the literal URL; an unrecognized named reference (for example é) makes that destination untrusted. Images and definitions inside code spans and closed fenced or indented code blocks are ignored, as CommonMark never renders them (code whose extent depends on an ambiguous layout is still read); lines end at LF, CRLF or a bare CR and only spaces and tabs make a line blank, so a bare CR or a no-break-space line can no longer turn a rendered image into apparent indented code. A reference image whose ASCII label matches no definition, such as vec![x] or routes![...] in Rust code, renders as literal text and is not blocked even next to a remote URL; a non-ASCII label, a label with a backslash-escaped bracket (read with its escapes, as [a\]b]) or an unterminated label is still blocked when the text names a remote URL, and a reference image whose definitions are all local is not blocked by an unrelated link. An inline image whose destination and title are not certainly valid (such as ) is also judged by the shortcut reference CommonMark falls back to, matched with Unicode case folding. |
| AI RAG tenant tags | RagPipeline::answer checks the tenant tag of every document the retriever returns, including those after the context budget is exhausted, and fails with RagError::InvalidDocument (audited as ContextRejected, no provider call) instead of answering when any of them belongs to another tenant. |
| Anthropic provider output | AnthropicProvider requests up to 16,000 output tokens instead of a fixed 1,024, and a reply whose stop_reason is max_tokens, model_context_window_exceeded or refusal now fails with AiError::ApiError instead of returning the partial text as a complete answer (so StatefulChat does not store it and RagPipeline audits GenerationFailed). Choose another limit with the new with_max_tokens; a longer reply can take longer than the 30-second default request deadline, so raise it with with_request_timeout. |
| OpenAI provider output | OpenAiProvider vision requests no longer send max_tokens: 1024 (deprecated by OpenAI and rejected by reasoning models); like text requests they leave the output limit to the model. A chat, vision, JSON or structured-output reply whose finish_reason is length or content_filter now fails with AiError::ApiError instead of returning the partial text. DeepSeek and OpenAiCompatibleProvider are unchanged. |
| Ollama host | OllamaProvider::new and AiClient::auto() read OLLAMA_HOST the way Ollama does: a scheme-less value such as 127.0.0.1:11434, localhost or [::1] now means http with port 11434 unless a valid port is named (ollama.com alone means https://ollama.com:443), instead of producing a URL every request rejected. Values with http:// or https:// are used as before. |
| AI chat memory keys on MySQL/MariaDB | SqlChatMemory tenant and conversation IDs compare case-sensitively on every backend; new MySQL/MariaDB tables use ascii_bin key columns, and prepare_schema never alters an existing table. On a table created by 12.x, an ID that differs from a stored one only by letter case fails closed (ChatMemoryError::InvalidConfiguration, ConversationNotFound or RevisionConflict). Stop chat-memory writers, back up both tables, review conversations that 12.x may have merged and apply the migration in the AI README. PostgreSQL and SQLite tables are unchanged. |
| Core CSRF on HEAD | csrf_middleware handles HEAD like GET: it installs the request-scoped CsrfToken and, when the request carries no CSRF cookie, sends the same Set-Cookie that GET would. A get route that extracts Extension<CsrfToken> now answers HEAD (monitors, crawlers, curl -I) with its GET status instead of 500. |
| Hot-reload machine endpoints | Server::new_hot(...).with_machine_endpoints(policy) now authenticates the policy’s exact routes in the development hot-reload server, as the static server already did in every environment. Previously hot reload ignored the policy and those routes ran unauthenticated; development machine clients must now send the configured credentials. |
| Core JSON PII masking | With enable_pii_masking, JSON string escapes are decoded before masking, so \uXXXX hex digits never join a card-number run (which produced invalid JSON such as \u****), while escaped digits and @ are masked like their literal characters. A masked character is written as a literal *; unmasked escapes keep their original spelling. |
| Core PII masking of range responses | With enable_pii_masking, a 206 Partial Content response whose range would be masked now becomes 502 with Cache-Control: no-store instead of a rewritten 206 that kept its status but lost Content-Range. A range that needs no masking passes through unchanged. A multipart/byteranges response, previously passed through uninspected, is now checked part by part and withheld the same way when a textual part would be masked or when the body cannot be split exactly (invalid boundary or delimiter line, more than 256 parts, a part header block over 8 KiB, no close delimiter). |
| Security DLP, RASP and honeypot | DlpResponseLayer now also masks XML (application/xml, +xml), YAML and application/javascript responses, as it already did for text and JSON; review clients that compare such bodies byte for byte. A 206 Partial Content response whose range contains a masked secret now becomes 502 with Cache-Control: no-store instead of a rewritten 206 without Content-Range. A multipart/byteranges response, previously passed through uninspected, is now checked part by part and withheld the same way when a textual part contains a secret or when the body cannot be split exactly (at most 256 parts and 8 KiB of headers per part). A honeypot ban TTL beyond the monotonic clock (for example Duration::MAX in HoneypotState::try_with_limits) now bans until the furthest representable deadline instead of never banning, and telemetry lists a ban only when one was stored. redact_secrets no longer increments dlp_secrets_masked or emits DLP_SECRET_LEAK_PREVENTED events (it counts only log_redactions), so dashboards stop reporting log lines as blocked responses. RaspSecurityLayer now serves requests whose User-Agent carries the stock PowerShell product token (PowerShell/7.4.1, WindowsPowerShell/5.1…) instead of returning 403; powershell elsewhere, or as execution syntax in that header, still blocks. HoneypotLayer telemetry (honeypot_route_hits, ban reasons and live events) names the configured trap path instead of the request’s letter case, so case variants share one route counter. |
| Security audit log lines | StdoutAuditLogger writes actor, action and resource as quoted, escaped strings (actor="bob" action="login") instead of raw text, so a value with spaces, = or a line break can no longer forge fields or lines. Update log parsers and alerts that match the unquoted 12.x form. |
| Connect generic OIDC | OidcProvider no longer rejects an ID token or userinfo response without the optional name claim (Missing name in id_token/userinfo); ConnectUser::name falls back to given_name/family_name, preferred_username, nickname and may be empty. Applications that relied on a non-empty name must handle the empty case. An ID token without a kid header is now verified when the issuer’s JWK Set holds exactly one key that fits the token’s asymmetric alg, instead of failing with Missing 'kid' header; other sets still fail closed, and a kid-less symmetric token now reports the algorithm error before any JWKS request. |
| Age assurance | Optional rullst-privacy APIs and make:age-gate add an authenticated native declaration journey. Existing apps acquire no age policy, verified age, guardian relationship, replay storage or camera capture by upgrading a dependency. |
| Optional consent and export | make:privacy adds explicit versioned choices, effective withdrawal of the demonstrated optional greeting and an authenticated own-account name/email export. It requires reviewed application installation and explicit consent-store initialization. It is not a complete export of all application data or automatic worldwide legal compliance. |
| Project context | New projects receive application instructions and a bounded metadata map. generate:ai-context replaces recognized legacy source-concatenation output, preserves existing AGENTS.md, and adds a non-writing --check. See the context guide for size/path/configuration limits. |
| Typed API generation | generate:api is an explicit additive OpenAPI profile with generated Rust codecs and a TypeScript client. Install/review the schema and application handlers; existing scanning generators and routes are not migrated automatically. See the supported profile. |
| CLI generator output | make:migration:auto reads #[orm(table_name = "...")], qualified #[derive(rullst_orm::Orm)] and a table option written after other #[orm(...)] options, so it diffs the declared table instead of a guessed <struct>s table. make:live imports rullst::async_trait instead of the undeclared async_trait crate; edit components generated earlier the same way. make:scalar imports rullst::web::axum::Router instead of the undeclared axum crate. eject writes a template that compiles (Axum through rullst::web::axum) and wraps its placeholder routes in the configured security baseline; it still does not carry over application routes, so --force output needs the routes moved from src/main.rs.rullst-backup. foundry:deploy uploads the binary to /opt/rullst/<app>/incoming/<bin>.upload (created 0700 for the SSH user during provisioning) instead of /tmp, and the remote step requires stat and sha256sum to check its owner and digest. foundry:init creates a missing .gitignore, no longer treats a comment or !Foundry.toml as protection, and writes Foundry.toml with mode 0600. make:model rejects names such as Match or Type whose module would be a Rust keyword, as make:resource already did. make:migration keeps leading m characters of the name (modify_users previously became odify_users) and rejects names that are not ASCII letters, digits, _ or -; generators that refresh src/migrations/mod.rs now fail on an m*.rs file whose name is not a valid module name instead of writing an unparsable registry. make:controller --api and make:resource --api handlers extract Json<CreateDto>/Json<UpdateDto> instead of Form, so clients must send application/json; controllers generated earlier keep Form until edited. generate:ts also scans src/lib.rs and turns {id}/{*path} captures into URL-encoded method arguments; regenerate rullst-client.ts, whose parameterized methods change signature. Re-run hook:install to refresh a managed commit-msg hook: it now accepts Git’s merge, revert and fixup!/squash!/amend! subjects. generate:openapi takes an operation summary only from the handler with the exact name, not from an earlier function whose name starts with it (show_archived for show). make:worker handlers no longer print the full job payload; existing workers keep their logging until edited. Fixture-mode make:billing (and SaaS starter) checkout/portal handlers answer a database failure with a generic Billing identity is unavailable 500 and log the error server-side. make:migration:auto ignores the ORM migrations table and rullst_* tables, and prints destructive-only differences instead of writing and registering a comment-only auto_sync migration on every run. hook:install fails when core.hooksPath selects another directory, instead of reporting success for wrappers Git never runs. inspect schema prints the ORM model schema derived from #[derive(Orm)] structs when no project-provided rullst-schema.json exists, instead of asking for a file that nothing generates. |
| CLI generator robustness | generate:models with --driver postgres or --driver mysql casts and aliases the information_schema columns it reads, so PostgreSQL 12+ and MySQL 8 schemas introspect instead of failing to decode sql_identifier or panicking on upper-case MySQL labels; unexpected metadata is now an IntrospectionError instead of a panic. make:migration:auto declares columns from the field’s Rust type (integer, big_integer, float, boolean or string) instead of TEXT for every field, declares non-Option fields of a new table NOT NULL, adds a required column to an existing table as NOT NULL DEFAULT 0/0.0/'', and fails without writing when a field type has no mapping (for example Vec<u8>, Uuid, a tuple or an enum) or a required date/encrypted/JSON column would leave existing rows NULL; mark such fields #[orm(skip)] or add them with make:migration. cargo rullst auth and make:mfa refuse a project without a SQL migration runner (pub mod migrations; plus a get_migrations() call in src/main.rs/src/lib.rs), such as a --no-database starter, instead of writing a users or MFA migration nothing compiles or applies; the generated User model, users migration and auth controller name the ORM as rullst::orm::Error instead of the undeclared rullst_orm crate. inspect routes no longer follows symlinks under src/ (a link cycle made it effectively hang, and a link out of the project listed routes from outside it); route files reached only through a symlink are no longer listed. generate:ts clients send the rullst_csrf cookie value as X-CSRF-Token on state-changing requests and set credentials: 'same-origin', so they pass csrf_middleware instead of receiving 403; the constructor gains an optional RullstClientOptions argument (csrfToken, credentials). Regenerate rullst-client.ts. make:live now requires a Rullst project root, and make:live, make:controller and make:middleware reject names whose module or type is not a non-keyword Rust identifier (such as ../notes, self or Bad.Name) instead of writing outside src/live or an unparsable mod.rs. New projects’ .gitignore also lists *.duckdb, *.duckdb.wal, *.sqlite-shm and *.sqlite-wal, matching .dockerignore; add them to the .gitignore of an existing project, particularly one created with --duckdb (DUCKDB_PATH=analytics.duckdb). |
| ORM database-managed columns | #[orm(skip)] means “not a database column for the ORM”: generated filters, ordering, grouping and select reject it with a Validation error that now says so and names #[orm(db_default)]. A column the table has that ORM writes must not touch (such as users.is_admin and users.session_epoch) is declared #[orm(db_default)]: read, filterable (where_eq("is_admin", true)), never written by INSERT, save() or update_partial(), and included in to_json(), audit and event payloads and query-cache entries (where the skipped field previously came back as Default). New SaaS, LMS and cargo rullst auth User models use it; in an application generated by 13.0.0-alpha.2 replace #[orm(skip)] with #[orm(db_default)] on those two fields (cargo rullst upgrade reports V13-ORM-SKIP-MANAGED-COLUMNS). |
| ORM dirty tracking | Opt-in: a model that declares #[sqlx(skip)] pub snapshot: RowSnapshot (rullst_orm::RowSnapshot, also rullst::db::RowSnapshot) makes save() of a loaded record write only the columns whose value changed and refresh the others from the stored row (RETURNING * on PostgreSQL and SQLite, a re-read on MySQL/MariaDB). An unchanged save runs no UPDATE and no updating/updated/saved observer, after_save hook, audit entry or event, and without a before_save hook, policy or observers opens no transaction. update_partial() on such a model writes only the patched columns that differ. Call mark_all_dirty() before save() where code relied on overwriting concurrent changes. UPDATE OF <column> triggers fire only for written columns, and a before_save hook that stamps updated_at on every call makes every save write (check is_dirty() first). Struct literals need snapshot: RowSnapshot::default(), and models deriving Serde need #[serde(skip)] on the field. make:model adds the field to new models; from 13.0.0-alpha.3 every SQLx model the CLI writes declares it (generate:models, cargo rullst auth, make:billing, make:chat-session and the Blank, Blog, ERP, Portfolio, SaaS and LMS starters), so new projects save only changed columns. Models without it, including those of projects generated earlier, are unchanged. |
| ORM partial updates | update_partial() now merges into a freshly loaded row and runs full-save policy/hooks/audit/post-commit behavior. It performs full-row SQL and refreshes the caller object; review audit identity, triggers, unsaved local fields and outer rollback. New save_with_tx is explicit. See the migration contract. |
| ORM derive checks | Some models that compiled on 12.x now fail at compile time instead of misbehaving at runtime: belongs_to without foreign_key defaults to <related model>_id (it previously used <this model>_id, joining on the wrong column); local_key on belongs_to/morph_to and related_key on has_one/has_many/morph_one/morph_many are rejected because those relations ignore them; and searchable models need a lowercase, Scout-valid table name. Add an explicit foreign_key, remove the ignored option or set #[orm(table = "...")]. |
| ORM queries and cache | count()/paginate() totals count DISTINCT rows and GROUP BY groups; offset chunk() pages are ordered by primary key unless order_by is set; SecretString columns are rejected in filters, ordering, grouping and pluck_*. While a transactional stream() is open, other ORM calls on that transaction return Validation instead of waiting. Models without a tenant_column share one global query-cache partition, so cache keys built manually with query_cache::query_key inside with_tenant for such models no longer match generated keys. Scout-backed search() results follow the engine’s ranking unless order_by is set. Generated writes now bind #[sqlx(json)] fields as SQLx Json (JSON on PostgreSQL/MySQL instead of the field’s own SQL type), so such a field type needs Serialize; a type that implemented only Encode no longer compiles. |
| ORM query-cache index | Generated .remember(...) keys move to the rullst:orm:cache:v4: prefix, and each table index becomes a Redis sorted set (<prefix>:index) scored by entry expiry instead of the <prefix>:keys set. Entries written under the previous prefix are not read again and expire through their TTL, so caches start cold after the upgrade; during a rolling upgrade a write on an instance of the other version does not invalidate the new entries before their TTL. Index members of expired entries are pruned on each cache write and no longer count toward the 10,000-key PostCommit invalidation cap. Tools that inspected the old :keys set must read the sorted set. |
| ORM Redis model hashes | Generated save_to_redis/get_from_redis/increment_redis_field hashes move from the shared 12.0/12.1 key orm:<table>:<id> to namespaced keys (rullst:orm:hash:v1:<namespace>:<scope>:table-<digest>:<id>), and tenant models require with_tenant(...). For a model without a tenant scope nothing has to be run: get_from_redis still reads the 12.x hash while the namespaced one is missing, and the next save_to_redis or increment_redis_field moves it to the namespaced key in one Redis script (an existing namespaced hash wins and the stale 12.x hash is removed); a 12.x hash with #[orm(encrypted)] fields holds them in plaintext and fails closed until save_to_redis() rewrites it. Tenant models never read the 12.x key, which every tenant shared: until you run the one-time procedure in Model hashes stored by 12.0 and 12.1, get_from_redis returns None and increment_redis_field starts from zero. A 12.x instance never reads a moved hash and a 13 instance ignores 12.x writes once the namespaced hash exists, so stop 12.x writers of these hashes before 13 instances write them instead of mixing versions. |
| ORM outbox keys on MySQL/MariaDB | Outbox streams, event keys and claim tokens compare case-sensitively on every backend; new MySQL/MariaDB rullst_outbox tables declare those columns ascii_bin. A table created by 12.x keeps the server’s case-insensitive collation without any error, so keys that differ only by letter case still share idempotency and claims. Run Outbox::install() once, for example from a new migration (an OutboxMigration that already ran is not run again), to convert the table in place; it rebuilds the table, so schedule it like other DDL. See the transactional outbox tutorial. PostgreSQL and SQLite tables are unchanged. |
| ORM audit payloads on MySQL/MariaDB | New MySQL/MariaDB rullst_audits tables store old_values, new_values and restore_patch as LONGTEXT. A table created by 12.x keeps TEXT (64 KiB): create_audit_table only logs a warning, and an audited change with a larger payload still fails in strict SQL mode (or is truncated otherwise). Apply ALTER TABLE rullst_audits MODIFY old_values LONGTEXT, MODIFY new_values LONGTEXT, MODIFY restore_patch LONGTEXT in a maintenance window, as described in auditable revisions. |
| Turso migrations | TursoStore::rollback_last and TursoOrm::rollback_last now refuse, with the same drift error as migrate, a latest migration whose digest (its up and down statements) differs from the recorded one, instead of running the changed down statements and deleting the history row. Restore the applied definition to roll it back. |
| Schema table names on PostgreSQL | Schema::create and Schema::drop_if_exists lower-case the quoted table name on PostgreSQL, matching the unquoted names generated model SQL uses, so Schema::create("UserProfiles", ...) now creates userprofiles. A table an earlier version created as "UserProfiles" is not dropped by drop_if_exists("UserProfiles") and stays unreachable by generated SQL; rename it with ALTER TABLE "UserProfiles" RENAME TO userprofiles. MySQL/MariaDB and SQLite are unchanged. |
Legacy SecretString ciphertext | Pre-v12 SecretString values, which carry no key ID, now decrypt with the current key and then each RULLST_ENCRYPTION_KEYRING key (default first), so they stay readable after a documented rotation. When the current key does not match, a malformed keyring now fails the read with PrivacyError::EnvError instead of DecryptionFailed. |
SecretString client input | SecretString’s Deserialize still decrypts any serde envelope under the configured key or keyring, and that envelope binds no model, field or owner. Review request types that deserialize a SecretString from client input, including models used directly as JSON bodies, and add #[serde(deserialize_with = "rullst_orm::privacy::deserialize_plaintext_secret")] (or deserialize_optional_plaintext_secret, v13 prerelease API), which rejects envelope-shaped values without decrypting them. |
ORM protected values and SecretString serialization | New audit rows and committed events (including Redis orm:events:*) write #[orm(encrypted)] and #[orm(masked)] values as "***", Scout documents omit them, and save_to_redis stores encrypted fields as the SQL envelope; a change to such a field makes its audit revision non-restorable. SecretString serializes as an encrypted RULLST:v2 envelope ("***" in generated projections) instead of its plaintext, and serializing it without RULLST_ENCRYPTION_KEY fails. Audit rows, search documents and events written by 12.x are not rewritten: purge or reindex them if they may contain plaintext. A 12.x process that deserializes a SecretString serialized by 13 (queue jobs, caches, JSON exchanged between services) takes the envelope as the plaintext value, so upgrade every reader before the writers and do not exchange such payloads between versions. |
| Offline Redis mock | The offline RedisDataStore mock returns equal sorted_set_top scores in descending lexicographic member order, like Redis ZREVRANGE, instead of ascending order. Update tests that relied on the old tie order. |
| SQLite DSN paths | The missing-file preparation before connecting percent-decodes the SQLite DSN path as SQLx does, so sqlite://data%20dir/app.db creates data dir/app.db, the file SQLx opens, instead of a stray data%20dir/app.db. Stray encoded-name files left by earlier versions can be removed. |
| Auto-healing diagnostics | SchemaErrorInterceptor::diagnose_sql_error returns None for constraint violations, already exists errors, other messages that merely mention a relation, and names that are not plain identifiers. Missing-table suggestions use SQLite or MySQL DDL when the message comes from those drivers, and SQLite no such column and MySQL Unknown column/Table ... doesn't exist messages are recognized. |
| ORM migrations | The Migration trait gains within_transaction() (default false, v13 prerelease API). Returning true makes the runner apply up()/down() and its migrations record in one Orm::transaction; only statements that join it (Schema, Orm::raw(..).execute(), execute_query!, generated model methods) are covered, and MySQL/MariaDB DDL still commits implicitly. The template written by the ORM artisan make:migration now opts in, so such a migration that sends queries to Orm::pool() directly must return false. Existing migrations keep the separate statements. db:seed now fails, without running any seeder, while a migration registered with artisan! is not recorded in the migrations table; run db:migrate first. |
| ORM transactional migrations and the pool | Since 13.0.0-alpha.3, while the up()/down() of a migration that returns true from within_transaction() runs, Orm::pool(), Orm::try_pool(), Orm::read_pool(), Orm::try_read_pool() and Orm::begin_transaction() return Error::Validation naming the migration, instead of a connection outside the migration’s transaction; the run then fails (DatabaseError with Transaction failed: Validation error: ...) and the migration rolls back unrecorded. Send that SQL with Orm::raw(sql).execute(), execute_query! or Schema, or return false from within_transaction(). Migrations that keep the default false are unaffected. |
| ORM instance mutations | save() of a row deleted after it was loaded, and delete()/force_delete() of a missing row, now fail with RecordNotFound (tenant models keep their tenant-scope Validation error) instead of returning Ok and running observers, audit and post-commit effects. A second soft delete() of a trashed row fails the same way instead of re-stamping its deletion time, and restore() of a missing or live row is a no-op on every model. cascade_soft_delete now also trashes children that the child model’s global_scope hides. A model whose only persisted field is id can now be inserted. An auditable save() locks the row it diffs (FOR UPDATE on PostgreSQL/MySQL), deleted/force_deleted/restored audit entries record the persisted row instead of the in-memory handle, and that read, revision restores, restore() re-reads and belongs_to_many pivot loads now fail with the configured query timeout instead of waiting indefinitely. Callers that deleted idempotently must accept RecordNotFound. |
| ORM constraint errors | A unique or primary-key violation now returns Error::UniqueViolation { constraint, .. } and a foreign-key violation Error::ForeignKeyViolation { constraint, .. }, from generated writes, query builders and Orm::raw, instead of Error::DatabaseError(String). Their message names only the constraint (PostgreSQL) instead of quoting the database’s text, so code that matched DatabaseError or searched the message for UNIQUE constraint failed, duplicate key or Duplicate entry must match the variants instead. Orm::transaction returns them unchanged too (see ORM transaction errors). |
| ORM transaction errors | Orm::transaction returns Result<R, E>, where E is its closure’s error type and must implement From<rullst_orm::Error> and Display, instead of Result<R, rullst_orm::Error> (13.0.0-alpha.3). After the rollback the closure’s error reaches the caller unchanged instead of Error::DatabaseError("Transaction failed: ..."), so a UniqueViolation, ForeignKeyViolation or the application’s own error keeps its type; begin, commit and post-commit failures arrive through E::from. A closure whose error type lacks From<rullst_orm::Error> (such as String) no longer compiles: return rullst_orm::Error or an application error that wraps it. Code that matched DatabaseError on the result must match the closure’s own error instead; cargo rullst upgrade reports Orm::transaction calls in files that mention DatabaseError (V13-ORM-TRANSACTION-ERRORS). |
| ORM encryption key settings | RULLST_ENCRYPTION_KEY, RULLST_ENCRYPTION_KEY_ID and RULLST_ENCRYPTION_KEYRING are now also read from .env in the current working directory when the process environment does not set them, as APP_KEY already was; the process environment still wins and the file is never exported. A .env that sets them now takes effect for #[orm(encrypted)] fields and SecretString, so remove stale entries; a malformed .env now fails such a lookup when the process lacks the variable. |
| ORM query values | RullstValue is now #[non_exhaustive] and gains I64(i64) and Null, so a match on it outside the ORM needs a wildcard arm. i64 and u32 values now convert to I64 (bound as BIGINT) and Option<T> to T’s value or Null, so where_eq("lesson_id", Some(3)) and BIGINT filters compile. Null binds as a text-typed NULL; on PostgreSQL cast it for a column of another type, and filter missing values with where_null. Integer literals still convert to Int (i32). The chrono types DateTime<Utc>, NaiveDateTime, NaiveDate and NaiveTime convert to the new DateTime, NaiveDateTime, Date and Time variants; strict driver pools bind them natively and the SQLx Any pool as ISO-8601 text. |
| ORM date/time columns | With a strict driver feature (strict-postgres, strict-mysql or strict-sqlite, which generated projects use), Blueprint::timestamps() now creates timestamp_tz() columns instead of TEXT DEFAULT CURRENT_TIMESTAMP: TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP on PostgreSQL, DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) holding UTC on MySQL/MariaDB and RFC 3339 TEXT (2026-10-15T09:30:00.123+00:00) on SQLite. Read them as rullst::orm::chrono::DateTime<Utc>, set with Utc::now(), or as Option<DateTime<Utc>> marked #[orm(db_default)] so inserts keep the database default; a String field no longer decodes them on PostgreSQL or MySQL/MariaDB (on SQLite it still does, but the default text changes from YYYY-MM-DD HH:MM:SS to the RFC 3339 form). Only tables created from now on change, including a fresh database built by an existing migration that calls timestamps(); existing TEXT columns stay until a reviewed migration converts them, for example ALTER TABLE t ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at::timestamptz on PostgreSQL. The SQLx Any pool (the drivers-all default without a strict feature) cannot read date/time values, so there timestamps() keeps the TEXT columns and the new date(), time(), timestamp() and timestamp_tz() columns fail on PostgreSQL and MySQL/MariaDB with Error::Validation before any DDL runs. make:migration:auto declares chrono fields with those helpers instead of string(). Nexus now reads Date/DateTime fields as text on PostgreSQL and MySQL/MariaDB so native columns display, and writes them as typed values (see “Nexus dates and times”). cargo rullst upgrade reports String created_at/updated_at fields of SQLx models. |
| Schema builder constraints | Column gains the public field is_unique (set by Column::unique()), so building a Column { .. } with a struct literal needs it; use Column::new. Schema::create also runs the CREATE INDEX statements of index()/unique_index() on PostgreSQL and SQLite, and Blueprint::build() includes the table’s unique, foreign-key and CHECK constraints. Error gains UnsupportedSchemaChange { driver, change }, returned by Schema::table for changes SQLite cannot make to an existing table; a match on Error outside the ORM already needs a wildcard arm. |
| ORM sandbox tests | #[rullst_orm::test] keeps the function’s declared return type and returns the body’s value after the rollback, so a test returning Err now fails instead of passing. A test whose declared type does not match its body, or is not a Termination type such as Result<(), E: Debug>, no longer compiles; fix the signature. Post-commit effects of sandboxed writes (committed observers, cache invalidation, Redis events, Scout) no longer run, because the sandbox never commits; assert them in a test that commits through Orm::transaction. |
| ORM model SQLx options | A struct-level #[sqlx(...)] option other than default on a #[derive(Orm)] model, such as rename_all = "camelCase", no longer compiles: FromRow decoded the renamed columns while generated SQL used the field names, so writes or reads failed at runtime. Name the fields after their columns instead. |
| ORM trashed scopes | only_trashed() on a model without soft deletes now fails with Validation instead of returning, counting or bulk-deleting every live row as if it were trashed (an “empty trash” only_trashed().delete_all() deleted the whole table). Remove the call, or give the model a deleted_at field or #[orm(soft_delete)]. |
| ORM bulk soft deletes | On soft-delete models, delete_all() with with_trashed() or only_trashed() now fails with Validation instead of re-stamping the deletion time of rows that were already trashed, which kept age-based purges from ever seeing them expire. Bulk-delete live rows without these modifiers and purge trashed rows with force_delete(). |
| ORM joined scopes | Generated tenant and soft-delete predicates and the chunk_by_id keyset are now qualified with the model’s table (orders.tenant_id = ?, orders.deleted_at IS NULL, ORDER BY orders.id ASC), so a lazy belongs_to_many load or a join with a table that has the same columns no longer fails with an ambiguous column. Update tests that compare generated SQL text; Redis query-cache entries are keyed by the new SQL and are rebuilt on the next read. |
| ORM chunk bounds | chunk(), chunk_with_tx(), chunk_by_id() and chunk_by_id_with_tx() now honour an explicit limit(n) as a cap on the total rows handed to the handler and an explicit offset(k) as the starting point, instead of silently walking every matching row from the first one. Remove limit()/offset() from chunked queries that relied on them being ignored. |
| ORM eager pivot order | An eager belongs_to_many load now orders each parent’s related rows like the related query, so an order_by in with_<relation>_constrained applies, instead of following the order of the unordered pivot rows. Without an order_by the order remains unspecified. |
| PersonalData reports | compliance_schema() now reports the table #[derive(Orm)] uses (#[orm(table)] or the <struct>s default, previously the lowercased struct name) and lists in encrypted_fields/has_encrypted_data only #[orm(encrypted)] and SecretString columns; #[privacy] fields, which are not encrypted by the derive, are listed by the new ComplianceModel::personal_fields(). |
| Nexus derive | A field annotated #[nexus(primary_key)] is now the record key even when a later field is named id (previously the last of them won, and both were hidden and read-only). An annotation that contradicts the struct-level #[nexus(primary_key = "...")] no longer compiles; since alpha.2, several annotated fields declare a composite key (see “Nexus composite keys”). A non-integer derived key (a UUID or code) is now listed and entered in the create form instead of being hidden and read-only; mark it #[nexus(readonly)] or #[nexus(hidden)] to keep the previous behaviour for a database-generated key. Fields typed chrono::DateTime<Utc>, DateTime<chrono::Utc> or NaiveDateTime now get the validated date-time widget instead of free text. Rust integer fields (i8 to i64, u8 to u64, isize, usize, also under Option or kind = "number") now derive FieldKind::Integer { min, max } bounded by their type (u64/usize capped at i64::MAX) instead of Number, so Nexus rejects fractions, exponents and out-of-range values with 422; code comparing derived metadata with FieldKind::Number must expect Integer. |
| Nexus composite keys | NexusModel::nexus_pk_columns() (default: vec![nexus_pk()]) declares a composite primary key, and RegistryEntry gains pk_columns: Vec<&'static str> (empty means the single pk): code that builds a RegistryEntry with a struct literal must add pk_columns: Vec::new(). In #[derive(Nexus)], #[nexus(primary_key = "a, b")] or #[nexus(primary_key)] on several fields now declares a composite key (two annotated fields used to be a compile error); every column of a composite key is entered on create, even an integer one. A composite record is addressed by its values, each percent-encoded and joined by , (7,intro), in URLs, batch selections and the audit record_key. Registry validation rejects more than 8, repeated, unregistered or tenant key columns. |
| Nexus dates and times | Date and time fields are written as typed values instead of text, so native PostgreSQL DATE/TIME/TIMESTAMP/TIMESTAMPTZ columns accept edits (PostgreSQL writes go through a cast; a text column still stores text). #[derive(Nexus)] now maps chrono::DateTime<Utc> to the new FieldKind::DateTimeUtc (it was DateTime) and NaiveTime to the new FieldKind::Time; kind = "datetime_utc" and kind = "time" select them. A DateTimeUtc field’s datetime-local input shows and accepts wall times in the panel’s time zone, UTC unless Nexus::with_time_zone selects another NexusTimeZone (13.0.0-alpha.3; IANA zones need the nexus-tz feature): a value without an offset is a wall time in that zone, one with Z, ±HH:MM or ±HH is converted, and a text column stores RFC 3339 (2026-10-15T09:30:00+00:00). The handlers nexus_new_form, nexus_edit_form, nexus_create_record and nexus_update_record take an extra Option<Extension<NexusTimeZone>> argument, which only code calling them directly sees. Code comparing derived metadata with FieldKind::DateTime for a DateTime<Utc> field must expect DateTimeUtc. A Date (and the date of a date-time) with year 0000 is now a 422. A local date-time or time with a fraction is stored with chrono’s 3, 6 or 9 digits (10:00:00.5 becomes 10:00:00.500). Hand-written metadata keeps working; switch DateTime<Utc> fields to FieldKind::DateTimeUtc for UTC handling, as the starters now do for created_at/updated_at. |
| Nexus access in the starters | New SaaS and LMS projects mount Nexus with NexusAuthPolicy::local_development_or_application(middlewares::nexus_access::NexusAdmins): debug builds keep loopback access, and release builds admit the signed-in user whose new users.is_admin column (Boolean, default false, #[orm(db_default)] on User, editable in Nexus) is true, with no NEXUS_ADMIN_* credentials (their .env no longer lists them). Grant the first administrator with UPDATE users SET is_admin = TRUE WHERE email = '…';. To adopt this in an existing SaaS/LMS application, add a migration with table.boolean("is_admin").not_null().default(ColumnDefault::Integer(0)) (through Schema::table), the #[orm(db_default)] pub is_admin: bool field, and copy src/middlewares/nexus_access.rs from a new project. cargo rullst auth users also get is_admin, and its controller now sets is_admin: false when registering: a regenerated controller needs the field in an older User model. New Blog, Portfolio and ERP projects without NEXUS_ADMIN_* in a release build now start without Nexus (the ERP without its back office, serving only /health and /ready) and print one line naming the variables, instead of refusing to start; a weak or placeholder credential still stops startup. |
| Nexus credentials | NexusAuthPolicy::basic_from_env (and release builds of local_development_or_basic_from_env) reads NEXUS_ADMIN_USERNAME/NEXUS_ADMIN_PASSWORD from the process environment, then .env in the working directory only, through rullst::config::project_setting. It no longer loads .env (or a parent directory’s .env) into the process environment, so code that relied on that side effect must read its own settings; a malformed or unreadable .env now fails with the new NexusBuildError::InvalidDotenv instead of being ignored. |
| Nexus panel | A list sort_by naming a hidden column (for example a derived password_hash) now orders by the record key, as a Password column already did; bookmarked URLs that sorted by such a column show key order. A model without a visible text, textarea, e-mail or URL field shows no search box, and a q sent to it lists no rows instead of every row. With the debug loopback policy, an IPv4-mapped loopback peer (::ffff:127.0.0.1, reported by a dual-stack HOST=:: listener) is now accepted as loopback instead of receiving 403; other mapped addresses stay rejected. That policy also accepts a mutation with Origin: null when it carries Sec-Fetch-Site: same-origin (what browsers send under Referrer-Policy: no-referrer) and stamps Referrer-Policy: same-origin on responses that set none. A create or update rejected by validation now answers 422 with a plain-text message (Invalid <label> form: …) instead of an htmx out-of-band toast fragment, and a create without writable values answers 400 in plain text; the panel shows these messages, and HTML error bodies, as text. With required audit, a create whose primary key the administrator entered (a text or UUID key) now records that key in record_key instead of NULL. A saved local date-time is now stored as YYYY-MM-DD HH:MM:SS[.fraction] (the CURRENT_TIMESTAMP/SQLx text form) instead of the browser’s YYYY-MM-DDTHH:MM, and the space-separated form is accepted on input; values with an offset are stored as entered. Rows Nexus saved earlier keep their T form until edited again. FieldKind gains the Integer { min, max } variant and is now #[non_exhaustive]: a match on it outside Nexus needs a wildcard arm, and later kinds will not break it again. The AI assistant no longer renders <img> (or any other element outside a text-formatting allowlist) from a reply; images a model returns are dropped. |
| CLI migration catalog | rullst-upgrade-rules-v4 recognizes source majors 12 and 13 and adds v12 → v13 source rules: upgrade --dry-run (and --json, whose v1 findings gain kind, migration_row and migration_url) reports MUST-CHANGE and REVIEW findings linked to the rows of this table. update project verify, review and apply refuse a preparation with must-change findings and accept one with only review findings. v5/v6 applications, including their v11-era ecosystem crates, first upgrade to v12 with the v12 CLI; the v13 CLI no longer carries their rules. The installed CLI must belong to the exact target major. Preparations from an earlier catalog must be prepared and verified again. Downgrades still fail. When cargo fix or cargo check fails, the error also repeats the guidance of V13-CAPITAL-REMOVED and V13-MAIL-REMOVED findings: they name removed APIs but are review findings. |
CLI upgrade downgrades | cargo rullst upgrade (and the dashboard’s Safe Upgrade) now fails, before writing anything, when its target (the installed CLI version or --to) is older than a managed requirement’s lower bound or a Rullst package in Cargo.lock. It previously pinned every Rullst dependency to the older version and let cargo fix downgrade the lockfile. Install a CLI that is not older than the project. |
| CLI Foundry service environment | foundry:deploy adds PORT="<[app] port>" (default 3000) to the service environment unless [env] sets PORT, so the application listens where Caddy and the /health probe connect; previously a non-default [app] port produced a 502. An [env] PORT that differs from [app] port is now rejected; without [app] port, [env] PORT selects the proxied port. It also adds HOST="127.0.0.1" unless [env] sets HOST or RULLST_HOST, so the application no longer listens on every interface (0.0.0.0, the production default) beside Caddy; add HOST to [env] only if something other than the local Caddy must reach that port. |
| CLI Foundry build artifact | foundry:deploy uploads the executable that cargo build --message-format=json-render-diagnostics reports for the package instead of target/<profile>/<package name>, so CARGO_TARGET_DIR, build.target-dir, a workspace target directory and a configured build.target no longer upload a stale or missing binary. A package with several binaries needs package.default-run unless one is named after the package. |
| CLI generators | make:model --migration and make:resource skip the create-table migration when the model file or a *_create_<table>.rs/*_create_<table>_table.rs migration already exists, because its rollback would drop the live table; add schema changes with make:migration. generate:models/make:models-from-db refuse to replace an existing <table>.rs and append missing declarations to an existing mod.rs instead of rewriting it; move colliding files aside or use another --output. make:k8s, dockerize, nixify and generate:buildah no longer replace existing manifests, Dockerfile, flake.nix, .envrc or build_buildah.sh; move a file aside to regenerate it. cargo rullst auth rejects Turso-primary projects, refuses to replace existing user/auth files or to add a second users-table migration, enables the orm and auth features and registers its modules in the crate root. make:mfa now generates a server-side factor store (user_mfa_factors migration, replay-protected verification bound to the authenticated Extension<i32> user id) instead of a handler that verified a client-supplied secret; it enables orm and security, rejects Turso-primary projects and refuses to overwrite. Replace a previously generated mfa.rs that accepted a secret form field. |
| CLI operations | deploy --platform fly and --platform railway now exit non-zero when the installed flyctl deploy or railway up fails, instead of printing a hint and exiting 0; a missing provider CLI is still advisory. Update scripts that relied on the old exit status. update project verify resolves the candidate with cargo update --workspace instead of cargo generate-lockfile, so unrelated Cargo.lock pins are kept; verifications recorded with the old command no longer pass review/apply and must be run again. Project-copy Cargo/rustc commands pin RUSTUP_TOOLCHAIN to the caller’s toolchain (inherited value, otherwise rustup’s default) and ignore the project’s rust-toolchain(.toml); set RUSTUP_TOOLCHAIN to verify with a project-pinned channel. foundry:deploy runs the service as a dedicated rullst-<app> system account (it now requires useradd) in a sandboxed unit instead of root; existing /opt/rullst/<app>/data is re-owned, and the application can write only there, so move state written elsewhere (for example an absolute SQLite path) under that directory first. |
| CLI diagnostics and side effects | generate:diagram writes a marker comment at the top of diagram.md and refuses to replace a diagram.md it did not generate; the refresh after make:controller, make:model, make:middleware, make:worker, make:island, auth, make:billing, make:cors and make:jwt keeps such a file and prints a notice. A diagram from an earlier release (a single unmarked Mermaid block) is still replaced. Move a hand-written diagram.md aside to regenerate it. audit --compliance and audit --sbom fail instead of writing through a symlinked SECURITY_COMPLIANCE.md or sbom-cyclonedx.json. The audit source scans and generate:diagram no longer follow symlinked files or directories, so code reachable only through a link is not scanned; a source tree deeper than 64 levels or larger than 250,000 entries makes the audit fail as incomplete. audit --network now exits non-zero, with an ERROR network row in the report, when the TCP listener inventory (ss -ltnH, from Linux iproute2) cannot run, which is always the case on macOS and Windows; it previously reported a clean scan. In sbom-cyclonedx.json, only crates.io packages keep the plain pkg:cargo/<name>@<version> purl: git and other-registry packages gain a vcs_url or repository_url qualifier, path and workspace packages lose their purl, and these components carry a rullst:cargo:source property. Update SBOM consumers that matched those purls. pkg add without a package name and an unknown pkg action now exit non-zero instead of printing an error and exiting 0. deploy rejects an unknown --platform before scaffolding a missing Dockerfile and .dockerignore. make:k8s and the fly.toml written by deploy --platform fly derive names from the [package] name as a lowercase RFC 1123 label (my_app becomes my-app, the image my-app:latest, the host my-app.local); manifests generated earlier keep their names, so rename the resources or the image tag if you regenerate them. cargo install cargo-rullst no longer installs the internal sync-badges repository tool: it requires the new maintainer-tools feature, which the repository’s cargo sync-badges/cargo sync aliases enable. |
| CLI home screen | cargo rullst without a subcommand shows the new opening (the RULLST wordmark and v<version> · SECURE, FAST AND AI-NATIVE RUST FRAMEWORK) instead of the RULLST v… // SECURE • FAST • … tagline and the COMMAND INTERFACE READY line, followed by a summary of the detected project. Inside a project the main menu starts with dev, dash, scaffolding, database, doctor and deploy; the former Already have a project? operations remain under All Project Operations. From a project subdirectory, menu commands run at the project root. When standard input, output or error is not a terminal, CI is set or TERM=dumb, it prints the plain home and exits 0 instead of failing at the first prompt; scripts that matched the old banner text or relied on that failure must change. The opening animates on every run; since 13.0.0-alpha.4 RULLST_OPENING=daily limits it to the first run of a day (the date is stored under the user cache directory, rullst-ui-v1) and RULLST_OPENING=static never animates. See the CLI reference. |
| CLI security audit | Run from a package directory, audit now also scans the src of every workspace member below it (from cargo metadata) for unsafe code and parameterized routes instead of only ./src. audit --idor and audit --network skip each top-level #[cfg(test)] item on its own instead of ignoring the rest of the file after the first one, so routes and listeners declared after an early #[cfg(test)] mod tests; are now scanned and can report new findings. audit --idor also checks every quoted path on a route line, not only the first, so the later routes of a one-line routes! list are reported, and a comment between .route( and its path literal no longer hides that path from the check (place the marker on that comment line). Guard names in comments, doc comments or #[cfg(test)] items no longer satisfy an owner, role or admin classification. Classify such routes with an adjacent // rullst-access: marker. |
| CLI dashboard audit | The dashboard’s security audit item adds --geiger only when cargo geiger --version succeeds, so it no longer fails on a machine without cargo-geiger. Run cargo rullst audit --geiger to require the dependency inventory. |
| Core deferrable jobs | New in 13.0, opt-in: Queue::dispatch_deferred and dispatch_deferred_with place a job by a deadline, daily TimeWindows and an optional application-provided CarbonIntensitySource (deterministic FixedIntensitySource for tests; no network source in Core), and Scheduler::deferrable_task defers recurring runs into windows. Placement reuses dispatch_at, so SQLite/Redis need no schema change and 12.x rows keep working; custom drivers without durable scheduling reject future placements with QueueError::Unsupported. Existing dispatch, dispatch_at and Scheduler::task are unchanged. See deferrable jobs. |
| Core scheduler time zones | New in 13.0.0-alpha.2, opt-in with the scheduler-tz feature: Scheduler::task_in_tz(expression, "America/Sao_Paulo", handler) matches the cron fields against that zone’s wall clock, so a task written in UTC to stand for a local time (0 10 * * * for 07:00 in São Paulo) can state the local time instead. Scheduler::task is unchanged and stays in UTC. 13.0.0-alpha.3 adds Scheduler::deferrable_task_in_tz, the zoned form of deferrable_task, whose windows are wall times of the zone. SchedulerError gains InvalidTimeZone; matches are unaffected because the enum is #[non_exhaustive]. See time zones for scheduled tasks. |
| Dash N+1 warning | New in 13.0, opt-in by running cargo rullst dash: the development telemetry’s observed database object gains repeated_threshold, repeated_queries_total and recent_repeated (ORM operation fingerprints a single request repeated at least three times, by matched route), and the dash shows a POSSIBLE N+1 QUERIES panel. The rullst.dev-telemetry.v1 schema stays compatible: older dashboards ignore the fields, and a newer dashboard shows “N+1 detection needs request-correlated ORM telemetry” for an older application. Studio’s N_PLUS_ONE_THRESHOLD now re-exports rullst_core::query_patterns::N_PLUS_ONE_THRESHOLD (still 3). See N+1 query warning. |
| CLI security report | New in 13.0: cargo rullst audit --report (md by default, or html or json, with optional --output <path>) writes SECURITY_REPORT.<format>, an evidence report mapped to OWASP ASVS 5.0.0 Level 1 with a personal-data inventory, accessibility heuristics and a NOT EVALUATED list, and exits 1 when a check reports FINDINGS or ERROR. No change for existing commands: audit without --report keeps its checks, output, rullst.cli-audit.v1 JSON and exit status. See the security report guide. |
| Generated security tests | New projects get src/security_tests.rs, declared under #[cfg(test)] (all(test, debug_assertions) for Nexus starters) in src/main.rs, which now builds its router in fn router() that main passes to Server::new. cargo test runs the checks offline against that router wrapped in the staging/production baseline: security headers, CSRF, WAF, plus the SaaS/LMS sign-in rate limit, the LMS lesson owner check and the Blank JSON API bearer token. Existing projects can copy the file from a new project and extract their router the same way; nothing changes at runtime. |
| Blank JSON API starter | The --api starter adds POST /api/messages, registered with Server::with_machine_endpoints as a bearer machine endpoint, and a random API_TOKEN in .env (empty in .env.example). JSON clients send Authorization: Bearer <API_TOKEN>; requests with cookies are refused, and startup fails when API_TOKEN is unset or weaker than 32 characters. Previously the starter had no write route, and the first one added was refused with 403 for JSON clients in staging and production because the baseline requires CSRF. Existing API projects keep their behavior; to adopt it, register each JSON write route as shown in machine_endpoints(). |
| CLI audit lockfile | cargo rullst audit, audit --report and audit --sbom in a workspace member, which has no Cargo.lock, now use the workspace root’s lockfile found through cargo metadata (passed to cargo audit as --file) instead of failing the dependency check. The SBOM then lists every package of that lockfile. |
| RASP shell signatures | RaspSecurityLayer and RaspInspector match | sh only when sh ends at a word boundary, and ; cat only when a path-like argument follows (a path, dotfile, ~, $, option or glob). Prose such as “Add it to the cart | shopping list” or “dogs; cat food” is no longer refused with 403; ; cat /etc/passwd still is, while ; cat followed by a bare word no longer is. |
| CLI footprint report | New in 13.0.0-alpha.1, renamed cargo rullst green in 13.0.0-alpha.4 (see the CLI ai removed, footprint renamed green row): cargo rullst footprint builds the release binary (or measures a loopback --url), runs a bounded closed-loop load and reports requests/s, latency p50/p95/p99, errors, process CPU time, peak and idle RSS, binary and Docker image size, energy from readable RAPL counters or a labelled --cpu-watts estimate, and an SCI figure (ISO/IEC 21031:2024) only from a user-provided --grid-intensity; --json prints rullst.cli-footprint.v1. Unavailable values are NOT MEASURED with the reason. No change for existing commands. See the green guide. |
CLI dev/dash shutdown | SIGTERM and SIGHUP (and closing the console on Windows) now stop dev and dash like Ctrl+C: the supervised application and its process group are stopped and the rullst-dev-* snapshot removed. Previously the CLI died on the default signal action, leaving the application holding its port. Scripts that relied on the application outliving a terminated CLI must start it separately. |
CLI dev --ts-sync | The flag now regenerates rullst-client.ts after the initial build and after every successful rebuild, and reports a failed generation in the dev log. It previously generated the SDK once before startup, ignored errors, and never refreshed it. |
CLI dash live metrics | dash polls the application’s loopback GET /_rullst/dev-telemetry once a second and shows requests/s, latency (p50/p95 and a sparkline), errors, recent requests and, when the application reports them, ORM query and queue figures. An application that does not serve the endpoint shows a “telemetry not available” panel with the steps to enable it instead of numbers, and figures from a process the dashboard did not start (another application on the same port) are never shown. r now restarts the application from the current build (refused while a migration or a rebuild runs) and ? opens a help overlay that Esc closes before it quits the dashboard. The footer lists r and ? and shows d and Tab only on terminals at least 120 columns wide. Both log panes now keep the newest line visible when earlier entries wrap; previously the newest rows could fall below the fold. cargo rullst dev prints one extra stderr line at startup suggesting cargo rullst dash; scripts that compare its exact output must allow it. See the CLI reference. |
| Core development telemetry | In a debug build running in Development under cargo rullst dev/dash (RULLST_DEV_GENERATION set), Server also mounts GET /_rullst/dev-telemetry for loopback peers with a loopback Host (HTTP/1.0 requests and requests with proxy forwarding headers receive 404), records each request’s method, path (no query string), status and duration in a bounded in-process recorder from an outermost layer (so panics answered by the development error console and the responses of the security, lifecycle and traffic layers are counted; development polls and /static files are not), and keeps that poll out of the access log, rate limiter and Traffic Shield. An application route at that path conflicts with it, as with the other /_rullst/* development routes. In debug builds telemetry::init_telemetry (called by Server::run) adds a passive layer that counts rullst.orm.query spans (a chunk/chunk_by_id traversal counts its page fetches and its handler’s operations, not itself); it records nothing unless the endpoint is mounted. The new Server::with_dev_queue(queue) reports a queue’s pending count to the dashboard. Release builds, Staging and Production are unchanged. See the telemetry guide. |
| Core development error console | The development panic page (debug build, Development, loopback peer) replaces its AI panel with an “Ask your AI assistant” panel: a read-only prompt to copy into your own assistant (error message, location, the first project stack frames, request method and path) and, when the error is recorded, the path of its JSON context; the page no longer calls /_rullst/explain or /_rullst/autofix and makes no network request. Within Server’s development stack the console records each rendered panic (message, location, project backtrace frames, request method and path without query string; never headers, cookies or bodies) under a random 128-bit id, at most 32 entries for 30 minutes, in process memory, and Server mounts GET /_rullst/errors/{id} with the console, answered only to direct loopback requests with a loopback Host and no proxy forwarding headers (others receive 404). POST /_rullst/autofix (handle_autofix, still exported) no longer validates a path or returns an AI-offline message: loopback peers receive 410 Gone with success: false, a message that points to that panel and context_url: "/_rullst/errors/<error-id>", other peers 403. An application route at /_rullst/errors/{id} conflicts with it, as with the other /_rullst/* development routes. The hot-reload server shows the panel without a context path. Release builds, Staging and Production are unchanged. |
CLI ai fix and ai review | Added by an earlier 13.0 prerelease and removed with cargo rullst ai in 13.0.0-alpha.4 (see the CLI ai removed, footprint renamed green row): cargo rullst ai fix <error-id> and cargo rullst ai review no longer exist. No 12.x command changes. |
| CLI project updates on SELinux | update project review and apply no longer reject a manifest or staged file whose only extended attribute is the SELinux label (security.selinux), so they work on Fedora/RHEL hosts; the replacement receives the label the policy assigns to a new file in that directory. ACLs, file capabilities and other attributes still require manual handling. |
CLI pkg in a virtual workspace | In a Cargo.toml with [workspace] but no [package], pkg add writes the dependency to [workspace.dependencies] instead of a top-level [dependencies] table that made every later Cargo command fail, and pkg list lists that table. Reference the entry from a member with { workspace = true }. |
CLI new repository | Like cargo new, cargo rullst new now runs git init in the new project when Git is installed and the destination is not already inside a Git work tree, and writes a .gitignore keeping .env and /target out when none exists. Pass --vcs none to skip it, for example when a script copies the generated folder into another repository. Without Git it warns and keeps the project. |
CLI generated validator dependency | Generated manifests declare validator = { version = "0.21", features = ["derive"] }, because #[derive(rullst::Validate)] expands to ::validator paths. In a project generated earlier, add that line before deriving Validate on a ValidatedForm/ValidatedJson DTO. |
CLI make:model imports | SQLx models now start with use rullst::db::{FromRow, Orm}; instead of also importing the unused RullstModel and sqlx, so a new model compiles under -D warnings. Existing model files are not touched. |
CLI dockerize and generate:buildah | The binary path in the Dockerfile and the Buildah image name come from [package].name parsed as TOML, as in deploy, instead of the first line starting with name = . A [lib] or [[bin]] table before [package], name="x" without spaces, single quotes or a trailing comment no longer produce a wrong name. Regenerate files written from such manifests. |
| CLI errors and exit codes | Failures are printed on standard error by one report (error: <title> with What happened, How to fix and Docs) instead of Error: <message>; the original message stays in What happened, with passwords, secret-like values and terminal control characters masked. Match the message text or error: instead of Error: . Project commands run outside a project root (make:model, make:controller, make:resource, make:middleware, make:worker, make:migration, make:migration:auto, make:cors, make:jwt, make:mfa, auth, db:*, studio, build, generate:openapi, generate:ts) report this on standard error and exit 1 instead of printing to standard output and calling process::exit(1); the public generators::* functions behind them now return an Err instead of terminating the calling process. An unknown command prints the closest commands and exits 2 (clap’s usage status) with a new message. A command run from the home menu or the command palette that fails is reported once, by the command itself, and the CLI exits with its status; previously a second dashboard command ... failed error followed and the exit status was always 1. A panic prints a short report unless RUST_BACKTRACE or the new global -v/--verbose flag is set. An argument that is not valid UTF-8 is a usage error (exit 2) instead of a panic (exit 101). When standard output is not a terminal, or with NO_COLOR, CI or TERM=dumb, generator output is now plain text without ANSI colours. See errors and exit codes. |
CLI doctor | cargo rullst doctor prints grouped ✓/!/✗/· checks with a fix and docs link per problem instead of [OK]/[WARNING]/[NOT FOUND] lines and N checks passed, M warnings, and adds project, configuration, database reachability, migration, security-baseline and disk-space checks. It now exits 1 when a check fails (a missing or pre-1.96 rustc, missing Cargo, an unreachable configured database, an unknown RULLST_ENV, an invalid Rullst.toml, a Git-tracked .env, less than 1 GiB free); it previously always exited 0, and the public generators::doctor::run_doctor now returns an Err in that case. Missing optional tools (cargo-deny, cargo-geiger, cargo-mutants, Kani, cargo-llvm-cov, Docker) are informational; Kani is detected by its cargo-kani executable without running it. --fix still runs rustup component add rustfmt clippy; its result reads installed by --fix. --json prints rullst.cli-doctor.v1. See doctor. |
| CLI output additions | Successful generators end with a Next steps block of one to three commands on standard output (none for new, auth, foundry:init, make:k8s or --json). New completions <shell> and info [--json] (alias version) commands, and --json for inspect routes (rullst.cli-routes.v1) and audit (rullst.cli-audit.v1, progress on standard error). Both home menus gain Search All Commands before View Help & Commands, so the entries after it move down by one. inspect routes no longer appends the route call’s closing parenthesis to the handler (controllers::users::show instead of controllers::users::show)). |
| SaaS plan gates | The generated /reports/billing route uses authenticated identity, fresh revision-fenced Stripe reconciliation and an explicit BILLING_REPORT_PLAN_IDS allowlist. Production requires live state; offline fixtures cannot grant access. The additive rullst-capital::entitlements API supports other trusted adapters. Existing applications must review/install the generated module and route; no new billing schema is needed. |
| Capital providers and NFS-e removed | rullst-capital keeps the provider-neutral contracts (BillingProvider, MeteredBillingProvider, checkout, subscription, entitlement, quota, charge, invoice and webhook verification/replay APIs), StripeProvider and the experimental InfinitePayProvider, whose live checkout and callbacks still fail closed. Removed: PaddleProvider with its Paddle* customer, checkout, subscription and portal types; LemonSqueezyProvider with LemonSqueezyUsageRecord and LemonSqueezyUsageAction; PolarProvider with PolarCheckoutRequest and PolarCheckoutSession; RazorpayProvider; MercadoPagoProvider; AlipayProvider; CoinbaseCommerceProvider/CoinbaseProvider; PicPayProvider; the Wise payout adapter (WiseProvider, WiseTransferState, WiseTransferStateChange); and the payout contracts (PayoutProvider, PayoutStatus, PayoutEvent, init_payout_provider, try_init_payout_provider, payout_provider). The NFS-e module is removed too: rullst_capital::fiscal and its crate-root re-exports, the nfse feature, the facade’s capital-nfse feature, Invoice::to_dps and CapitalError::FiscalError. It was never validated with a real municipality and may return as a separate product outside Rullst. These adapters stay in the 12.x line and in Git history. To keep another gateway, implement BillingProvider in application code as described in Writing your own payment provider. make:billing and the SaaS starter now generate Stripe only: BILLING_PROVIDER must be stripe, and the Paddle modules, the Lemon Squeezy fixture and the BILLING_STORE_ID and BILLING_PADDLE_* settings are gone; existing generated controllers are not rewritten. In one generated by 12.x (the SaaS starter or make:billing), delete the lemonsqueezy branches, lemon_provider and BILLING_STORE_ID, and keep BILLING_PROVIDER=stripe; the Stripe paths compile against v13 unchanged. make:mail-invoice now generates a receipt-only PaymentReceiptEmail that enables only mailer; delete from_nfse_response and the NFS-e variants from a mailable generated earlier. |
| Capital quota keys on MySQL/MariaDB | SqlQuotaStore keys (subject kind and ID, feature, event key) are case-sensitive on every backend. New MySQL/MariaDB tables use ascii_bin key columns. A quota table created by an earlier release, whose key columns fold case, makes prepare_schema and every reserve, release and usage call return QuotaError::StorageUnavailable until you run the ALTER TABLE migration in the Capital README. Rows that earlier releases merged for keys differing only by case stay merged. PostgreSQL and SQLite tables are unchanged. |
| Capital zero tier limit | Billable::quota_request for a feature whose tier_limit is Some(0) now returns QuotaError::LimitExceeded { used: 0, requested, limit: 0 } instead of QuotaError::InvalidRequest, matching check_quota. Map it like any other quota denial (for example 402/403 with an upgrade prompt) rather than a 400/500. |
| Capital provider subscription IDs | Subscription IDs made only of dots (., ..) now fail with CapitalError::SubscriptionError before any provider request. A live adapter put them into the URL path, where they are dot segments that send the authenticated request to another provider endpoint. |
| Capital invoice amounts | Invoice::validate, total_minor, try_generate_html, the PDF renderers and bind_succeeded_charge now reject an item amount or total that rounds to zero minor units (for example 0.0000000005 USD or 0.0000001 JPY) with CapitalError::InvalidInvoice, as the error message already stated. Such an item used to pass as 0 minor units. Amounts of at least one minor unit are unchanged. |
| Messaging outbox relay key | OrmOutboxRelay publishes with the broker idempotency key <lowercase hex SHA-256 of the stream>/<event_key> instead of the bare event_key, so streams that share a topic no longer deduplicate (identical content) or conflict (different content) with each other. A claim published before the upgrade and not acknowledged is republished once under the new key, producing a second broker message; drain or acknowledge in-flight outbox claims before upgrading, or rely on consumer idempotency. |
| Messaging encrypted SQLite startup | SqliteBroker::connect_encrypted now opens one retained record per referenced storage key ID. A prior key with the right ID but wrong bytes, or a tampered record, fails at startup with MessagingError::StorageAuthenticationFailed (or CorruptStorage for damaged row metadata) instead of starting and later failing every receive whose oldest pending record uses it. Correct keyrings are unaffected. |
| Messaging Redis Streams candidate | RedisBroker stores a new state format (v2) so that messages available at the same time, such as every retained message of a new earliest group, are claimed in publication order instead of text order (sequence 10 before 9). A namespace provisioned by an earlier v13 development build fails with MessagingError::ConfigurationConflict; drain it with that build, then provision a new namespace or generation. No 12.x release contains this adapter. |
| Mail providers removed | rullst-mail keeps Resend (ResendDriver), AWS SES (AwsSesDriver), SendPulse (SendPulseDriver) and SMTP (SmtpDriver), the development drivers (LogDriver, MemoryDriver and the offline mock_* fixtures) and the shared failover, tenant resolver, pipeline, attachment inspection, suppression, observation, feedback and queue support. Removed: SendGridDriver, PostmarkDriver, MailjetDriver, MailtrapDriver and the Azure Communication Services transport (AzureCommunicationDriver, AzureMailCredential, AzureMailAccessToken, AzureManagedIdentity, StaticAzureMailCredential), their MAIL_DRIVER values sendgrid, postmark, mailjet, mailjet-sandbox, mailtrap, mailtrap-sandbox and azure-acs, and the settings only they read (SENDGRID_API_KEY, POSTMARK_SERVER_TOKEN, POSTMARK_API_KEY, POSTMARK_MESSAGE_STREAM, MAILJET_API_KEY, MAILJET_SECRET_KEY, MAILTRAP_API_TOKEN, MAILTRAP_SANDBOX_ID, AZURE_COMMUNICATION_EMAIL_ENDPOINT, and the managed-identity IDENTITY_ENDPOINT, IDENTITY_HEADER and AZURE_CLIENT_ID). Cargo features are unchanged. A MAIL_DRIVER or [mail] driver that names a removed driver now fails whenever the Mail facade resolves its driver (each send and each queued job) with MailError::ConfigError naming the driver and this row, whatever credentials are set; it never falls back to log, memory or an offline mock. Queued rullst_mail_send jobs written by 12.x name no provider: a 13 worker delivers them through the driver it is configured with, and a worker still set to a removed driver fails each job with that error, so the job stays failed until Queue::retry_failed_job after the driver is changed. Suppression rows and delivery observations keep their provider labels (for example sendgrid) and stay readable. Select a kept driver, or implement MailDriver in application code for another provider and install it with Mail::set_driver or TenantMailResolver, as described in Writing your own mail transport. These adapters stay in the 12.x line and in Git history. |
| Mail driver default | With neither MAIL_DRIVER nor [mail] driver set, the Mail facade no longer falls back to the metadata-only log driver in staging or production (RULLST_ENV or APP_ENV from the environment or .env, or [app] env, as Server resolves them); it returns MailError::ConfigError. Configure the real driver, or set MAIL_DRIVER=log explicitly. Development and test keep the fallback. |
| Mail sender | Resend, SES and SMTP no longer substitute [email protected] when a message has no from. Configure a default sender your provider account has verified with MAIL_FROM (or from in the [mail] section of Rullst.toml), for example MAIL_FROM="Acme <[email protected]>": Mail facade sends, including generated mailables, use it for messages without a from, while an explicit from still wins. With neither, real delivery returns MailError::ConfigError before any request, as the 12.1 REST transports already did. New projects list an empty MAIL_FROM and a commented MAIL_DRIVER in .env and .env.example; the facade now reads its MAIL_* and provider settings from .env after the process environment, like DATABASE_URL. Offline mock delivery is unchanged. |
| Mail attachment inspection | LocalAttachmentInspector classifies markup by its root element and namespaces, as browsers do, so XML whose root is a prefixed svg/html, follows a DOCTYPE or processing instruction, binds the SVG or XHTML namespace (also through character references) or declares DTD entities is now rejected as SVG (every policy) or active markup (strict()); UTF-16 markup is read by its byte-order mark. Review XML attachments that embed these namespaces or entity declarations. |
| Mail link checks | The pre-flight homograph check now resolves each link as a browser does (WHATWG URL parsing through the url crate): tabs and newlines are removed, \ counts as / for HTTP(S), https:host, https:\\host and scheme-relative \\host name a host, and percent-encoded or A-label (xn--) hosts are inspected in Unicode form. Messages whose links spell a mixed-script host this way, which were previously sent, now fail with MailError::SendError. Click tracking checks each destination as one link and no longer wraps one that contains whitespace or control characters, so the pipeline inspects (and may reject) the original href. |
| Mail facade settings | A MAIL_PORT that is not an integer from 1 to 65535 (for example 587000 or smtp) now makes MAIL_DRIVER=smtp facade delivery fail with MailError::ConfigError instead of silently connecting to port 25. An unset or empty MAIL_PORT still means 25. The facade now reuses the driver built from its settings across messages and queue jobs until a setting changes (or Mail::reset_driver() runs), so connection pools and the native SES client are shared; MAIL_DRIVER=memory is still rebuilt per message. |
| Mail queued attachments | Queued mail (Mail::send after Mail::init_queue, Mail::enqueue and Mail::enqueue_for_tenant) stores each attachment’s bytes in the rullst_mail_send job as one standard base64 string instead of a JSON array with one number per byte. A 13 worker (register_mail_handler) still reads jobs written by 12.x, but a 12.x worker fails every job with an attachment written by 13. In a rolling deployment, upgrade every process that runs the mail worker before any process that enqueues mail; once 13 workers run, retry jobs that a 12.x worker failed with Queue::retry_failed_job. Before rolling back to 12.x, stop 13 producers and let 13 workers drain the queue. Jobs without attachments are unchanged. |
| Mail SES addresses | Native and bearer-proxy SES requests now send 7-bit ASCII addresses, as SES requires: internationalized domains as IDNA A-labels and non-ASCII sender display names as RFC 2047 encoded-words, instead of verbatim Unicode that SES rejected with a permanent BadRequest. A non-ASCII local part now fails before the request with MailError::ValidationError. A trusted proxy receives the encoded FromEmailAddress and ToAddresses. |
| Mail Resend scheduling | A direct (non-queued) ResendDriver send scheduled more than 30 days ahead, Resend’s documented limit, now fails before the request with MailError::ConfigError instead of a permanent provider rejection. Schedule such mail through a durable Rullst queue (Mail::enqueue). The offline Resend fixture still accepts it. |
| Mail tracking recipient | Message::try_with_open_tracking/try_with_click_tracking (and their deprecated variants) now sign the bare recipient address, so OpenEvent.email and ClickEvent.email are [email protected] rather than Alice <[email protected]>. Handlers that parsed the display-name form can compare the address directly. |
| Mail plain-text fallback | The text part that Message::html derives when no text is set (and strip_html_to_plain_text) now appends each absolute HTTP(S) link target after its label as label <URL>, unless the label already is the URL, so text-only readers keep call-to-action links. Tests that compared the whole derived text of linked HTML need the new form; set text explicitly to control it. |
| Labs runner | The unpublished rullst-labs-runner candidate (Linux Rust-to-Wasm/Wasmi executor) was removed from the v13 workspace and is not published; its source remains in git history. rullst-labs APIs are unchanged and never execute learner code. Applications that evaluated the candidate deploy their own isolated runner against the controller contract; the non-executing byo_runner_controller example shows the controller flow. |
| Education candidates | These v13 crates changed during source evaluation, before their first publication in 13.0.0-alpha.1. rullst-supervision returns Capacity for an observation once one learner (tenant and subject) holds 4096 unexpired events across all sessions, or the store-wide event limit when lower; ended sessions keep their events until event retention ends. The new Limits::subject_events selects another bound. The rullst-media upload module accepts a grant expiring up to 3600 seconds plus 300 seconds of clock skew after the browser clock, instead of refusing a maximum-length grant when the browser clock is behind the server. Its SQLite store returns MediaError::Busy instead of Storage when a lock or connection wait exceeds 3 seconds; retry it with backoff rather than treating it as an integrity incident. rullst-labs adds the optional StoreConfig::tenant_exercises quota for stores shared by several tenants; without it, max_exercises stays a single store-wide budget. |
| Labs package | New in 13.0: rullst-labs is in the release inventory and was first published in 13.0.0-alpha.1 (previously publish = false). Depend on it directly; it has no umbrella feature and no default features. It packages exercise, authorization, job and grading contracts only and never executes learner code; applications still deploy their own runner against the controller contract. No API changed. |
| Media S3 storage | New in 13.0, opt-in: the rullst-media s3 feature adds s3::S3Storage for AWS S3, Cloudflare R2 and MinIO. It stores and serves the original object without transcoding: upload grants come from the new MediaService::upload_declared with an UploadDeclaration (presigned PUT), playback uses the new PlaybackKind::Original (presigned GET), and grants expire within 900 seconds. UploadGrant gains protocol (Bunny grants serialize "protocol":"Tus"), content_type and content_length; VideoProvider gains upload_declared, whose default returns Unsupported. Code that matches PlaybackKind exhaustively needs an Original arm; Bunny returns Unsupported for it. Experimental; validated against the offline mock only; provider interoperability not yet validated. See S3-compatible object storage. |
| Generated billing settings | New make:billing/SaaS controllers read every BILLING_* key with rullst::config::project_setting (process environment, then .env) instead of the process environment only, and an unreadable or malformed .env makes billing unavailable. Their production check now uses RullstConfig::global().environment(), so RULLST_ENV/APP_ENV from the .env a started Server read and [app].env count, and an invalid value is treated as production. Existing generated controllers are not rewritten; apply the same change by hand to read .env values. |
| Application templates | SaaS and make:billing add the report module; privacy generators remain explicit additions. Updating a dependency does not replace generated controllers or application customizations. |
| Starter migrations | New Blog, ERP and Portfolio seeds no longer call SQLite-only datetime('now'); seeds with explicit ids advance their PostgreSQL sequences; and SaaS, LMS, cargo rullst auth and SQLx make:billing create their indexed email, status, event-key and subscription-id columns as VARCHAR, with the LMS audit index ordered by id, so these migrations also run on PostgreSQL, MySQL and MariaDB. Already-applied migrations are not re-run: an existing PostgreSQL deployment that seeded rows should run SELECT setval(pg_get_serial_sequence('<table>', 'id'), (SELECT MAX(id) FROM <table>)) per seeded table. |
| ERP starter access | The dashboard at / now sits behind the same NexusAuthPolicy as Nexus and the inventory forms (loopback-only in debug, Basic Auth over verified TLS in release), because it lists customers and revenue. In existing ERP applications, move get("/" => …index) into the routes passed to admin_access.protect_router. |
| LMS lesson progress | The player renders a fresh random idempotency key and the controller scopes it to the requested percentage, so later saves no longer return 409. Keys are unique per learner (the replay lookup and the unique index cover (subject_user_id, event_key)), so one learner can no longer claim another learner’s key and block their saves. Existing LMS applications keep the fixed progress:{user}:{lesson}:next key until they copy the new new_progress_key/progress_event_key helpers; to scope keys per learner, add subject_user_id to the replay query and replace lesson_progress_events_key_unique with a unique index on (subject_user_id, event_key). |
| LMS concurrent progress saves | record_progress now claims the idempotency key by inserting the progress event as the transaction’s first write (its previous_percent comes from a subquery) and treats a unique violation as a replay, so a double-submitted save returns the stored receipt instead of 503. Existing applications must copy the service’s replay helper and record_progress. |
| LMS lesson media in Nexus | The Lesson Nexus fields media_url and captions_url are FieldKind::Text instead of Url, so lessons with the seeded same-origin /static/media/*.vtt captions can be edited again (the player still accepts only HTTPS or root-relative sources), and media_kind is an Enum of video/audio. A lesson the player cannot render now returns a logged 500 instead of a silent 503. Existing applications can make the same changes in src/models/lesson.rs and the learning controller. |
| Blog robots and sitemap | The Blog starter’s robots.txt and sitemap.xml use absolute URLs from RULLST_PUBLIC_ORIGIN (an HTTPS origin without a path, read from the process environment, then .env); the sitemap lists the home page and the 1,000 newest posts. Unset, robots.txt has no Sitemap: line and the sitemap is empty instead of advertising the relative / and /sitemap.xml that crawlers reject. New Blog projects list the variable in .env/.env.example. Existing applications must copy the controller’s public_origin, path_segment, robots_txt and sitemap_xml. |
| Generated login and registration | SaaS, LMS and cargo rullst auth controllers run Argon2id through at most four bounded permits (503 after a two-second wait) and export credential_rate_limit (ten submissions per peer per minute, then 429), which the SaaS/LMS routers apply to POST /login and /register. Behind a reverse proxy, configure [security] trusted_proxies. Existing applications must copy the controller and route changes. |
| SaaS user JSON | The SaaS starter’s User.password_hash is now #[orm(hidden)], as in the LMS starter, so to_json(), audit rows and post-commit Redis model events no longer carry the Argon2 hash. In an existing SaaS application, add #[orm(hidden)] to that field. |
| Registration timestamps | SaaS, LMS and cargo rullst auth registration now stores the current UTC time in created_at/updated_at; the generated User model reads them as chrono::DateTime<Utc> (see ORM date/time columns), and utc_timestamp(), User::change_password and password_reset::redeem take that type instead of YYYY-MM-DD HH:MM:SS text. Registration previously stored empty strings, which replaced the column default because the ORM inserts every field. Existing applications can copy utc_timestamp from a new controller; rows created earlier keep their empty timestamps until updated. |
| Billing row timestamps | SaaS and make:billing controllers now store the current UTC time in created_at/updated_at of the billing customer they create on a first checkout and of a new subscription (SQLx), instead of empty strings that replaced the column default because the ORM inserts every field. SQLx billing models read those columns as chrono::DateTime<Utc>, and the billing stores write CURRENT_TIMESTAMP in the column’s own type instead of casting it to text; Turso billing keeps text. Existing applications can copy utc_timestamp from a new controller. |
| Registration lengths | SaaS, LMS and cargo rullst auth registration count the 120-character name limit and the 12-character password minimum in UTF-16 code units, as the forms’ minlength/maxlength do, instead of UTF-8 bytes. The password maximum stays at the 72 bytes that rullst::auth::hash_password accepts, and the error message now says so. Long non-ASCII names are no longer rejected, while a non-ASCII password shorter than 12 characters (for example six Cyrillic letters, 12 bytes) is now refused. Existing sign-ins are unaffected. Existing applications can copy form_length and valid_password. |
| Revocable login sessions | Additive: rullst-auth adds make_login_cookie_with_epoch(user_id, epoch, max_age), encrypt_session_with_epoch, decrypt_session_claims, SessionClaims (verify_epoch), DEFAULT_SESSION_MAX_AGE, MAX_SESSION_MAX_AGE and AuthError::SessionRevoked. make_login_cookie cookies keep their format, 30-day lifetime and validity; decrypt_session rejects the new v2. tokens. New SaaS, LMS and cargo rullst auth projects store users.session_epoch (BIGINT NOT NULL DEFAULT 0, #[orm(db_default)] on the model), issue epoch cookies, refuse a cookie with another epoch in auth_middleware (the cargo rullst auth middleware now loads the user on every request) and mount POST /logout/everywhere, which increments the epoch. To adopt in an existing application, add the column with a migration and the #[orm(db_default)] pub session_epoch: i64 field, issue cookies with make_login_cookie_with_epoch, decode them with decrypt_session_claims and verify_epoch, and increment the column in one UPDATE for sign-out everywhere, password changes and resets, and deactivation. Existing cookies decode as epoch 0 and keep working until the first increment. make:age-gate and make:privacy accept only authentication files equal to this release’s SaaS starter, so a SaaS application generated by 13.0.0-alpha.1 must adopt the new auth_middleware.rs and auth_controller.rs first. See encrypted sessions. |
| Password change and reset | Additive: rullst-auth adds PasswordResetToken (generate, expose, digest), password_reset_token_digest and password_reset_digests_match. New SaaS, LMS and cargo rullst auth projects get src/controllers/password_controller.rs (/password/change behind auth_middleware, /password/forgot and /password/reset, every submission behind CSRF and credential_rate_limit), src/models/password_reset.rs, a password_reset_tokens migration, User::change_password, the forgot_password_page, reset_password_page and change_password_page views, a notice argument on login_page (shown on /login?reset=done) and the mail feature; the SaaS and LMS .env list RULLST_PUBLIC_ORIGIN. A change keeps only the asking browser signed in; a reset signs out every device and signs nobody in. To add the flows to an existing application that already uses session epochs, copy those three files and the migration (named after your users migration) from a new project of the same starter, add User::change_password to the user model, the three views and the login notice to src/pages/auth.rs, register the modules and routes as the new router does, make the auth_controller helpers that password_controller imports pub(crate), enable the mail feature, and set RULLST_PUBLIC_ORIGIN, MAIL_DRIVER and MAIL_FROM for staging and production. See Tutorial 11. |
| VPS deploy proxy | deploy --platform vps now attaches both services to an edge compose network pinned to 172.31.250.0/24, gives Caddy 172.31.250.10, and adds that address to [security] trusted_proxies in Rullst.toml (creating it if needed) before a missing Dockerfile is scaffolded. Clients behind Caddy no longer share one rate-limit bucket, such as the starters’ login/registration budget. Existing docker-compose.prod.yml files are kept: add the network and address yourself, or move the file aside and rerun the command. An existing trusted_proxies list is not changed. Fly.io, Railway and Render print a notice while no trusted proxy is configured. |
| Blank database status | The Blank starter’s public home page (HTML and JSON API, SQLx and Turso-primary) probes the database with a one-row User::query().first() instead of User::all(), which read up to 1,000 SQLx or 10,000 Turso rows per request. Existing applications can make the same one-line change. |
| Portfolio profile | The Portfolio page shows the first profile by id instead of Profile::find(1), returns 404 when none exists instead of a hard-coded placeholder person with 200, and returns a logged 503 when a profile, project, experience or skill query fails instead of empty sections. Repository-mode ProfileRepository::get now returns Result<Option<Profile>, rullst_orm::Error> and the other repositories’ all return Result<Vec<_>, _>. The seeded profile’s e-mail is [email protected]. Existing applications must copy the controller (and repositories) to adopt this. |
| Blog and Portfolio pages | index_page, detail_page and the Portfolio render take a csp_nonce argument from the request’s CspNonce; style attributes became classes, the pages use system fonts and the default avatar is /static/rullst.png, so styling survives the production CSP. Remote avatars need a same-origin copy or a reviewed img-src change. |
| Generated linker configuration | .cargo/config.toml (mold/lld selection of the generating host) is now listed in the generated .gitignore and .dockerignore. In an existing project, add .cargo/config.toml to both files or delete its -fuse-ld flags before building in CI or Docker. |
| Build files from the 12.0 CLI | Only projects generated by the 12.0 CLI; 12.1 generators no longer write these. .cargo/config.toml passes the unsupported MSVC linker flag /DEBUG:FASTLINK: delete that [target.x86_64-pc-windows-msvc] block. .gitignore lists /Cargo.lock: remove the line and commit the lockfile, so builds and deployments use the versions you tested. A Dockerfile written by --docker or dockerize runs cargo build --release: add --locked once the lockfile is committed. See Projects generated by the 12.0 CLI. |
| ERP orders and stock | store_order reserves stock with a conditional UPDATE ... WHERE stock >= ? and inserts the order in the same transaction; add_stock increments in SQL. Quantities outside 1–10,000, prices outside 0–1,000,000, stock outside 0–1,000,000 and names, SKUs or customer names over 255 characters (UTF-16 code units, as the forms’ maxlength counts them) return 422, an unknown product 404, insufficient stock 409 and database errors 503, instead of redirecting or rendering 0 Units. Existing ERP applications keep the read-modify-write handlers until they copy the new controller. |
| Blog and ERP reads | Blog index pages through posts newest first (?page=N, 20 per page) and show queries by slug; ERP index pages through products (?page=N, 50 per page), lists the 20 newest orders and computes revenue, order count and low-stock alerts in SQL. Neither depends on Model::all(), which stops at the ORM’s 1,000-row cap without an ORDER BY. Database errors now return 503 instead of an empty page or a 404. blog::index_page takes a PaginationResult<Post> and erp::dashboard_page a PaginationResult<Product> plus a DashboardSummary; existing applications keep their controllers until they copy the new ones. |
| Blog and ERP page bounds | The Blog index and the ERP dashboard serve ?page= 1–100,000 and answer a larger page with 404 instead of passing it to paginate, whose overflowing or out-of-range OFFSET was reported as a 503 database failure. Existing applications can add the same MAX_PAGE check. |
| Docker build context | The .dockerignore written by --docker/dockerize now also excludes .env.* (keeping .env.example), Foundry.toml, *.sqlite, *.sqlite3, *.duckdb and their journal files, so the development database and secret files no longer reach the builder stage, a remote builder or its cache. An existing .dockerignore is not rewritten: add these patterns to it. |
| Kubernetes Ingress | make:k8s now emits ingressClassName: nginx instead of the deprecated kubernetes.io/ingress.class annotation and a spec.tls section (<project>-tls Secret) for the placeholder host, so cert-manager issues a certificate. Previously generated Ingresses served the production app over plain HTTP; add a tls block for the real host to them. |
| Starter health probes | Every starter now merges rullst::health::health_router(), so the /health and /ready probes written by make:k8s, deploy (Fly, Railway, Render) and foundry:deploy return 200 instead of 404, which kept Kubernetes pods unready, restarted them and failed Fly releases. Existing applications should add .merge_axum(rullst::health::health_router()) (or the lifecycle-aware health_router_with_lifecycle) outside any authentication layer. |
| Kubernetes and Buildah names | make:k8s and the --buildah/generate:buildah script derive their object, container, Service and image names from the package name lowercased with each run of other characters replaced by - (at most 55 characters), so my_startup or MyApp no longer produce manifests and tags that Kubernetes and OCI registries reject. Manifests or scripts generated for a lowercase hyphenated name are unchanged; for other names, regenerate them or rename the objects and image. |
| Pre-compressed static assets | cargo rullst dev deletes static/**/*.br and *.zst siblings of html, css, js, json, svg, wasm, xml and txt assets when they are not newer than their source, at startup and before each rebuild, and no longer restarts the application when only such a sibling changes. Siblings without a source file are kept. Rerun cargo rullst build to regenerate them; outside dev, rerun it after editing assets so stale siblings are not shipped. |
| Static asset caching | Files served by the standard /static mount no longer fall back to the production baseline’s Cache-Control: no-store. 2xx and 304 responses for a content-hashed name (a ./--separated segment of 8–64 lowercase hex characters with a digit and a letter, such as app.3f9a2c1b.css) get public, max-age=31536000, immutable; other static files get no-cache and are revalidated with the ETag/Last-Modified that ServeDir already sent (304 on a match). Missing files and dynamic responses keep no-store. Give an asset a new hashed name whenever its content changes, and review proxy/CDN rules that relied on no-store for /static. See static assets. |
| Generated release profile | New projects get [profile.release] with lto = "thin", codegen-units = 1 and strip = "symbols"; panic stays unwind. Since 13.0.0-alpha.4 they also get a [profile.dev] that keeps line tables for the application only (Build times). Existing manifests are not edited: copy the sections to adopt them. Release builds take longer and release backtraces lose symbol names (use strip = "debuginfo" to keep them). Measurements: release defaults. |
| Generated container runtime | The Dockerfile written by --docker, dockerize or a first deploy now uses gcr.io/distroless/cc-debian12:nonroot for the runtime stage instead of debian:bookworm-slim: no shell, package manager or HEALTHCHECK, still UID/GID 10001 with HOME=/app and a writable /app/data. New projects only; an existing Dockerfile is never replaced. Probe /health over HTTP and run /app/<name> db:migrate directly instead of through a shell. |
Interactive new --api | Without --default, cargo rullst new <name> --api no longer asks for a blueprint or for the build type, whose Full-Stack default discarded the flag and generated an HTML/HTMX app; it generates the Blank JSON API profile. Scripts that answered those two prompts must drop the answers. |
CLI new wizard | In a terminal, cargo rullst new without --default runs a new wizard: a blueprint screen with descriptions and file-tree previews, a build-type screen (Blank only), one database list that includes No database for Blank (replacing the separate “need a database?” confirmation), one optional-features checklist (AI, Redis, Dockerfile, Nix flake and the storage add-ons, replacing the separate AI and Redis questions) and a review with Create the project, Back and Cancel; Esc goes back. An invalid project name given on the command line is refused before the first question. Scripts that typed answers into the old prompts must use flags instead. --blueprint, --database, --no-database, --ai and --redis no longer require --default and skip their questions (the cargo_rullst::cli::Cli parser used directly still requires it). Without an interactive terminal (or with CI/TERM=dumb) new without --default now fails immediately with a hint instead of failing at the first prompt; nothing is created. The How to run: block after creation became numbered Next steps ending on the welcome-page URL. New --dry-run prints the plan and writes nothing. Generated files are unchanged for the same choices. See the CLI reference. |
| Minimal default features | Breaking (13.0.0-alpha.3): rullst has no default feature (it had orm, drivers-all and queue-sqlite) and rullst-orm has none (it had drivers-all), so cargo add rullst compiles no SQLx driver. A dependency that relied on those defaults now builds without the ORM, a database driver or Core’s SQLite queue: code that uses rullst::db/rullst::orm stops compiling, and Orm::init of a driver-less rullst-orm returns Error::Validation naming the feature to enable. List what the application uses, for example rullst = { version = "...", features = ["strict-sqlite", "queue-sqlite"] } or rullst-orm = { version = "...", features = ["strict-postgres"] }; drivers-all keeps all three drivers. Projects generated by cargo rullst new already disable the defaults and list their features. cargo rullst upgrade reports such dependencies (V13-RULLST-DEFAULT-FEATURES, V13-ORM-DEFAULT-FEATURES). |
Generated rullst-orm dependency | Projects with a SQLite, PostgreSQL, MySQL or MariaDB primary now declare rullst-orm = { ..., default-features = false, features = ["strict-..."] }, so the ORM’s default drivers-all no longer adds the other SQLx drivers (and the bundled SQLite C library) to a single-backend build. Existing projects can add default-features = false to that line; code that relied on another driver through it must enable that driver explicitly. Since 13.0.0-alpha.3 rullst-orm has no default driver at all (see Minimal default features). |
| ORM adapter aggregate features | The orm-polyglot feature of rullst and the polyglot feature of rullst-orm were removed. They enabled every optional storage adapter at once, including DuckDB’s bundled C++ build, which made the first build much slower. Cargo now refuses a manifest that names them: replace orm-polyglot with the adapters you use (orm-turso, orm-mongodb, orm-duckdb, orm-surrealdb, orm-qdrant), or polyglot with turso, mongodb, duckdb, surrealdb or qdrant. Each adapter feature is unchanged, and --all-features builds are not affected. cargo rullst upgrade reports a dependency or [features] entry that still names them. |
| Omni and offline sync removed | 13.0 removes cargo rullst make:omni, cargo rullst omni (including omni android --release) and the native-only offline-sync feature with rullst::offline_sync; the Tauri shells exposed no native API, so an installed Progressive Web App gives the same result. Run cargo rullst make:pwa to make the application installable, add --android --host <host> --package-id <id> for a Google Play package (Trusted Web Activity built with Bubblewrap, with /.well-known/assetlinks.json from rullst::pwa::AssetLinks), and package the same site for the Microsoft Store with PWABuilder (PWA guide). An existing omni-app/ keeps building with its own Tauri toolchain but receives no further fixes; retire it once the PWA replaces it. Remove offline-sync from rullst/rullst-core features (Cargo refuses it) and queue browser writes in IndexedDB with idempotency keys, keeping the server authoritative. cargo rullst upgrade reports an Omni shell or a script that runs the removed commands (V13-OMNI-REMOVED) and a manifest that enables the feature (V13-OFFLINE-SYNC-REMOVED). |
| Starter page language | The Blank starter renders with render_page_with_lang(&htmx, "en", ...) and the ERP page declares <html lang="en">, matching their English copy; render_page and the earlier ERP page declared pt-BR. Existing applications can make the same one-line changes. |
| Repository examples | The blog showcase selects its database like Server and Artisan (DATABASE_URL, .env, then Rullst.toml, now sqlite://blog.db?mode=rwc) and should be started from examples/blog. Post uses fail-closed tenant_column with the request’s TenantContext, and /posts/repository lists only the selected tenant. Its Nexus registration now declares nexus_tenant_column, so /nexus lists, searches, creates and changes posts only in the request tenant (another tenant’s record is 404) and hides tenant_id, which a form can no longer submit (422). /pricing, /billing and /checkout read provider credential presence (process environment, then .env) once at start-up instead of re-reading .env on every request, so a credential added later appears after a restart. Stories are bounded (120/4,000 characters, 100 per tenant, 64 KiB forms; 422, 403 or 413), listings show the newest 20, startup seeding no longer deletes posts that resemble old seeds, and pages render under the unrelaxed production CSP with same-origin assets, and the /omni guide page was removed with Omni. The non-working Wasm island page, the Tauri 1.5 shell and the Dioxus omni-app were removed; use cargo rullst make:island/make:pwa. In the SaaS example, note bodies with control characters receive 422 instead of 503, and membership.py rejects tenants made only of dots. |
| Windows paths in CLI and console output | Generator refusals (make:k8s, dockerize, introspect and the other no-clobber outputs), audit IDOR, unsafe-code and listener findings (and the compliance report built from them) and the development panic console now name files with / separators on Windows as well (k8s/deployment.yaml, not k8s\deployment.yaml). Adjust scripts that matched the backslash spelling. |
| Consumer scaffolds under symlinked directories | make:age-gate and make:privacy now inspect only the entries below the project directory for symlinks, so a project reached through a symlinked parent (macOS /var → /private/var, a Windows junction) is accepted. A symlink inside the project is still refused, as is a planned path outside it. |
| Core HSTS default | From 13.0.0-alpha.2 the staging and production header baseline sends Strict-Transport-Security: max-age=31536000; includeSubDomains instead of max-age=63072000; includeSubDomains; preload, and rullst_security::SecureHeadersConfig::default() uses the same value. preload consents to the browsers’ preload list, so it is now opt-in: set [security] hsts_preload = true (with hsts_max_age_seconds of at least 31536000 and hsts_include_subdomains = true, or the server refuses to start) to keep sending it. hsts_max_age_seconds = 63072000 restores the two-year max-age. |
Artisan and .env | From 13.0.0-alpha.2 db:migrate, db:rollback, db:status, db:seed and studio (through rullst::artisan!, check_and_run_artisan or Server::run) no longer load .env into the process environment, matching Server. DATABASE_URL is still read from .env. A migration or seeder that called std::env::var for a value that only .env defines must call rullst::config::project_setting instead, which reads the process environment and then ./.env. |
| Core WAF in development | From 13.0.0-alpha.2 Server in development (and apply_security_baseline with Environment::Development) runs the WAF rules in report-only mode: no request is refused, but each one staging and production would refuse logs a warn event (target rullst::security::waf) with the rule and path, and textual request bodies are buffered (bodies over 1 MiB are replayed in full). The new [security] waf_body_exempt_paths exempts the bodies of exact paths from inspection in every environment. |
| CSRF token on static-looking paths | From 13.0.0-alpha.2 a GET or HEAD for a path such as /report.json or /static/page receives CsrfToken when the browser sends a valid rullst_csrf cookie; it still never mints a token or sets the cookie there. |
| Multipart CSRF refusal | From 13.0.0-alpha.2 a multipart/form-data request without a _token field the CSRF middleware can read (its first _token must end within the first 64 KiB, so before any file input) is still refused with 403, but the body is Missing CSRF token: a multipart/form-data form must send its _token field before any file field (the CSRF check reads only the first 64 KiB of the body), or send the X-CSRF-Token header instead of Invalid or missing CSRF token. A readable but wrong token keeps the old text. Update tests or clients that compare that body. Forms read with the new rullst::uploads::Multipart get the same refusal in development; see form uploads. |
| Secure cookies over HTTPS | From 13.0.0-alpha.2 the rullst_csrf cookie, and every rullst_* cookie (such as rullst_session) passing through Server’s security baseline, carries Secure in any environment when the request arrived over HTTPS: an https request URI, or X-Forwarded-Proto: https from a proxy listed in [security] trusted_proxies with trust_forwarded_proto = true. Staging and production already set it. A development or test deployment reached over HTTPS no longer needs RULLST_ENV=staging for this, and its browser then sends those cookies only over HTTPS. |
| CSRF cookie on cacheable responses | From 13.0.0-alpha.2 a GET or HEAD response that shared caches may store (its Cache-Control has public or s-maxage and neither private nor no-store) no longer sets the rullst_csrf cookie, so a cache cannot hand one visitor’s token to another. A page that renders a form must not be publicly cacheable; give it private or no cache policy. The new rullst::cache_control::public/private layers label dynamic routes, such as downloads and feeds, that may be cached. |
| Connect mock credentials | From 13.0.0-alpha.3, outside rullst-connect’s own unit tests, empty or mock_* provider credentials need the mock feature (oauth-mock on the rullst facade). Without it try_new, Connect::driver and OidcProvider::discover return ConnectError::InvalidConfiguration { field: "credentials", .. } instead of building a provider whose code exchange later failed with ConnectError::Offline, and the deprecated new constructors return a disabled provider (CredentialMode::Invalid, redirect about:blank). Set real credentials, build the provider only when they are configured, or enable the feature in development and test builds. |
| Connect mock authorization URL | From 13.0.0-alpha.3 a provider in mock mode returns the configured redirect_url with code=mock_authorization_code, the state and any PKCE challenge, instead of https://example.invalid/rullst-connect/mock?provider=..., which a browser could not open. Code that matched the old URL, or that redirected to its own callback to work around it, can follow the returned URL directly. |
| Account mail language and product name | From 13.0.0-alpha.3 the password controller generated for SaaS, LMS and cargo rullst auth projects writes account emails in the request’s language (the rullst::i18n Locale when the router has LOCALES.layer(), else Accept-Language, matched to English, Brazilian Portuguese or Spanish, else DEFAULT_MAIL_LOCALE) instead of always MailLocale::En, and names the product APP_NAME (environment, then .env) before the APPLICATION_NAME constant. Existing projects are not changed; to adopt it, pass a MailLocale from MailLocale::from_preference to AccountMail::new in send_account_mail. |
| Production log defaults | From 13.0.0-alpha.3, in staging and production Server no longer prints [HTTP] lines for /health and /ready requests that answer below 400 (failing probes still appear); set RULLST_LOG_HEALTH_PROBES=true (process environment or .env) to restore them, and note that an invalid value now stops startup. Without RUST_LOG, the default tracing filter is info,sqlx=warn instead of info, which drops SQLx’s own INFO events such as acquired connection; set RUST_LOG=info (or info,sqlx=info) to keep the previous output. |
| Connect GitHub email | From 13.0.0-alpha.3 GithubProvider also calls GET /user/emails after GET /user: ConnectUser::email is the address GitHub marks primary (which may differ from the public profile address) and email_verified is that entry’s verified flag (Some(true) or Some(false)) instead of always None. When the call fails (for example a token without the user:email scope) or lists no primary address, email stays the public profile address and email_verified stays None. Code that linked or created accounts on the GitHub email should now require email_verified == Some(true); transports that answered every GET with the /user body keep the old result. cargo rullst upgrade reports GithubProvider and Connect::driver("github", ...) (V13-CONNECT-GITHUB-EMAIL). |
| Account mail link lifetime | From 13.0.0-alpha.3 AccountEvent::PasswordReset accepts a remaining lifetime of 1–1440 minutes (24 hours) instead of 1–30; the verification and email-change links keep 1–30. Every account link now states whole hours as hours (“This link expires in 2 hours.”, “1 hora”) and one minute in the singular (“1 minute”); other values are unchanged (“30 minutes”). |
| Generated reset link lifetime | From 13.0.0-alpha.3 new SaaS, LMS and cargo rullst auth projects bound RESET_TOKEN_LIFETIME_MINUTES (src/models/password_reset.rs) to 10–1440 with a compile-time assertion, and the forgot-password answer states that value instead of a hard-coded “30 minutes”. In an alpha.2 project a value above 30 failed at send time (AccountMail refused it, so no email went out) and the answer still said 30 minutes: to change the lifetime, copy reset_requested_notice and lifetime_words from a new project’s password_controller.rs and the assertion from its password_reset.rs. |
| CLI deploy targets removed | Breaking, from 13.0.0-alpha.4: cargo rullst deploy no longer writes fly.toml, railway.json or render.yaml or runs flyctl/railway; --platform fly|railway|render fails before writing anything. It writes, when absent, the Dockerfile (with its dependency-cache stage), compose.yaml (the app and Caddy with automatic HTTPS; read-only root filesystems, every capability dropped except Caddy’s NET_BIND_SERVICE, no new privileges, a /health check; works with docker compose and podman compose) and the Caddyfile (it checks the app’s /health every 5 seconds and holds requests for up to 15 seconds while the app restarts), and trusts Caddy in Rullst.toml as deploy --platform vps did; --platform vps is still accepted. compose.yaml replaces docker-compose.prod.yml, which stays yours. It refuses a Cargo.toml package name that is not a valid Cargo name. Provider files you already have keep working with the provider’s CLI; other targets are in Advanced deployment. cargo rullst upgrade reports fly.toml, render.yaml, railway.json, railway.toml and scripts that run the removed targets (V13-DEPLOY-TARGETS-REMOVED). For library users, cargo_rullst::blueprints::deploy::{FLY_TOML, RAILWAY_JSON, RENDER_YAML, DOCKER_COMPOSE_PROD} were removed (COMPOSE_YAML is new). |
| SQLite database in the deployed image | From 13.0.0-alpha.4, the Dockerfile that cargo rullst deploy and cargo rullst dockerize write for a project whose Rullst.toml [database] url (or, without one, .env.example DATABASE_URL) is SQLite sets ENV DATABASE_URL=sqlite:///app/data/rullst.db?mode=rwc, as new --docker already did, so the database lives in the /app/data volume. Earlier dockerize and deploy --platform vps left it out: the starters’ relative sqlite://db.sqlite then pointed into the image’s read-only /app, and the app stopped at startup under a read-only root filesystem. An existing Dockerfile is kept, and deploy warns when it lacks the line: add it to the last stage, or set DATABASE_URL in .env.production. |
| CLI Foundry, Nix, Buildah and gRPC generators removed | Breaking, from 13.0.0-alpha.4: cargo rullst foundry:init, foundry:deploy, nixify, generate:buildah, make:grpc and the new --nix and new --buildah flags were removed, with the wizard’s “Nix flake” choice. Files they generated stay yours and are no longer regenerated; Advanced deployment shows Nix, Buildah, Podman Quadlet and Kubernetes setups, and gRPC code keeps working with your own tonic/tonic-build setup. cargo rullst upgrade reports Foundry.toml, a flake.nix written by nixify, build_buildah.sh, src/grpc/mod.rs and scripts that run these commands (V13-DEPLOY-GENERATORS-REMOVED). For library users, cargo_rullst::cli::Commands::{FoundryInit, FoundryDeploy, GenerateBuildah, Nixify, MakeGrpc} and the buildah/nix fields of Commands::New and ProjectScaffoldOptions were removed, with the generators::foundry and generators::grpc modules, generators::project::{generate_buildah_script, generate_nix_files} and the deprecated generators::project::create_new_project. |
| CLI self-update removed | Breaking, from 13.0.0-alpha.4: the CLI no longer downloads, verifies or replaces its own binary. cargo rullst update check, update verify, update stage and update install review|apply|recover were removed, and update guided lost --scope, --root, --allow-major and --prerelease (it now runs only the project flow, for a --to in the CLI’s major). Install or update the CLI with cargo install cargo-rullst --version =<version> --locked; the native executables stay attached to each GitHub release for manual verification. At most once a day, in an interactive terminal, the CLI prints that command when crates.io has a newer release of its major (a newer prerelease too when the CLI is a prerelease); RULLST_DISABLE_UPDATE_CHECK, CARGO_NET_OFFLINE, CI and non-terminal streams silence it. update project prepare|verify|review|apply|recover are unchanged. cargo rullst upgrade reports scripts and workflows that run the removed commands (V13-CLI-SELF-UPDATE-REMOVED). |
CLI ai removed, footprint renamed green | Breaking, from 13.0.0-alpha.4: cargo rullst ai (the terminal assistant of the earlier 13.0 prereleases, with ai connect, ai fix, ai review and ai upgrade) was removed; cargo rullst ai … now prints what replaces it and exits with status 1. Use your editor’s or terminal’s AI assistant with the project’s AGENTS.md and .llms.txt (cargo rullst generate:ai-context writes and checks them) and the published llms.txt. Keys saved by ai connect stay in ~/.config/rullst/credentials.toml ($XDG_CONFIG_HOME/rullst/, or %APPDATA%\rullst\ on Windows) and its git checkpoints under refs/rullst/ai-checkpoints/ until you delete them. The rullst-ai crate is unchanged. cargo rullst footprint is now cargo rullst green, an Experimental command of the same cargo-rullst package (no separate install): it reports only measured figures (binary and --image size, idle RSS, Cargo.lock dependency count and, with --bench, CPU time per request) and an SCI estimate labelled estimate from your --cpu-watts and --grid-intensity; it drops requests/s, latency, peak RSS and RAPL energy, and its JSON is rullst.green.v1 instead of rullst.cli-footprint.v1. cargo rullst upgrade reports scripts, workflows, Makefiles and justfiles that run rullst ai or rullst footprint (V13-CLI-AI-REMOVED). See the green guide. |
| Core error console assistant panel | From 13.0.0-alpha.4 the development error page’s “Fix with cargo rullst ai” panel (cargo rullst ai fix <error-id>) is an “Ask your AI assistant” panel: a read-only prompt to copy into your editor’s or terminal’s assistant, with the error message, its location, the first five project stack frames and the request method and path (no query string, headers, cookies or body), a copy button that only the page’s nonce-carrying script shows, and, when Server records the error, the path /_rullst/errors/{id} of its JSON context. The retired POST /_rullst/autofix still answers loopback peers 410 Gone with success: false and never edits files, but its message points to that panel and its command field is replaced by context_url: "/_rullst/errors/<error-id>"; a client that read command must read context_url. catch_panic_middleware records a panic only inside Server’s debug Development stack, which serves /_rullst/errors/{id}; the hot-reload router and an application’s own router no longer record panics that nothing serves. No 12.x API changes. |
| CLI starters moved to the examples | Breaking, from 13.0.0-alpha.4: cargo rullst new offers three starters: Blank, API (--blueprint api, the same as --api) and SaaS. The LMS, Blog, Portfolio and ERP blueprints were removed: the wizard no longer lists them or asks a separate web/API question, and --blueprint lms|blog|portfolio|erp fails before writing anything with a report naming the example and its GitHub URL (exit status 1). LMS, ERP and Portfolio are complete applications in the repository’s examples/lms, examples/erp and examples/portfolio, tested in its workspace; the blog showcase is examples/blog. Copy one to start from it. Applications generated earlier from these blueprints keep working: their code is theirs, so no upgrade rule applies. Library users of the cargo_rullst crate: the blueprints::{lms, blog, portfolio, erp} modules and LMS_BLUEPRINT_ID, BLOG_BLUEPRINT_ID, PORTFOLIO_BLUEPRINT_ID and ERP_BLUEPRINT_ID were removed (IDs 1, 3, 4 and 5 are never reused), and BlueprintChoice::id() became starter(), which returns the moved example (MovedBlueprint) as its error. |
The 12.0→12.1 guide still applies to applications that have not adopted the 12.1 account, payment and deployment changes. Skipping directly to 13 does not perform those application/database migrations.
html! returns Markup
cargo rullst upgrade reports V13-HTML-MARKUP-RETURN for a function that
declares a String (String, Html<String>, Result<String, _>) and
returns html!, and V13-HTML-RAW-MARKUP for RawHtml around html!
output. Before:
use rullst::html;
use rullst::html::RawHtml;
fn badge(label: &str) -> String {
html! { <span class="badge">{label}</span> }
}
fn rows(names: &[&str]) -> String {
let rows: String = names
.iter()
.map(|name| html! { <tr><td>{name}</td><td>{RawHtml(badge("new"))}</td></tr> })
.collect();
html! { <table>{RawHtml(rows)}</table> }
}
After:
#![allow(unused)]
fn main() {
use rullst::html;
use rullst::html::Markup;
fn badge(label: &str) -> Markup {
html! { <span class="badge">{label}</span> }
}
fn rows(names: &[&str]) -> Markup {
let rows: Markup = names
.iter()
.map(|name| html! { <tr><td>{name}</td><td>{badge("new")}</td></tr> })
.collect();
html! { <table>{rows}</table> }
}
assert_eq!(
rows(&["<Ana>"]),
"<table><tr><td><Ana></td><td><span class=\"badge\">new</span></td></tr></table>"
);
}
Keep RawHtml (or Markup::from_trusted) only for trusted HTML produced
outside html!, such as a sanitizer’s output, and never for request data.
Projects generated by the 12.0 CLI
The 12.0.0 and 12.1.2 CLIs generate the same Rust sources, Cargo.toml,
Rullst.toml and .env for the Blank and Blog starters, so the rows above and
their findings apply unchanged. Three generated files differ; cargo rullst upgrade reports each under the “Build files from the 12.0 CLI” row:
.cargo/config.tomlcontains a[target.x86_64-pc-windows-msvc]block whoserustflagspass the unsupported MSVC linker flag/DEBUG:FASTLINK(V13-MSVC-FASTLINK). Delete that block; 12.1 no longer generates it..gitignorelists/Cargo.lock(V13-CARGO-LOCK-IGNORED). Remove that line and commit the lockfile, so builds and deployments use the versions you tested. The upgrade itself works without a committed lockfile.- A Dockerfile written by
--dockerordockerizerunscargo build --release(V13-DOCKER-UNLOCKED-BUILD). Add--lockedonce the lockfile is committed.
The starter’s rullst-orm dependency relies on default features; since
13.0.0-alpha.3 rullst-orm has none, so after the upgrade it has no SQLx
driver until the backend is named. The V13-ORM-DEFAULT-FEATURES finding
gives the fix.
Upgrading an alpha.2 application to alpha.3
This checklist moves an application from 13.0.0-alpha.2 to 13.0.0-alpha.3. The rows above that name alpha.3, and the ORM database-managed columns, ORM dirty tracking and Omni rows, are the changes in between. The rehearsal on generated starters shows the expected result.
-
Prepare. Work on a branch with a clean tree and a committed
Cargo.lock, record the currentcargo testresult, and back up every database (check that the backup restores). -
Install the alpha.3 CLI.
cargo install cargo-rullst --version =13.0.0-alpha.3 --locked --force, thenrullst --versionmust print13.0.0-alpha.3. -
Name the features.
rullst(which enabledorm,drivers-allandqueue-sqlite) andrullst-orm(drivers-all) have no default features any more. A dependency that relied on them lists what the application uses, for examplerullst = { version = "=13.0.0-alpha.3", default-features = false, features = ["orm", "strict-sqlite", "queue-sqlite"] }(drivers-allkeeps the three SQLx drivers). Removeoffline-syncfromrullstandrullst-corefeatures. Projects generated bycargo rullst newalready disable the defaults and list their features. -
Inspect. Run
cargo rullst upgrade --dry-runand fix everyMUST-CHANGEfinding first (V13-OFFLINE-SYNC-REMOVEDis the one alpha.3 adds). These findings concern alpha.3 changes and usually need an edit:V13-ORM-SKIP-MANAGED-COLUMNS: replace#[orm(skip)]with#[orm(db_default)]onUser::is_adminandUser::session_epoch(SaaS, LMS andcargo rullst authmodels generated by alpha.2) and on any other column the database manages. No schema change is needed.V13-ORM-TRANSACTION-ERRORS:Orm::transactionreturns its closure’s own error type, so match that error instead ofDatabaseError. A closure whose error type lacksFrom<rullst_orm::Error>(such asString) no longer compiles, whichcargo checkreports; returnrullst_orm::Erroror an application error that wraps it.V13-RULLST-DEFAULT-FEATURESandV13-ORM-DEFAULT-FEATURES: step 3.V13-CONNECT-GITHUB-EMAIL: the GitHub provider now returns the primary address and GitHub’s verified flag; link or create accounts on it only whenemail_verified == Some(true).V13-OMNI-REMOVEDandV13-OFFLINE-SYNC-REMOVED: replace the Omni shell withcargo rullst make:pwa, as the Omni row describes.
The plan reads the application’s version from its manifests and
Cargo.lockand applies only the rules introduced after 13.0.0-alpha.2, which are the ones above; itsRules:line says how many earlier rules it skipped. If it names 13.0.0-alpha.1 instead, a requirement still has the caret form of the alpha.1 CLI ("13.0.0-alpha.1"), and the alpha.2 rules are listed too.--all-rulesalso lists the rules for the 12.x to 13 changes that alpha.2 already contained, such asV13-REFERRER-NO-REFERRER,V13-MAIL-FACADE-CONFIG,V13-LMS-RECORD-PROGRESSandV13-HOT-RELOAD-MACHINE-ENDPOINTS; an alpha.2 application already runs with those changes, so they need no edit for this step. -
Apply.
cargo rullst upgradepins every Rullst dependency, including directrullst-auth,rullst-securityorrullst-connectlines, to=13.0.0-alpha.3, runscargo fixandcargo check --locked, and restores the files if a step fails (--keep-on-failurekeeps the failed state for diagnosis). Make the edits from step 4, then update the dependencies of the changelog’s Dependency security entries that the lockfile contains:cargo update -p ammonia -p rustls-webpki -p webpki-roots -p webpki-root-certs. -
Read the alpha.3 rows that no rule locates and adjust the application where it depends on them: ORM transactional migrations and the pool (no
Orm::pool()inside awithin_transaction()migration), Connect mock credentials and Connect mock authorization URL (mock credentials need themockfeature,oauth-mockon the facade), Production log defaults (health probes are no longer logged, SQLx INFO events are hidden, and an invalidRULLST_LOG_HEALTH_PROBESstops startup), and the opt-in ORM dirty tracking, Nexus time zone (Nexus::with_time_zone, in the Nexus dates and times row) and Account mail language and product name. Workarounds for alpha.2 defects that the changelog’s “Fixes from alpha.2 dogfooding” lists, such as raw SQL to filter onis_admin, can then go. -
Verify with the production feature set:
cargo build,cargo testandcargo clippy --all-targets -- -D warnings;db:migrateagainst a restored copy of the production database; then sign-in, “Sign out everywhere”, Nexus access for anis_adminaccount, each configured provider and the health probes on staging before production.
Upgrading an alpha.3 application to alpha.4
This checklist moves an application from 13.0.0-alpha.3 to 13.0.0-alpha.4. alpha.4 changes the CLI and the files it generates, not the APIs of the framework crates: the rows above that name alpha.4 are the changes in between.
-
Prepare as for alpha.3: a branch with a clean tree and a committed
Cargo.lock, the currentcargo testresult and a database backup. -
Install the alpha.4 CLI.
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked --force, thenrullst --versionmust print13.0.0-alpha.4.cargo rullst green(the formerfootprint) is part of this package; nothing else to install.cargo rullst aiwas removed: use your editor’s or terminal’s AI assistant with the project’sAGENTS.md(cargo rullst generate:ai-context) and the publishedllms.txt. -
Inspect.
cargo rullst upgrade --dry-runapplies only the rules introduced after 13.0.0-alpha.3. All four areREVIEWfindings about files and scripts, not Rust code:V13-DEPLOY-TARGETS-REMOVED:fly.toml,render.yaml,railway.jsonorrailway.toml, or a script that runsdeploy --platformwith Fly.io, Railway or Render.V13-DEPLOY-GENERATORS-REMOVED:Foundry.toml, anixifyflake.nix,build_buildah.sh,src/grpc/mod.rs, or a script that runs a removed generator.V13-CLI-SELF-UPDATE-REMOVED: a script that runsupdate check|verify|stage|installorupdate guided --scope|--root.V13-CLI-AI-REMOVED: a script that runs the removedrullst aiorrullst footprint(nowgreen).
Generated files stay yours and keep working with their own tools; keep or delete them. Scripts that run a removed command need the replacement the finding names.
-
Apply.
cargo rullst upgradepins every Rullst dependency to=13.0.0-alpha.4, runscargo fixandcargo check --locked, and restores the files if a step fails. -
Optionally copy the
[profile.dev]sections that new projects get (Build times). A SaaS application whose code does not importrullst_connectcan remove that dependency. -
Verify with the production feature set:
cargo build,cargo testandcargo clippy --all-targets -- -D warnings.
Why the candidate uses a new major
Adding optional privacy APIs, a new crate or more features does not by itself require a major version. A fully compatible increment would belong in a minor release under the compatibility policy.
The current candidate does change a supported CLI/configuration contract:
13.0 removes commands and public items that 12.x applications can use (the
rows marked Breaking above list them), for example
cargo rullst make:omni and cargo rullst omni, the offline-sync feature
with rullst::offline_sync, and the Commands::MakeOmni/Commands::Omni
variants and OmniPlatformChoice of the cargo-rullst library. This
intentional change supports retaining v13 for the synchronized release train;
it does not mean every application’s Rust code needs rewriting. The Omni row
above documents the replacement.
A green version-aware SemVer job alone cannot establish that this candidate
could be renamed to 12.2. The current command derives the release type from the
manifest; the 12.1-to-13 comparison permits major changes. The hosted comparison
at 4773171a reported 0 executed checks and 254 skipped checks for compared
libraries. Treat that as no Rust API compatibility result, not as proof that
breaking changes are absent. Any reconsideration as a minor release requires
an explicit minor-level API comparison plus review and regression tests for
CLI, configuration, serialized contracts and generated consumers. Resolve the
known Android compatibility change before making that claim.
Use the target-major CLI
A v12 CLI cannot learn v13 migration rules just by downloading another binary: install the v13 CLI, then start a new invocation of that v13 CLI for the project migration.
To evaluate the published prerelease, install its CLI from crates.io. Cargo
never selects a prerelease on its own (cargo install cargo-rullst installs
the end-of-life 12.x CLI), so name the exact version:
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked
rullst --version
The output must identify 13.0.0-alpha.4. The projects it generates pin the
framework packages with =13.0.0-alpha.4, as described at the top of this
guide. Reinstall with --force to go back to 12.x (--version '^12') or to
move to a later prerelease.
To evaluate unreleased main changes instead, build the CLI from the reviewed
checkout:
cargo build --locked -p cargo-rullst
./target/debug/rullst --version
Use that explicit executable path instead of assuming a rullst already on
PATH is the same binary. This is a local source build, not authenticated
published distribution evidence. Generated projects then refer to the matching
local framework packages; keep that checkout available and review those paths.
The final package rehearsal must independently prove registry-based consumers.
Prepare, verify, review and apply
Keep a restorable application and database backup. Quiesce writers during file
application/recovery and review the application’s ignored files, toolchain and
Cargo configuration. The Git working directory’s tracked and non-ignored files
are copied into private preparation storage; the root Cargo.lock is included
even if ignored. Typical ignored secret files and build outputs are excluded.
Using the selected v13 executable, the structured sequence is:
rullst update project prepare --project /path/to/app --to 13.0.0-alpha.4 --json
rullst update project verify --prepared PREPARED_DIRECTORY --dry-run --json
rullst update project verify --prepared PREPARED_DIRECTORY --allow-project-code --json
rullst update project review --verified VERIFIED_DIRECTORY --json
rullst update project apply --verified VERIFIED_DIRECTORY --approved-review REVIEW_SHA256 --json
Use the directories returned by the preceding commands. Inspect the complete
review before supplying its digest. Verification executes trusted build scripts,
procedural macros and tests, and is offline by default; the private copy is not
a sandbox. Supply --allow-network only after reviewing that separate need.
Select the application’s actual feature policy (--features, --all-features
or --no-default-features) instead of assuming default features cover production.
Preparation accepts supported versioned dependency declarations and preserves their TOML structure. Unversioned path/git dependencies, ambiguous ranges, unsupported source majors, stale inputs and unresolved source findings require manual review. Verification requires every managed framework package in the candidate lockfile to resolve to the selected exact target. It runs locked workspace checks and tests before review can authorize manifest/lockfile edits.
After application, source-file recovery uses the same reviewed operation:
rullst update project recover --verified VERIFIED_DIRECTORY --approved-review REVIEW_SHA256 --json
Recovery rejects later conflicting edits. It restores the recorded manifest and lockfile states; it cannot undo database writes, network calls, provider effects or other external effects of application tests. It does not deploy either state.
Privacy remains an application decision
The preview privacy guide documents the explicit local source selection and authenticated SaaS installation contracts. Do not enable an age restriction merely because the framework offers one. Select a server-owned policy for the actual action and accept declarations only where that policy permits their assurance level. Production rejects unsupported stronger methods and process-local replay storage.
Consent-store files, replay databases, encryption/signing keys, subject/tenant
binding and retention/restore procedures need their own deployment plan.
Shared-local SQLite consent is not a multi-host consensus store; the optional
consent-postgres store shares consent through one authoritative PostgreSQL
database. The profile
export remains available independently of optional-consent configuration, but it
only exports the authenticated account fields supported by its adapter.
Evidence and remaining acceptance
update_project_cli exercises distinct 12.1.0 and 13 packages through real Cargo
resolution, prepare/verify/review/apply/recover, changed inputs and wrong approval
digests. Those intentionally tiny packages prove the updater protocol, not the
framework’s runtime API compatibility. The separate generated-starter suites
compile actual framework source and execute application/authentication tests;
the age/privacy suites also exercise both opt-in installation orders.
Final release admission still requires the combined exact-commit workspace and feature/consumer matrix, strict Clippy and formatting, package/native artifact rehearsals, the required security/fuzz workflows, and this inventory updated for all admitted changes. Application sandbox/provider journeys and deployment smoke tests remain distinct from framework CI. See the SST and delivery plan.