A readable, per-version log of what changed in Suprnova. Each version
section is that version's release record. A version is released when its
version commit and matching v<version> tag are pushed atomically. Newest first.
1.3.7 - 2026-08-26
Added
- Where the Inertia error page middleware sits is now yours to choose, and documented.
Inertia::installregistersInertiaErrorPageMiddlewareinnermost of the Inertia layer, so it covers the handler, the route middleware, and everything you register after that call - which is why the scaffold putsCsrfMiddlewarebelow it. It does not cover anything registered above the call, because a middleware that answers without callingnexthands its response to nothing registered inside it. The case that bites is a lapsed session posting a form:CsrfMiddlewareregistered above the install answers419with{"message":"CSRF token mismatch."}and the user gets the Inertia crash modal on the one flow they are most likely to hit; an outer rate limiter's429and an auth guard's401are the same. Registering the middleware yourself, further out, already worked in 1.3.6 - the type was public and registration is idempotent per type, so an earlier registration kept its place - but nothing said so and nothing ininstallacknowledged it, which made it an accident rather than a contract. It is a contract now: registerInertiaErrorPageMiddleware::new("Error")afterSessionMiddlewareandLocaleMiddlewareand before the middleware whose rejections it should cover, andinstallchecks for it, logs atdebug, and skips its own. The component you named at that registration is the one rendered, so you name the page once and.error_page(...)on the config becomes optional - it is still what makesinstallregister a middleware for an app that does not place one itself. The two ordering rules are documented on the type and in the manual.
Fixed
- An SSR page has one
<title>, and it is the page's own. The HTML shell wrote itsdefault_titleand then the SSR worker's head verbatim, so every page rendering a title through Inertia'sHeadcomponent produced a document with two<title>elements and the framework's generic one first. First is the one the browser tab, the crawler and the link preview read, so the real title never showed. A worker head carrying a title now replaces the shell's title rather than joining it - bothdefault_titleand a per-responseInertiaResponse::title(...)stand down; a head without one leaves the shell's title exactly where it was. - The document declares the language it is written in. The shell hardcoded
<html lang="en">, so a reader switched to Japanese got Japanese prose in a document claiming to be English - a screen reader picks its voice from that attribute and a search engine takes it as the page's language signal. It now carries the locale in effect for the request: whatLocaleMiddlewaredetected, then aLang::set_localeoverride, then the configuredAPP_LOCALE, in the same BCP 47 formLocalerenders (pt-BR,zh-Hans). This holds for the error page too, which is rendered on the way out and was the case that surfaced it. Without thelocalizationfeature the shell keepsen.
Upgrading
- Nothing is required. Both fixes apply to every Inertia app on upgrade, and
Inertia::installbehaves exactly as it did for an app that does not register the error-page middleware itself. - An app that spliced
<html lang="...">into the finished document with a middleware of its own can delete it - the shell does it now, from the same locale that middleware was reading. - An app whose
CsrfMiddleware, rate limiter, or auth guard is registered beforeInertia::installshould registerInertiaErrorPageMiddleware::new("Error")afterLocaleMiddlewareand before that middleware, so its rejections render the error page instead of reaching the client as raw JSON.installthen skips adding its own, and the component you named at the registration is the one rendered, so.error_page("Error")on the config is optional - keep it or drop it. The scaffoldedbootstrap.rsregisters CSRF after the install, so a project generated bysuprnova newneeds no change. - An app that renders its own
<title>through Inertia'sHeadcomponent under SSR will see the shell's title stop appearing in the document - bothInertiaConfig::default_titleand a per-responseInertiaResponse::title(...). That is the fix: the page's own title is the document's only one. If you were relying on the shell's title as a prefix or suffix, move it into theHeadcomponent where the rest of the title lives.
1.3.6 - 2026-08-26
Added
- Framework errors can render your own Inertia page instead of the client's crash modal. A user without a permission clicked a nav link into a guarded route and got Inertia's "All Inertia requests must receive a valid Inertia response, however a plain JSON response was received" screen: the
403carried the framework's JSON error body and noX-Inertiaheader, so the client refused it. The same held for an unrouted404, a rate-limited429, and a failing handler's500. Name a page component withInertiaConfig::error_page("Error")and those responses render that page at their original status, withstatus,message, and - when the error carried one -request_idprops. Every header the error response set survives the swap except the ones that only described the body being replaced (Content-*,Transfer-Encoding) or governed how it could be stored (Cache-Control,Expires,Age,ETag,Last-Modified), soRetry-Afteron a429,WWW-Authenticateon a401,Vary, andSet-Cookieall still reach the client. The page setsCache-Control: no-cache, privatefor itself: it carries your shared props, so it must never be stored by a shared cache and served to a different visitor, whatever the response it replaced permitted. An Inertia visit gets the JSON page object; a hard navigation gets the full HTML shell, so pasting the URL into the address bar works too. Everything with an owner is left alone: validation422s still redirect back to the form,X-Inertia-Locationbounces and responses that already are Inertia pages pass through, and a client whoseAcceptprefers JSON keeps the exact body it got before.suprnova newscaffoldsfrontend/src/pages/Error.*and sets.error_page("Error"), so new projects are covered without doing anything.
Fixed
- A local disk no longer refuses a legitimate path because another task touched it. The path guard resolved each component of a path with two probes and combined them into one verdict, so ordinary concurrent activity could be read as a symlink escape: a component that
canonicalizehad just reported missing, and that another task then created as an ordinary file, came back asPermissionDeniednaming a symlink that was never there. It bit hardest where writers contend by design - a losingwrite_with(..).if_not_exists(true)racer got that refusal instead ofConditionNotMatchwhenever the winner published the key between the two probes, which under a loaded test suite was roughly a third of runs. Each component is now classified from a single pass,symlink_metadatafirst: nothing there is free space, an ordinary file or directory is resolved and confined as before, and only a symlink that still cannot be resolved is refused. A component that vanishes mid-classification is looked at once more rather than refused. Every symlink refusal is unchanged.
Upgrading
- Nothing changes for an existing app until it opts in.
InertiaConfig::error_pagedefaults toNone, andInertia::installregisters the error-page middleware only when a component is named, so error responses keep their exact bodies. To adopt it, add a page component namedErrorbeside your others (it receivesstatus,message, and an optionalrequest_id) and chain.error_page("Error")onto theInertiaConfigyou pass toInertia::install. A handler that panics stays out of scope: the panic net wraps the whole middleware chain, so its synthesized500is built after every middleware has unwound. ReturnErr(...)rather than panicking and the error page covers it. Note that the gate is the body's shape, not its author: at an error status, an empty body, a JSON object whosemessageis a string, and the router's own404 Not Foundtext are rewritten no matter which middleware built them, and onlymessageandrequest_idsurvive into the props. A response that must keep its own JSON body should key its text as something other thanmessage, or setX-Inertia: trueon itself. And registerLocaleMiddlewarebeforeInertia::install: the error page is rendered on the way out, after every middleware registered inside the Inertia layer has returned, so a locale scope opened inside it is already gone and every error page would render in the app's default locale. The scaffoldedbootstrap.rsnow does this, and the same reasoning applies to any request-scoped middleware of your own whose state the page's shared props read.
1.3.5 - 2026-08-26
Changed
- Every changelog section reads in all six manual translations. The de, es, fr, ja, pt-BR and zh-Hans manuals used to carry the 1.3.0 to 1.3.2 sections in English behind a translator's note, and older sections with stray English lines; every section from 1.3.5 back to 0.1.0 is now translated, and the notes are gone.
Fixed
-
Local-filesystem disks publish every object in one step.
Storage::register_fsandregister_fs_withnow stagedisk.write(...),disk.writer(...), anddisk.copy(...)as a temp file under<root>/.suprnova-atomic/and publish it onto the target with a singlerename(2), so none of them is ever observable at a partial length. Before this, the driver opened the target withcreate + truncateand streamed into it in place: a concurrent reader got an empty or half-written object for the whole duration of the write, and a crash mid-write left a truncated object at the live path.abort()on a writer now discards the staged file instead of failing withUnsupported. -
write_with(..).if_not_exists(true)is a true exclusive create on a local disk. It is published withlink(2), which fails atomically in the kernel when the target exists, so exactly one of any number of racing callers succeeds and every other one getsConditionNotMatchhaving written nothing. A staged write published by a plain rename would have degraded the condition to a check followed by an overwrite, silently discarding all but the last writer - which is the opposite of what the primitive is reached for. -
An
appendthat creates the object is still an append. Appends are the one in-place operation on a local disk, and that now holds for the first one too, so two writers appending to the same missing object both land instead of one staging its own copy and overwriting the other. -
suprnova serveno longer rebuilds a project nobody has touched, and neither doessuprnova generate-types --watch. Both watchers classified a filesystem event by its path alone, and the generator reads every.rsfile under the samesrc/tree they are watching - so on Linux, where the kernel reports those reads, each regeneration scheduled the next one. A freshly scaffolded project regenerated its types and restarted its backend every half second, forever, without a single source edit. Only events that mean the bytes on disk actually changed count now.generate-types --watchalso had no debounce at all, so it acted on the first file of a burst rather than the last; it now sharesserve's 500 ms trailing edge, and both watchers share one implementation so the next fix cannot land in only one of them. The generator compares before it writes, so a regeneration whose output is byte-identical leaves the file, and its mtime, alone. -
The backend watcher is scoped to the paths the server is built from.
cargo watchran with no-w, so it watched the whole non-gitignored project: saving a Svelte component, or regeneratingfrontend/src/types/inertia-props.ts, rebuilt the framework and restarted the server. It now watchessrc/,cmd/,Cargo.toml,Cargo.lock,.env, andlang/- the build inputs plus the two trees read once at boot - each included only when it exists, since cargo-watch refuses a-wpath that does not.cmd/is where the full-stack scaffold keeps the server binary'smain.rs. The invocation also passes--no-vcs-ignores, because cargo-watch applies.gitignoreto explicitly named-wroots and the scaffold ignores.env, which would otherwise leave-w .envwatching nothing;-whas already narrowed the surface, so the flag cannot widen it. Frontend edits and generated.tsfiles no longer restart the backend. -
serde_json::Valuegenerates asJsonValueinstead ofunknown. It used to degrade tounknownand warn that it "isn't a struct this project defines", advice that is wrong for a JSON document - and the scaffold's own login and register pages tripped it twice on every regeneration, so every fresh project warned out of the box. It now emits a recursiveJsonValuealias, declared once at the top of the generated file and only when something references it. A bareValuemaps there too, unless the project defines aValuestruct of its own. -
Neither
generate-typesnorservereports a file it did not write as generated. Because a pass now writes only when the emitted content differs,Generated <path>was a claim about the filesystem that was false on every rerun of an unchanged project.generate-typessays<path> is up to dateinstead, in one-shot and--watchalike, andserve's startup pass saysN type(s) up to date → <path>, keeping the count.serve's file watcher now stays silent on a regeneration that wrote nothing, in text and under--jsonboth: atypes_regeneratedevent means the generated file on disk is different now, so silence after a save tells you your edit did not change any prop shape.
Upgrading
.suprnova-atomicis reserved at the root of every local disk. The staging directory has to live inside the root - a sibling of the root can be on a different filesystem when the root is a mount point, and every rename would fail withEXDEV- so the name is reserved rather than merely conventional. Any path whose first component is.suprnova-atomicis now refused with a permission error (read, write, delete, stat, list alike), as is any path that resolves into the directory through a symlink, and the entry is filtered out offiles,directories,all_files, andall_directories. If a disk root already contains a.suprnova-atomicentry of your own, it is no longer reachable through that disk: move it aside before upgrading. A regular file of that name is refused at registration with a message saying so, rather than failing later inside the driver. The name is exported assuprnova::ATOMIC_STAGING_DIRso backup and sync tooling can exclude it.- Publishing by rename replaces the target's inode. Rewriting an object on a local disk no longer preserves its mode, owner, or hard links, and a reader holding an open descriptor keeps the old content instead of seeing the new bytes. That is the standard cost of atomic publishing, but it is a behavior change if you were relying on either.
- A conditional write needs a filesystem with hard links.
if_not_existsis published withlink(2), which is unsupported on FAT, exFAT, and some network filesystems. There it fails outright rather than falling back to a check followed by an overwrite, because a fallback would hand you an exclusivity guarantee that does not hold. Nothing else on the disk is affected. - A first
appendthat fails leaves an empty object. An append is the one operation that is not published in a single step, so the object is created before the bytes land; a failed or aborted first append leaves it behind, exactly as an append onto an existing object always has. - A dangling symlink in the disk root is refused, not overwritten. A path whose symlink target does not exist can no longer be written, appended to, copied onto, moved onto, or deleted through the disk.
1.3.4replaced such a link with a regular file; the guard cannot prove where an unresolvable link leads, and creating through one creates the link's target anywhere on the host, so it now refuses. Remove the link outside the disk if you meant to write there. - Nothing sweeps the staging directory. It holds in-flight temp files plus whatever a process that died mid-publish left behind, so a host in a crash loop grows it without bound. Emptying it while nothing is writing to the disk is safe; excluding it from backups is recommended.
1.3.4 - 2026-08-25
Added
-
Read-through disks take a
copyflag and resolvecopy/renameacross the fallback. Setcopy: falseonReadThroughConfigto serve fallback hits without writing them through, which turns the disk into a transparent overlay and narrows each fetch to the range you asked for.copyandrenamenow stream a source that lives only on the fallback across to the primary destination; arenamealso deletes the fallback source, so a later read cannot resurrect the moved object. Conditions carry across that streaming path:if_not_existsstill refuses an existing destination, a copy's source version selects which object the fallback hands over, and a copy'sif_matchis refused withUnsupportedrather than silently dropped. A transfer that fails partway removes only a destination it created, so it cannot destroy an object that was already there. -
Debounced jobs and debounced queued listeners.
Job::debounce_for()collapses a burst of dispatches into one run, one window after the most recent one, carrying the newest payload. It is the mirror ofpush_unique, which keeps the first dispatch and suppresses the rest.Job::max_debounce_wait()stops a continuous burst from deferring the work forever, andJob::debounce_id(&self)scopes the window per entity so twenty updates to one order collapse without touching another order's.Queue::push_debounced(job, DebounceOptions)sets the window at the call site, andDebouncedListener::new(window, build).keyed_by(...)debounces an event listener with the key derived from the event - a plainQueuedListeneralready honors a window the job itself declares. Every dispatch is still enqueued; the collapse is settled at the worker, which acknowledges a superseded envelope and emitsJobDebounced. Debouncing fails open: an expired or evicted window runs the job rather than dropping it. Each actual run starts a fresh maximum-wait window, so a burst always measures its maximum wait from its own first dispatch rather than inheriting the previous burst's. A job cannot declare bothdebounce_forandunique_id, and chains and batches refuse a debounced job - a superseded link would strand the rest of its chain, and a superseded batch job would leave the batch's pending count above zero forever. The envelope carries two additive fields for this and stays byte-identical on the wire for every non-debounced push. -
Storage::register_read_throughcomposes two disks into a read-through disk. Reads and metadata resolve against the primary first and fall back to the second disk; anything found on the fallback is written through to the primary, so a store migration completes under real traffic. Writes and listings stay on the primary, and a delete removes the object from both disks. Setthrow_on_promotion_failurewhen a failed promotion must surface instead of degrading to a fallback read. A promotion is published atomically, so no reader can see a half-written object, and it carries the fallback object's content type, cache control, content disposition, content encoding, and user metadata across. A versioned or conditional read is passed through with its condition intact and served without being promoted. -
Queue::forwardredirects a whole queue by name. WhereQueue::routeis keyed by job type,Queue::forward("default", "high")is keyed by queue name - the lever for retiring a pool, absorbing a backlog, or moving work off a pool you are about to take down, without touching a single job or route. It applies on both sides: new pushes that resolved todefaultland onhigh, and a worker started with--queue=defaultdrainshigh, so the destination cannot collect work nobody claims. Forwardingdefaultcatches jobs that named no queue. A forward is a single lookup, never a chain, so a swap (a -> bwithb -> aalso registered) or a longer rotation is a coherent pool exchange rather than a loop - exactly like Laravel, whose resolver is the same single lookup. Pausing is still evaluated on the names a worker was started with, soQueue::pause(&connection, "default")stops that worker even whiledefaultis forwarded.Queue::forward_on(from, to, connection)restricts a forward to one connection name, compared against this process's connection name rather than a job's declared connection, so both halves of the redirect gate on the same value.Queue::forward_for(from)reads a forward back, andQueue::try_forwardis the fallible sibling. The inspection calls (Queue::pending_jobsand its siblings) deliberately do not follow a forward, so a backlog left behind on a forwarded queue stays visible. -
Read-shaped Redis commands retry a transient failure instead of surfacing it. The connection manager already reconnected in the background, but the command that hit the dead socket still failed your call.
GET,EXISTS, theSCANandSSCANpages behindCache::flush/Cache::flush_tags, the queue driver'sXLEN/ZCARD/XPENDINGreads, and the rate limiter'sRetry-Aftercomputation now retry once after a short pause.REDIS_COMMAND_RETRIESadds further retries on top, clamped at 10. Budget the retry in seconds rather than milliseconds: the second attempt waits for the replacement connection, so it costs the driver's whole connect and response budget, and a timed-out command counts as transient as well as a dropped one. Writes never retry at any setting: a transient error means the connection failed, not that the server refused the command, so repeating aSET, anINCR, a lock acquisition, a rate-limit hit, or a queue pop could run it twice. Error messages are unchanged, so anything matching on them keeps working. -
A paused worker now tells you it is paused.
queue:workprints one line per transition -2026-08-25 14:03:11 Queue billing PAUSED, andRESUMEDon the way back - and the worker emitsWorkerQueuePaused/WorkerQueueResumedso you can route the same signal into your own alerting. These are the worker-side pair; the existingQueuePaused/QueueResumedfire in whichever process ranqueue:pause, which is never the worker, so until now a worker that went quiet because somebody paused its queue was indistinguishable from a hung one. Each event fires once per transition, not once per poll. Theirqueuefield is optional: a worker started without--queuedrains everything and has no queue names to report underpause_all, so it reportsNonerather than inventing a name a listener could match on. -
?include=paths are capped at five segments, andmax_relationship_depthmoves the ceiling. A cyclic relationship graph turns?include=author.posts.author.posts...into fan-out a client controls, bounded only by the query string. Paths are now truncated while they parse; callsuprnova::max_relationship_depth(n)inbootstrap::register()to change the limit, or pass0to turn includes off. -
Gt,Gte,Lt, andLtecompare a field against a number or against another field.CompareWithnames the operand and the measure in one value:Numberfor a literal,NumericFieldfor a numeric sibling, andLengthFieldfor a sibling compared by character count. An operand the rule cannot measure fails the field instead of panicking. -
Three membership rules join the built-in set:
InArray,Contains, andDoesntContain.InArraychecks a value against another field's list, and you pass the list directly instead of naming the field in a rule string.ContainsandDoesntContainrun over a JSON array and match a parameter only against a string element, so1and"1"stay distinct. -
The database pool now has liveness knobs.
DB_IDLE_TIMEOUT,DB_MAX_LIFETIME,DB_ACQUIRE_TIMEOUT,DB_TEST_BEFORE_ACQUIRE, andDB_PING_AFTER_IDLEcontrol when the pool closes, recycles, and pings a connection, with matchingDatabaseConfig::builder()setters. Each is unset by default, so an existing deployment's pool behaves exactly as it did. Use them when a NAT gateway or firewall drops idle connections: sqlx exposes no libpqkeepalives_*equivalent, so pool recycling is the mechanism. -
db:seed <Class>reports its progress. A targeted run prints aRUNNINGline before the seeder and an elapsed-millisecondsDONEline after it. A baredb:seedstays silent. The formatter,suprnova::two_column_detail, is available to your own#[command]handlers. -
Many-to-many relations now filter on pivot columns.
where_pivot,where_pivot_op,where_pivot_in,where_pivot_not_in,where_pivot_null,where_pivot_not_null,where_pivot_between,where_pivot_not_between,where_pivot_group, and theiror_twins constrainget,first, andcountonBelongsToMany,MorphToMany, andMorphedByMany.where_pivot_grouptakes a closure and renders one parenthesised group, so it stays atomic inside a followingor_where_pivot. Pivot filters apply to reads only:attach,attach_with,detach, andsyncreturn an error while one is set, and eager loading does not carry them. -
where_binarycompares column values byte for byte. The family (where_binary,or_where_binary,where_not_binary,or_where_not_binary) ships onBuilder<M>, andwhere_binaryandwhere_not_binaryship onDB::table(...). MySQL and MariaDB emit= binary; Postgres and SQLite return an error when the query renders, rather than falling back to a collation-dependent match. -
Builder::try_to_sql_with_bindings_forrenders SQL for a dialect without panicking. It is the fallible sibling ofto_sql_with_bindings_for, for the cases where a builder legitimately cannot render for a backend. -
Model::refresh_for_updatereloads a row under aFOR UPDATElock. Call it inside a transaction when you need the row's current state and the exclusive lock in one statement. SQLite has no row-level locking, so the lock clause is a no-op there. -
Builder::or_where_keyandBuilder::or_where_key_notadd primary-key filters as a disjunction. Both fold into the precedingWHEREclause the same wayor_wheredoes, and both shipor_filter_keyandor_filter_key_notaliases. -
Builder::in_order_ofsorts rows into an explicit sequence. Pass a column and the values in the order you want them; rows whose value is not in the list sort last. The values bind as parameters, so they are safe to take from request data.
Fixed
- The maintenance bypass cookie now expires on the server. The 12-hour TTL was a
max-agethe browser enforced, so a captured cookie kept working until you rotated the secret. The encrypted payload now carries the deadline, and every request re-checks it. suprnova serveruns a frontend-less project. A project scaffolded withsuprnova new --apihas nofrontend/directory, andserverejected it as "No frontend directory found. Are you in a Suprnova project directory?" unless you passed--backend-only. It now skips the Vite pane and the TypeScript generation that feeds it, and serves the backend.--frontend-onlystill fails on such a project, with a message that says why.
Upgrading
- Bypass cookies issued before this release stop working. The cookie's payload changed from the bare secret to a sealed
{ secret, expires_at }object, and a payload with no deadline is refused. Visit the secret URL once after upgrading to get a new cookie. Nothing else changes:down,up,--secret, and--with-secretall behave as before. - An include path longer than five segments now returns its first five relationships instead of all of them. Nothing outside a resource's allowlist was ever reachable, so no response gains data; a deep path loses its tail. One status code changes with it: a path whose over-deep tail names a relationship the resource does not allow is truncated before anything validates it, so it now returns
200with the segments that survived where the full path used to return400- adjust any client or test asserting on that rejection. Raise the ceiling withsuprnova::max_relationship_depth(n)if your API documents paths longer than that. DatabaseConfiggained five public fields. Code that builds one with a struct literal no longer compiles. UseDatabaseConfig::from_env()orDatabaseConfig::builder(), both of which fill the new fields with the defaults that preserve today's pool behavior.
1.3.3 - 2026-08-25
Added
-
Failover queue connection.
FailoverQueueDriverwraps an ordered list of connections: a push the first one refuses is retried on the next, and so on down the list. Wire it from env withQUEUE_DRIVER=failoverplusQUEUE_FAILOVER_CONNECTIONS=redis,database(each entry reads its own driver's variables, so adatabaseentry still needsDB::init()first and still brings its failed-jobs store), or build it directly withFailoverQueueDriver::new(vec![(label, driver), ...]). Only writes fall through:pushandbulk_pushwalk the list, whilepop,pop_from,ack,nack,release,settle,clear, all four counters and all three inspection listings delegate to the first connection and no other, because a reservation token is meaningful only to the driver that issued it. The operational consequence is documented rather than papered over: a worker on the failover connection drains the primary only, so whatever failed over to a fallback needs its own worker.bulk_pushpushes each envelope separately rather than forwarding a batch, which both preserves each envelope's ownavailable_at(Laravel #60950) and keeps a batch the primary half-accepted from being re-pushed wholesale onto the fallback. A refusal dispatchesqueue::events::QueueFailedOver { connection, job_name, exception }, edge-triggered: a connection reports itself once when it enters failure and stays quiet until a later push succeeds on it and re-arms it, so an outage produces one alert instead of one per dispatch. When every connection refuses, the push returns the last connection's error. An empty connection list, a missing or blankQUEUE_FAILOVER_CONNECTIONS, a nestedfailoverentry, and an entry naming a driver that doesn't exist are all boot errors - the warn-and-fall-back-to-memory behaviour stays onQUEUE_DRIVERitself, where a typo can't splice an ephemeral backend into a durable chain. -
Queue inspection API.
Queue::pending_jobs(queue)/delayed_jobs/reserved_jobslist the actual envelopes behind the existingpending_size/delayed_size/reserved_sizecounters, asInspectedJobDTOs (id,queue,name,attempts,payload,created_at) - mirrors Laravel'sInspectedJob. A singleOption<&str>queue filter collapses Laravel'spendingJobs($queue)/allPendingJobs()pair (and thedelayedJobs/reservedJobsequivalents) into one call each. TheQueueDrivertrait default is an honestErr- not Laravel's Beanstalkd/SQS empty-collection default, which reads as "nothing queued" even when there plainly is - so a driver that has not implemented inspection says so;sync/nulloverride withOk(vec![])because for them that really is the truth. The memory, database, and Redis drivers all implement the full listing: the memory driver's delayed storage moved from a bareDelayQueue<Envelope>(which cannot be iterated) to aDelayQueue<Uuid>plus an id-keyed map; the database driver reuses the size counters' exact predicates plusORDER BY available_at, and a row whoseenvelope_jsonfails to decode is still listed (id: None,payload: {"unparseable": true}) rather than dropped, so one poison row can't blind an operator to the rest of the queue; Redis'sreserved_jobsis scoped to this consumer's in-process reservations (documented), andpending_jobsscans the stream viaXRANGEin batches.Queue::fake()gained matchingpending_jobs()/delayed_jobs()helpers, projecting recorded pushes withattemptsalways0andcreated_atalwaysNone. -
After-commit dispatch.
Job::after_commit()holds a push until the surroundingDB::transactioncommits, so a worker on another process can never pop an envelope that describes rows the transaction has not made durable yet. The whole push waits, not just the driver write: the envelope build,JobQueueingandJobQueuedall happen at commit time, so no listener is ever told about a job a rollback then discards. A rollback discards the push entirely; outside a transaction the push happens immediately, which is what lets a job type declare the opt-in without every dispatch site knowing whether its code path is transactional. Per dispatch,EnvelopeOverrides::after_commitoutranks the job:Some(true)(with the shorthandQueue::push_after_commit(job)) defers a job that did not opt in, andSome(false)is Laravel'sbeforeCommit(). A deferredQueue::pushre-resolvesJob::delay()against the commit rather than the push, whileQueue::push_later/later/later_withcarry the caller's absolute timestamp through unchanged.Queue::push_uniquetakes its dedupe lock immediately even when the envelope is deferred, so a duplicate inside the same transaction is still suppressed, and a rollback releases that lock owner-scoped.Queue::bulkdefers as a unit.Queue::fake()records a push immediately, deferral and all, matching Laravel'sBus::fake. ManualDB::begin_transactionnever defers - it installs no ambient transaction, so there is no commit to hang a callback on. Every ending that leaves the commit unlanded compensates identically, including aCOMMITthe database refuses and a leakedTxHandlethat blocks one, andTransaction::rollback_tocounts as one for the scope it unwinds: a push deferred inside a savepoint is discarded when that savepoint rolls back and its lock is released right then, while anything registered before the savepoint is untouched. Queued mail, notifications, batches and chains do not defer yet. -
Unique-until-processing jobs.
Job::unique_until_processing()releases the uniqueness lock when processing begins - after the job's middleware pass, immediately before the handler runs - instead of holding it for the fullunique_forwindow, which is what you want when the lock exists to coalesce queued duplicates rather than to serialize execution. A job that a middleware releases back onto the queue keeps its lock, because it has not started processing; a job a middleware deletes or dead-letters gives its lock up. Release is owner-scoped:Queue::push_uniquerecords the cache lock's owner token on the envelope (Envelope::unique_lock_owner, an additive field that leaves the frozen wire format byte-identical for every non-unique push), and the worker releases with that token, so a redelivered attempt can never force-release a lock a newer dispatch now holds. The supporting idempotency surface is public too:Idempotency::commit_on_success_ownedhands the body the lock owner and returns it, andIdempotency::release_owned(key, owner)releases owner-scoped, reportingOk(false)rather than an error when the lock is absent or held by somebody else. Plainunique_idjobs are unchanged and still let theunique_forTTL be the dedupe window. -
Gate::default_denial_responsecustomizes the default shape of a bare denial. Mirrors Laravel'sGate::defaultDenialResponse($response). Set once - typically inbootstrap::register()- it reshapes exactly two outcomes: a barefalse(a bool gate -Gate::define/Gate::define_async, including a#[policy]method returningbool- or abefore/afterhook that decidedfalse) and an evaluation nothing else decided at all (an undefined ability with no hook opinion either). All of those used to collapse to a bareResponse::deny()(a 403); now they surface as whateverResponsethe default carries, e.g.Response::deny_as_not_found()for a 404 that hides a resource's existence application-wide instead of gate by gate. The default applies to barefalseonly - a gate registered withdefine_with/define_async_withalready returned theResponseit wanted, and that always passes throughGate::inspectuntouched, matching Laravel's own rule that the default never substitutes for a returnedResponseobject. A default shaped asResponse::allow()is rejected (logged, ignored) rather than silently inverting every bool gate to allowed - seeGate::default_denial_response's doc comment for the one place this deliberately diverges from Laravel, which has no such guard. -
The
Passwordvalidation rule family ships, including the Have I Been Pwneduncompromised()check.Password::min(n)plus the strength builders (.max(),.letters(),.mixed_case(),.numbers(),.symbols()) port Laravel'sPasswordrule regexes verbatim - a plain space satisfies.symbols(), matching Laravel's\p{Z}separator class..uncompromised()(or.uncompromised_with_threshold(n)) checks the password against Have I Been Pwned's k-anonymity range API: only the first 5 characters of the password's SHA-1 hash ever leave the process, and a network failure, timeout, or non-2xx response fails open rather than blocking signups, exactly like Laravel'sNotPwnedVerifier. Because that check is an HTTP round trip,Passwordis the one built-in rule implementing bothRule(strength only, for syncvalidate!rows) andAsyncRule(strength, then the HIBP check, forafter_validation_async) - calling the sync path on aPasswordconfigured withuncompromised()is a loud, developer-facing error rather than a silent skip.Password::defaults_with(...)sets the process-wide defaultPassword::defaults()returns. NewHIBP_TIMEOUT_SECSenv var (default 30s).Http::fake_response_text(...)is the new raw-body sibling offake_response(...)for tests againsttext/plainupstream APIs like HIBP's. -
A scheduled task can now name the timezone its cron expression is read in, and
schedule:listcan render the whole schedule in any zone..timezone(chrono_tz::Tz)pins one task,.try_timezone("Area/City")is the fallible sibling for a zone name that only exists at runtime, andSchedule::timezone(tz)sets a default for every task registered after it. Nothing changes for a task that pins no zone: it is still evaluated against the process's local zone. A pinned zone affects due-ness only - the scheduler still ticks once per process minute and the same-minute dedup gate is untouched. Note that a zone observing daylight saving makes some wall-clock minutes happen twice and others not at all, so a task pinned to such a minute can run twice or be skipped; the scheduling chapter carries the full warning.schedule:listgained a--timezoneoption and two columns: the zone a printed expression is written in, and the next minute the task fires. A pinned task's expression is rewritten into the listing's zone, splitting into several lines when it straddles midnight there, and is left exactly as written when a faithful rewrite is impossible - across a daylight-saving transition, when a day rollover would have to move a restricted day-of-month and day-of-week together, or when it would have to decide how long February is.chrono_tz::Tzis re-exported from the crate root, so consuming apps do not addchrono-tzto their ownCargo.toml. -
A Laravel-shaped image subsystem, in
suprnova::mediabehind the default-onmediafeature.Image::from_bytes/from_path/from_disk/from_upload/from_streambuilds a lazy pipeline -resize,scale,crop,cover,contain,rotateat any angle,flip_vertically/flip_horizontally,blur,sharpen,grayscale,to_format,quality- finished withto_bytes,to_response,save,store,dimensions,mime_type, ordominant_color. Reads and writes PNG, JPEG, WebP, GIF, and BMP; AVIF output is deferred until the in-house AV1 encoder publishes, at which point it is one newOutputFormatvariant and no other change. Like Laravel'sgd/imagicksplit there are two drivers:IMAGE_DRIVER=oxideav(the default) runs on the pure-Rust OxideAV codec family with no native library and nothing to install, andIMAGE_DRIVER=magickshells out to a host-installed ImageMagick 7 for wider input support including HEIC. Decode limits (IMAGE_MAX_DIMENSION,IMAGE_MAX_ALLOC_BYTES) are checked against the input's own header before anything is allocated - including the inner bitstream of an extended WebP, whose advisory canvas size cannot be used to smuggle a larger frame past the gate - and all pixel work runs on a blocking thread. Themagickdriver pins the input coder by name rather than letting ImageMagick pick one from the bytes, and bounds every invocation withIMAGE_MAGICK_TIMEOUT_SECS.ImageDriveris the trait boundary for anything else. The module is namedmediabecause the OxideAV-backed audio and video surfaces will live beside it. Images -
The WebP gate carries one fixed, non-configurable bound. A WebP declares its real decoded size in its innermost bitstream chunk, so the framework walks the container to find it; that walk visits at most 4096 chunks per level and follows two levels of nesting, and a file past either is refused rather than measured. Reporting a number from an unfinished walk would be a gate that enough filler chunks could step around. No
IMAGE_MAX_*variable affects it and the error says as much. A 300-frame animation is unaffected; a 4100-frame one is refused. Images -
OAuth can now be installed without replacing an application's existing password and session authority.
MagnetarOAuthOnlyConfigandinit_magnetar_oauth_onlyinstall the default ceremony and provider engine while leaving the password and passkey slots empty. Applications with an existinguserstable can callverify_oauth_identity, map the verified provider subject themselves, and establish their normal framework session.
Changed
DB::transactioncan now returnErrafter a successful commit, when an after-commit callback fails: the message readsafter-commit callback failed (the transaction itself committed): …, the closure's return value is lost and its writes are not.DB::transaction_with_attemptsnever retries that error, however deadlock-shaped the callback's own message reads - re-running a closure whose writes are already durable would apply them twice.- New validation catalog key:
validation-password-unverifiable. A customUncompromisedVerifierthat returnsErrno longer puts its own error text in the 422 body verbatim. That text is logged aterrorinstead, and the response carries this key, rendering as "The { $field } could not be checked against known data leaks. Please try again." - the check did not run, which is not the same as the password being bad, and infrastructure detail does not belong in a client response. An app shipping its own validation catalog has to add the key, or its users see the built-in English fallback. - The
Imageupload validator is nowImageFile.suprnova::Imageis the new image-manipulation pipeline type, matchingIlluminate\Image\Image, and the magic-byte upload rule takes the name Laravel gives the same rule class,Illuminate\Validation\Rules\ImageFile. Migration is one line per use site:UploadedFile<(Image, MaxSize<N>)>becomesUploadedFile<(ImageFile, MaxSize<N>)>. Pre-1.0 churn absorbed by the git-tag distribution model.
Removed
- The unused direct
imagedependency is gone. It had been a base dependency with zero use sites anywhere in the workspace, pulling JPEG, PNG, WebP, and GIF codecs in for nothing; dropping it removesgif,image-webp,zune-jpeg,color_quant, andweezlfrom the tree. The crate itself still appears transitively, with only itspngfeature, behindtotp-rs's QR-code rendering. The new image subsystem is built on the OxideAV crates behind themediafeature instead.
Fixed
- Installing OAuth no longer forces provider-backed applications into
Magnetar web-binding validation. The full
init_magnetarpath remains atomic and unchanged. The OAuth-only path reserves the engine slots during construction, publishes only OAuth, and fails rather than mixing two authentication authorities.
Upgrading
-
Imageis a different type now; the upload validator isImageFile. Source-breaking for anyone using the magic-byte upload rule. Rename it at every use site:UploadedFile<(Image, MaxSize<N>)>becomesUploadedFile<(ImageFile, MaxSize<N>)>.suprnova::Imagestill resolves, but it is now the image-manipulation pipeline type, so a missed rename fails to compile rather than changing behaviour silently. -
EnvelopeOverridesgained a publicafter_commit: Option<bool>field. Every construction in this repo and in the scaffolded templates uses..Default::default(), which needs no change. Code that builds anEnvelopeOverrideswith an exhaustive struct literal has to name the new field;after_commit: Nonekeeps today's behaviour, which is to defer toJob::after_commit(). Nothing else changes:after_commit()defaults tofalse, so no existing job starts waiting for a commit it did not before. -
Envelopegained a publicunique_lock_owner: Option<String>field. The wire format is unchanged - the field is#[serde(default)]and skipped whenNone, so envelopes round-trip byte-identically in both directions andschema_versionstays at 2 - but any code that builds anEnvelopewith a struct literal now has to name it. Addunique_lock_owner: Noneunless you are deliberately carrying a uniqueness lock across the push. Code that only reads envelopes, or builds them throughQueue::pushand its siblings, needs no change. -
Use
init_magnetar_oauth_onlyinstead ofinit_magnetarwhen the application already owns users, passwords, framework sessions, and remember-me state. OAuth-only callbacks useverify_oauth_identity; full Magnetar applications continue to usecomplete.
1.3.2 - 2026-08-25
Added
-
OAuth providers can now be registered through
MagnetarConfig::oauth. Suprnova re-exports theOAuthProvidercontract, all five first-party provider and configuration types, and the HTTP, revocation, abuse-limiter, authorization, and auto-link types an application needs. Custom providers no longer require a directsuprnova-magnetardependency or a hand-retainedMagnetarHostEngine. -
A production OAuth transport and framework limiter adapter now ship at the crate root.
ReqwestOAuthTransportimplements token, userinfo, and revocation I/O with redirects disabled by default, a 30-second timeout, a defaultUser-Agent, and a 1 MiB response cap.FrameworkAbuseLimiterreuses the configuredRateLimiterDriver; apps no longer hand-write either adapter.
Fixed
-
init_magnetarnow publishes OAuth with password and passkey services as one reserved installation. The OAuth service is built before publication, and all three engine slots remain hidden while the reservation is active. A failed or duplicate OAuth configuration cannot leave password and passkey state visible without the configured OAuth registry. -
Custom providers can supply userinfo headers.
OAuthProvider::userinfo_headersis merged with the host-owned bearer header, enabling requirements such as GitHub'sUser-Agentand media-typeAcceptheaders without allowing a provider to replaceAuthorization.
Upgrading
-
The Magnetar cutover in
4faaa933removed Torii's OAuth installation path without wiring its replacement into the default initializer. The old workaround required constructing a custom host engine, callingoauth_service, and installing the adapter separately. Replace that workaround withMagnetarConfig::from_sea_orm(database).oauth(oauth_config)and oneinit_magnetarcall. -
GitHub community providers must handle verified email explicitly. GitHub
/userusually omits non-public email, while the verified primary address requires/user/emails. Returnemail: Noneto use the email-completion ceremony, or pointuserinfo_endpointat a host adapter that combines both responses; never treat a public but unverified address as ownership.
1.3.1 - 2026-08-24
Fixed
- Provider-backed applications can reset verified users again. When no Magnetar engine is installed,
PasswordResetuses an explicitly reset-capableUserProviderand frameworkauth_flow_tokensfor already verified accounts.EloquentUserProvider<M>opts in whenMimplementsMustVerifyEmail + CanResetPassword; noapp_usersmigration is required. - The published framework line now contains both post-release repair sets. The translated 1.3.0 changelog layout and headings, CJK wrapping, localized anchors, glossary terms, and prose punctuation are reconciled instead of split across divergent local and remote branches.
- Post-tag CLI and Magnetar hardening is included. Development-process cleanup uses the completed process-group fallback, and the local qualification contracts cover the released refs and plugin-SDK SQLite lanes.
Security
- The provider fallback never treats password reset as first mailbox proof. Unknown and unverified addresses receive the same no-mail response. Install Magnetar when an unverified account must prove mailbox ownership through reset so credential cleanup, auth-epoch advancement, and revocation remain atomic. Provider fallback completion reports framework session and remember revocation failures through
PasswordResetOutcome.
Upgrading
- Move every
v1.3.0Git dependency tov1.3.1. Applications with their ownuserstable keep their configuredUserProvider; they do not initialize the defaultapp_usersengine merely to reset an already verified account. Applications that use Magnetar credentials or unverified-account first proof continue to initialize Magnetar.
1.3.0 - 2026-08-24
Security
-
Magnetar now fences credential and session mutations to the authenticated actor and account auth epoch. Password, passkey, linked-account, two-factor, opaque-session, JWT, remember, OAuth, and device-authorization writes reject stale or revoked actors. The first successful password-reset, magic-link, or OAuth verified-email proof on an unverified account advances the epoch and atomically removes provisional credentials, sessions, remember state, and squatter TOTP enrollment. Verified accounts preserve legitimate credentials during password reset. Email verification requires the authenticated token owner, and OAuth never auto-links an unverified existing account from email alone.
-
A protocol-relative
_previous.urlcan no longer produce an off-origin open redirect throughRedirect::back(), on either the write side or the read side.SessionMiddlewareno longer persists a protocol-relative current URL: the write goes through the identical sanitizerInertiaValidationRedirectMiddlewareuses for itsReferercheck, and a request path shaped like//host(or carrying an ASCII control byte) is never recorded - without this, an app'sfallback!route (the standard Inertia/SPA app-shell pattern, where any unmatched path answers200) could haveGET //evil.test/anythingpersist that path verbatim.SessionData::previous_url()now applies the same check on every read, too, so a session cookie that survived an upgrade from a release before this fix - already carrying a raw, unsanitized value no write in the current process ever produced - self-heals to "nothing recorded" instead of being trusted. Together, neither an old poisoned cookie nor a new malicious request can handRedirect::back(),Redirect::refresh(), orurl::previous()an off-originLocation. When a value fails either check it's treated as absent rather than replaced with a synthesized one, so a genuinely good previous URL is never clobbered. -
The Inertia validation-redirect bridge's
Referercheck closed two more same-origin bypasses.InertiaValidationRedirectMiddleware's303target only rejected aRefererstarting with the literal//or/\prefix - a value likeReferer: /<TAB>/evil.testslipped through, because the WHATWG URL parser strips ASCII tab and newline from the whole string before comparing origins, so a browser reads that as//evil.testand follows the303off-origin. The check now rejects any ASCII control byte (C0 or DEL) anywhere in the candidate, not only within the two named prefixes. Separately, the last-resort fallback - the failing request's own path, used when neitherReferernor the session's previous URL is usable - was never sanitized: an origin-form HTTP request-target is syntactically free to start with//, so a raw client or a non-normalizing proxy could turn the "safe last resort" into an off-origin redirect too. Both legs now share one root-relative check, falling back to/if even the request's own path fails it. -
Cookie ciphertext is now bound to its logical cookie name with contexted v2 AAD.
Cookie::encrypted/Cookie::read_encrypted_forstop a value minted for one cookie slot from decrypting in another slot, while the logical-name binding keeps a later__Host-/__Secure-wire-prefix flip safe. The version-less compatibility window tries v2 across the whole key ring, then v1 across the whole ring, so existing cookies survive the rollout; the v1 fallback preserves the old replay weakness until its scheduled 1.4.0 removal. -
Session and remember-me cookie prefixes are validated at boot and enforced at render time.
SESSION_COOKIE_PREFIX=__Host-requiresSecure,Path=/, and noDomain;__Secure-requiresSecure. Invalid boot combinations fail before serving, and the renderer rewrites invalid prefixed headers instead of letting browsers discard them silently.
Added
- Suprnova authentication now runs on the internal Magnetar engine. The
framework-owned
Authfacade preserves existing password, magic-link, passkey, OAuth, bearer, lockout, session, and two-factor call sites while removing the Torii dependency. The default engine installs password/session and passkey adapters atomically, stores lifecycle delivery leases in the application database, and shares the application's canonicali64app_usersidentities. - A shape-aware authentication migration runner now covers Torii, Suprnova web, and Suprnova API sources. Dry runs bind a stable plan id to durable row and schema fingerprints plus destination identity decisions. Apply uses transactional imports, retry ledgers, shape-owned cleanup, and collision refusal. MySQL uses a write-barrier-protected shadow swap with pre-copy journals, row and schema parity, resumable renames, and cleanup-preserving restore.
MAIL_DRIVER=filewrites one RFC 5322.emlper message toMAIL_FILE_PATH(defaultstorage_path("mail"); a relative value anchors at the application base directory, not the process CWD), so local mail can be opened in a mail client instead of read out of a log line. The file carries the same header superset SMTP emits, includingX-Priority,Importance,X-Tag,X-Metadata-*, andReturn-Path. Likelogandmemory, it does not deliver: a production boot refuses it unlessMAIL_ALLOW_NON_DELIVERING_IN_PRODUCTION=true.FrameworkError::Externalcarries the error it wraps.FrameworkError::from_external(e)andFrameworkError::from_external_with("saving user", e)keep the original error reachable as astd::error::Errorsource instead of melting it into a string.FrameworkError::external_source()returns it for downcasting - use that rather thansource(), which yields the sharedArchandle. Both constructors map to HTTP 500.- 5xx logs now render the full error source chain.
render_error_chainwalkssource()and is wired into the framework-error log line, theErrorOccurredevent payload, and thedebug_messagefield emitted underAPP_DEBUG=true. Client-facing response bodies are unchanged and 5xx bodies stay sanitised. InertiaResponse::scroll_wrapped/scroll_with_wrapped/try_scroll_wrapped. Nest a scroll prop's merge instruction under<key>.<wrap_key>instead of the bare key -mergeProps: ["users.data"]rather than["users"]- for a value that's itself an envelope ({ data: [...], meta: {...} }). Laravel'sScrollPropwraps under"data"unconditionally; Suprnova's built-in paginators hand back a bare row array, so this is opt-in rather than a default every caller has to work around. NewProvidesScrollMetadatatrait (page_name/previous_page/next_page/current_page, with a defaultscroll_metadata()) mirrors Laravel's interface of the same name for a paginator this crate doesn't know about;LengthAwarePaginator,Paginator, andCursorPaginatornow implement it instead of buildingScrollMetadataby hand. A scroll prop's.match_on(...)fields now also emit intomatchPropsOn, matching Laravel'sresolveMergeMatchingKeys(Response.php:641-652), which folds aScrollProp'smatchesOn()in the same as any other merge prop - the match entry keys off wherever the prop actually merges,<key>unwrapped or<key>.<wrap_key>under.scroll_wrap(...).Prop::merge_with_path, multi-fieldmatch_on, and resolver-backed merge props.Prop::merge_with_path(path)merges a nested field inside a prop's value instead of the whole prop -Prop::eager(v).merge().merge_with_path("data")emitsmergeProps: ["<key>.data"], and a path-merging prop never also merges its root;.deep_merge()ignores it, since a deep merge already recurses into every field.Prop::match_onnow takes one field or several in one call (match_on(["id", "slug"])) on top of thematch_on("id").match_on("slug")chainingPropcomposition already supports.InertiaResponse::merge_lazy/merge_lazy_withadd the resolver-backed siblings of.merge/.merge_with, matching Laravel'sInertia::merge(fn () => ...).- Partial-reload
only/exceptunderstand dot notation.X-Inertia-Partial-Data: user.namenarrows theuserprop to{ name: ... }instead of requiring the whole value or nothing;X-Inertia-Partial-Except: user.emailprunes just that field, leaving the rest ofuserin place.exceptwins on a path both headers name, a bare entry still means the whole prop, and an unknown or type-mismatched nested path drops silently without touching its siblings.Alwaysprops are unaffected - they always ship whole. - Dot-key prop nesting.
.with("user.name", value)(and any other prop-attaching method, eager or resolved) now nests intoprops.userinstead of shipping a literal"user.name"key, matching Laravel'sArr::set-basedresolveArrayablePropertiesunpacking. Two calls sharing a prefix -.with("user.name", …)then.with("user.age", …)- accumulate into one object; a key with no dot is unaffected.App::inertia_share*shared-registry keys nest the same way on the wire. The unpacking only ever touches top-level prop keys - it never recurses into a prop's value, so a validationerrorsbag keeps whatever dotted field names it carries internally. App::inertia_shared(key)/App::flush_inertia_shared(). Laravel'sInertia::getShared/Inertia::flushShared, reading and clearing the static share registry (App::inertia_share/_lazy/_once).inertia_sharedsupports the same dot notation asinertia_sharefor the read side; it returnsNonefor a lazy or once share (there's no request to resolve one against) and for an unregistered key.flush_inertia_sharedclears only the static registry - a trait provider registered viaApp::register_inertia_sharedis untouched, matching Laravel (there's no per-request state there to flush).InertiaResponse::always_with(key, resolver). The async-resolver sibling of.always(key, value), for an always-included prop expensive enough to be worth resolving lazily - Laravel'sInertia::always(fn () => …)(AlwaysPropaccepts any value, closures included).InertiaSharedData::sharenow receives the page component name, so a provider can vary its output by page - Laravel'sRenderContext. See Upgrading.- Inertia prop composition. A
Propnow carries orthogonal flags instead of being one of nine closed variants, so a single prop can be deferred and mergeable, mergeable and cached, or optional and cached - the combinations the Inertia 3 protocol expects and a closed enum could not spell. Build one withProp::eager/Prop::lazy/Prop::from_resolver/Prop::absent, chain.always(),.optional(),.defer(),.group(),.rescue(),.merge(),.prepend(),.deep_merge(),.match_on(),.once(),.as_key(),.until(),.fresh(),.scroll(), and attach it with the newInertiaResponse::prop(key, prop). Adefer().merge()prop is announced underdeferredPropson the first render and arrives undermergePropson the follow-up request. NewMergeModeandVisibilitytypes describe the flags; every existing builder shortcut (.with,.always,.lazy,.optional,.defer,.merge*,.once*) is unchanged. - Queue pause / resume.
Queue::pause(connection, queue)/resume/pause_all()/resume_all()/is_paused(connection, queue)/paused_queues(connection, &queues), backed byCachethe same way the restart signal is -resume_alldoes not clear a per-queue pause, matching Laravel. The worker's claim gate sits right before every pop, so an in-flight job always finishes; a global pause short-circuits--queue=...filtering the same way Laravel'spausedQueuesdoes, and a per-queue pause only takes effect on a worker started with an explicit--queue=...list. New CLI commandsqueue:pause [queue] [--all]/queue:resume [queue] [--all](aliasqueue:continue), plusQUEUE_PAUSABLE=falsefor an operator to disable the feature - an unpausable worker ignores pause signals, andqueue:pauseitself refuses to run. New events:QueuePaused/QueueResumed/QueuesPaused/QueuesResumed. suprnova::testing::TestResponse- a fluent, Laravel-TestResponse-shaped wrapper over the(status, headers, body)triple every HTTP test harness already produces:assert_status,assert_ok,assert_redirect,assert_json,assert_json_path,assert_json_count,assert_see,assert_header,assert_cookie, and (given.with_session_store(...))assert_session_has. Every assertion returns&Selfand panics on failure, the same contract asexpect!. Nothing about how a test drives a request has to change.suprnova newscaffolds an SSR entry. Every starter (Svelte, React, Vue) now shipsfrontend/src/ssr.{ts,tsx}and abuild:ssrnpm script (vite build --ssr), wired to its own output directory (frontend/bootstrap/ssr/) so the SSR bundle never collides with the client build inpublic/assets/.InertiaConfig::ssr_bundle_path(path)/.ssr_ensure_bundle_exists(bool). The SSR gateway can now check the built bundle exists on disk before dispatching a render, mirroring Laravel'sensure_bundle_existsconfig - a worker that was never started, or a bundle that was never built, fails fast instead of payingssr_timeouton a connection that was never going to succeed. Opt in with.ssr_bundle_path(...); unlike Laravel'sBundleDetectorthe path is never auto-detected, so existing SSR configs (and tests) that don't set one are unaffected.- Validation failures on an Inertia visit now redirect back instead of returning
422JSON.Inertia::installregisters a fourth middleware,InertiaValidationRedirectMiddleware, which turns a validation422on anX-Inertiarequest into a303to the form page with the errors flashed - souseForm().errorsfills in with no handler code. The Inertia client treats any response without anX-Inertiaheader as non-Inertia and shows its error modal, so the old422could never reachform.errors. Non-Inertia requests keep the422envelope, Precognition dry-runs are untouched, andX-Inertia-Error-Bagscopes the flashed bag. The redirect target is the same-originReferer, then the session's previous URL, then the request's own path run through that same sanitizer, falling back to/if even that fails it - never trusted verbatim. InertiaConfig::with_all_errors(bool)- keep every validation message per field instead of collapsing to the first. Mirrors Laravel'sInertia\Middleware::$withAllErrors.suprnova::testing::AssertableInertia- fluent, Laravel-AssertableInertia-shaped assertions over an Inertia page object, parsed from either anX-InertiaJSON response or a hard-navigation HTML shell's embedded<script data-page="app">element:component,url,version,prop,has,missing,where_,count,has_flash. Build one from anHttpResponsewithAssertableInertia::from_response, or from aTestResponsewith the newTestResponse::assert_inertia().reload_only,reload_except, andload_deferred_propsreplay a partial reload against a caller-suppliedwith_reload(...)closure - Suprnova's HTTP tests cross a real socket, so there's no single in-process test client to hardcode against.Cookie::queue/queued/unqueue/expire. A task-local cookie jar - Laravel'sCookieJar- lets any code queue a cookie for the next outgoing response without holding anHttpResponseto attach it to: an event listener, a container-bound service, middleware ahead of the handler. Backed by the same per-request slotAuth::login_rememberalready uses to carry the remember-me cookie past the handler boundary;SessionMiddlewaredrains it onto the response next to the session cookie.Cookie::expire(name, path, domain)queues a deletion cookie built withCookie::forget_with. RequiresSessionMiddlewarein the route's middleware chain - outside it, all four calls are a silent no-op, matchingApp::flash's behavior outside a flash scope.HttpResponse::event_stream(stream, end)andHttpResponse::stream_json(stream). Laravel'sResponseFactory::eventStream/streamJson, and the exact wire shapes@laravel/stream-{react,vue,svelte}'suseEventStream/useJsonStreamexpect.event_streamframes aStream<Item = sse::StreamedEvent>asevent: updateper item unless the item names its own event, JSON-encodes any non-string payload, and appends a configurable terminal frame (EndSignal::default()isdata: </stream>;EndSignal::Noneomits it).stream_jsonstreams anyStream<Item = impl Serialize>as one incrementally-flushed JSON array. Both are built on the existingsse/stream_bytesbody pipeline, so they share its cancellation and panic-isolation behavior with the rest of the framework.suprnova serverespawns a crashed dev process instead of tearing the whole session down. Exponential backoff between attempts - 200ms, doubling on each consecutive crash, capped at 5s, resetting to the floor once a process has stayed up 30s.--no-restartopts out and restores the previous behaviour.--restart-tries <N>(default5, matching Laravel's--restart-tries=5) gives up retrying a process after that many consecutive crashes instead of retrying forever, printing an actionable message and leaving the other processes - and the session itself - running.--timestampsprefixes every forwarded line withHH:MM:SS. A newSuprnova.toml[[serve.process]]array lets a project declare its own dev processes - Laravel'sDevCommands::register- to run alongside the backend and frontend, each with its own[name]prefix and an optional color; an unknown key or a blankname/commandin an entry is now a hard parse error instead of silently ignored or a later opaque spawn failure.--jsonemits one JSON object per line (NDJSON) on stdout instead - process start, output, exit, restart-scheduled, restart-succeeded, gave-up, types-regenerated, and shutdown events, including the file watcher's own regeneration notices and theCtrl+Chandler's shutdown notice, both of which now stay off stdout under--jsontoo - for scripting and log pipelines; combining it with--timestampsis harmless but redundant, since every event already carries its own timestamp.RequestBuilder::retry_when(predicate). A predicate consulted before every retry the built-in policy (.retry(...)/.retry_non_idempotent(...)) would otherwise make, receiving aRetryContext { attempt, method, url, outcome: RetryOutcome::TransportError | Status(u16) }. It composes with the policy rather than replacing it:falsevetoes a retry the policy would have made; it can never force one pastmax_attemptsor one the policy wouldn't otherwise attempt (a 4xx status, or a non-idempotent method withoutretry_non_idempotent).#[model(touches = [...])]now actually touches. After a child is created, saved, updated, or deleted, eachBelongsToowner named in the list gets oneUPDATE <owner> SET updated_at = ? WHERE <key> = ?, on the same executor as the write that triggered it - so inside aDB::transactionthe touch joins that transaction and rolls back with it. An owner whose model hastimestamps = falseis skipped, not written and not an error (Laravel 13.25 closed the same gap). Owners reached through aNULLforeign key, and soft-deleted owners, are skipped too. Atouchesentry that doesn't name a declaredBelongsTorelation is now a compile error; polymorphic owners are not supported yet.without_touching_on::<M, _, _>(fut)- Laravel'sModel::withoutTouchingOn([M::class], $cb). Suppresses bothm.touch()and any owner cascade targetingM, while owners of other types keep bumping. Scopes nest, and the existingwithout_touchingnow suppresses the owner cascade as well as directtouch()calls.Model::touch_owners()/touch_owners_with_tx(tx)- Laravel'stouchOwners(), for when you wrote the child row through a path the framework doesn't own.- Value-shaped validation rules:
ArrayKeysandDistinct. A newValueRuletrait (passes(&self, value: &serde_json::Value)) sits alongsideRule, sharing the same keyed-message contract.rules::ArrayKeys(&[...])rejects a JSON object carrying any key outside the allowed list (Laravel'sarray:keys, #60918);rules::Distinct { ignore_case, strict }rejects a JSON array with a repeated element (Laravel'sdistinct).validate!rows accept either kind of rule in the same field list - dispatch is automatic, chosen by which trait the rule implements, not by new row syntax. Job::delay()- jobs can declare a default delay (fn delay() -> Option<Duration>, defaultNone), honored byQueue::pushandQueue::bulk:available_atbecomesnow + delayinstead ofnow. An explicit call-site delay still wins -Queue::push_later(job, at)andQueue::later(delay, job)use the caller's timestamp verbatim and never consultJob::delay().Notification::{queue, timeout, fail_on_timeout, max_tries, backoff}. A queued notification (Notify::queue) now carries its own queue-tuning defaults onto every per-channelSendNotificationJobpush via theEnvelopeOverridesprimitiveMail::on_queueuses -fail_on_timeout(&self) == truedead-letters on the first timeout instead of retrying, matching Laravel's#[FailOnTimeout]notification attribute (#61072). All five default toSendNotificationJob's existingJobdefaults, so a notification that overrides nothing is unaffected.Mail::on_queue/Mail::on_connection+Queue::push_with/later_with. A queued mailable now routes itself withMail::to(..).on_queue("emails").queue(mailable), or defaults viaMailable::queue(&self). Both outrank anyQueue::routeregistered for the job and the job's ownJob::queue()/Job::connection()- the newEnvelopeOverridesprimitive behind them (Queue::push_with(job, overrides)/Queue::later_with(delay, job, overrides)) also covers timeout, fail-on-timeout, max-tries, and backoff for one push.MailFake's queued snapshots now carry the resolvedqueue, withqueued_on(...)/assert_queued_on(name, queue)to assert it.Application::http_bootstrap(f)- an HTTP-only boot hook. It runs afterbootstrapand only on theserve/web:runpath, so the queue, schedule, and workflow workers and the console binary never run it. Worker and console container images no longer need a built frontend manifest to boot:Inertia::installfails closed in production when it is missing, and that check now only runs on a process that actually serves HTTP.Router::inertia(path, component, props)- Laravel'sRoute::inertia, for a static page whose handler would be one line. RegistersGET(HEAD falls through to it) and returns aRouteBuilder, so the route can be named and given middleware.Router::viewis retained as an alias.- SES v2 send options. The SES transport now emits
TenantName,ConfigurationSetName, andListManagementOptionsonSendEmail. Each has a transport-level default (SesMailTransport::tenant_name/configuration_set_name/list_management) and a per-message header override (X-SES-TENANT-NAME,X-SES-CONFIGURATION-SET,X-SES-LIST-MANAGEMENT-OPTIONS), with the header winning. The headers are consumed when the request is built and never rendered into the message. without_cookieson every response builder.HttpResponse,Response(viaResponseExt),Redirect, andRedirectRouteBuilderall expire a list of cookies in one call, andRedirect/RedirectRouteBuildergained the single-namewithout_cookiethey were missing. NewCookie::forget_with(name, path, domain)builds a deletion cookie scoped to the path and domain the original was set with - a plainforgetnever clears a cookie set outside/.Queue::fake()stamps an envelope id on every captured push.pushed_with_id::<J>()returns(job, id)pairs, and the fake now dispatches the sameJobQueueing/JobQueuedpair a real driver push does - carrying that id - so a test can correlate a captured push with what its listeners saw. Existing fake helpers are unchanged.UniqueJobSkippedqueue event.Queue::push_uniquenow dispatchesqueue::events::UniqueJobSkipped { job_name, unique_id, connection }when it suppresses a duplicate, so a dedupe is observable instead of silent. The call's return value is unchanged (Ok(false)).model_keys()on the query builder and on collections.User::query().model_keys().await?returns every matching row's primary key without hydrating a single model, projecting the table-qualified key (users.id) so the query survives a join.Collection::model_keys()is the already-hydrated counterpart.#[suprnova::model]now also declares the key's Rust type asEloquentModel::Key, so both return the typekey_typenames rather than a caller-chosen turbofish.
Fixed
-
PostgreSQL soft deletes now use backend-aware placeholders, and generated timestamp writes honor declared casts.
delete()andrestore()render PostgreSQL ordinal placeholders instead of MySQL and SQLite?placeholders. Generated create, update, save, touch, and soft-delete writes also convert timestamps through each field's declaredCaststorage type, so nativeTIMESTAMPTZcolumns no longer receive text values. Thanks to @i-am-v-alexander-v for reporting both defects and submitting a fix in PR #3. -
Default workspace and Magnetar gate runs no longer require live PostgreSQL or MySQL services. Backend-specific behavior suites are explicit, ignored qualification tests that still fail when deliberately invoked without their configured database. Reachability-only tests and permanent gate environment requirements were removed, so unrelated changes don't pay for external database setup on every verification run.
-
PartialFilter::narrowis nowpub. Its four sibling predicates (should_include,should_include_eager,should_include_optional, and the type itself) were already public, but the narrowing pass that makesshould_include_eager'strueanswer correct - trimming a resolved value down to the dotted paths anonly/exceptentry actually asked for - waspub(crate). A caller building custom partial-reload handling on top ofPartialFilterhad no public way to reproduce that narrowing and would ship a value whole under a dottedonlyentry even thoughshould_include_eagerreported the key as included. -
MailFake'sQueuedSnapshotcan now assert on.on_connection(...).Queue::fake()gainedassert_pushed_on_connectionin Wave 3 alongsideassert_pushed_on_queue;Mail::fake()only got the queue half, so a mailable queued with a connection override was resolved and applied to the real dispatch but unassertable through the fake. NewQueuedSnapshot::connection,MailFake::queued_on_connection, andMailFake::assert_queued_on_connectionclose the gap, mirroringassert_queued_on's shape. -
A dotted shared prop was unreachable by a bare
onlyentry.App::inertia_share("auth.user", …)followed byrouter.reload({ only: ['auth'] })returnedprops: {"errors":{}}- the share vanished outright. The registry storesauth.useras one literal key and theArr::setunpacking pass only nests it after every prop has resolved, so the partial-reload gate saw the still-flat key and matched it against neitherauthnor anything else.only/exceptentries are now symmetric: an entry may name a prop's key exactly, a path inside it (user.name, which narrows), or an ancestor of it (authagainst the keyauth.user, which ships the prop whole, because the caller asked for the whole root). A bareexcept: ['auth']drops every prop key beneath it the same wayArr::forgetdrops the whole subtree in Laravel's already-nested bag. The prefix must end on a segment boundary, so an unrelatedauthAgent.userprop is untouched by either list. Laravel never hits this becauseInertia::sharerunsArr::setat share time; Suprnova's registry cannot, since a lazy share has no value to nest until the request resolves it. -
A
#[data(lazy(deferred))]field bypassed the?include=allowlist. The owner-tagged resolution path inresolve_propsselected props withProp::is_lazy(), which is false for anything carrying a flag - and a deferred field isVisibility::Deferred. The field therefore resolved off the ordinary prop path, where no include-set check exists, and shipped to any client that sent the deferred follow-up regardless of whether the request opted the field in.Prop::resolve_with_ownernow gates every resolver-backed owner-tagged prop, flags or not, andresolve_propsruns that gate ahead of every other block: a field outside?include=is dropped whole (no value, nodeferredPropsannouncement), and a field named by?include=but off the DTO's allowlist raises its400beforeX-Inertia-Partial-Datacan absorb it. Not a regression - the pre-Wave-4 code gated on theProp::Lazyenum variant, which aProp::Deferalso failed - but a real hole either way. -
deferredPropswas re-announced on a matched partial reload. A partial that named one deferred key still advertised every other deferred key back to the client, which then fetched them again, and again on the next partial. Laravel'sresolveDeferredPropsreturns[]the moment the request is partial, before it inspects a single prop (Response.php:661-663); the block is now dropped whole on any matched partial. A partial reload aimed at a different component is a standard visit for this gate, as for every other, so its announcements are unaffected. -
The
errorsbag filtered differently depending on where the errors came from. The session-flashed bag is seeded ahead of the resolve loop and no partial-reload filter could reach it, while a handler's own.with("errors", …)went through the ordinary gates - soonly: ['errors.email']shipped the whole seeded bag but a one-field handler bag, andonly: ['users']replaced the handler's bag with the seeded one instead of leaving the key alone. Both paths now treaterrorsas always-visible, matching Laravel's middleware, which shares it asInertia::always(...)and re-injects the raw value throughresolveAlwaysafter theonly/exceptrebuild. This is the shape the client needs: it folds a partial response in with{...current.props, ...response.props}, so an emptyerrorsobject wipes messages already on screen where an unfiltered one leaves them correct. An explicit visibility flag on the key still wins, so.prop("errors", Prop::eager(…).optional())behaves optionally. -
Queue::fake()can now observe per-pushEnvelopeOverrides. A job pushed throughQueue::push_with/Queue::later_withwas indistinguishable from a plainQueue::pushunder the fake -FakePushcarried only the payload andavailable_at, so the override never left the facade and nothing could assert a test dispatched to the right queue or connection. Newqueue::testing::pushed_with_overrides::<J>() -> Vec<(J, EnvelopeOverrides)>returns each captured push paired with what it declared;assert_pushed_on_queue::<J>(queue)andassert_pushed_on_connection::<J>(connection)cover the common single-field case, mirroringMailFake::assert_queued_on. Every other entry point (push,push_later,bulk,push_unique, the chain/batch dispatchers) still takes no overrides and recordsEnvelopeOverrides::default(), so a plain push reads under the fake exactly as "no override declared." -
An SSR worker that stalled mid-response body could hang a render forever.
SsrConfig::timeoutbounded only the wait for response headers; once headers arrived, reading the body had no timeout of its own, so a worker that accepted the connection, sent headers, then stopped sending data left the request hanging past the configured timeout instead of falling back to CSR (or erroring, underssr_throw_on_error). Both phases now share one deadline, so the configured timeout bounds the whole SSR call, as its own doc already promised. -
Queued cookies - including the remember-me cookie
Auth::login_remembersets - were silently dropped on three internal fail-closed paths inSessionMiddleware. A session read failure, a session write failure, and a session-cookie encryption failure each returned a synthesized500directly, bypassing the pending-cookie drain that runs at the end ofhandle. Anything queued viaCookie::queuethat request - including a remember-me token row already committed to the database - never reached the client as aSet-Cookieheader. All three paths now drain pending cookies before returning, the same as a handler-returned error or a redirect. This does not cover an uncaught panic, matching Laravel's own queued cookies being lost to one. -
Queue::push_uniquenow honorsJob::delay(), matchingQueue::push,Queue::push_with, andQueue::bulk. It previously computedavailable_atfromUtc::now()directly, so a job that declared a default delay (fn delay() -> Option<Duration>) dispatched immediately when pushed throughpush_uniqueinstead of after that delay.Queue::push_unique_laterandQueue::later_uniqueare unaffected - they already take an explicit timestamp or delay from the caller and never consultJob::delay(), the same rulepush_later/laterfollow.
Changed
- The current development branch uses SeaORM 2.0 and requires Rust 1.94.0. Suprnova preserves
its Eloquent,
#[model], migration, and database-facade source shapes. Applications that call SeaORM directly must importExprTraitfor SeaQuery expression methods and use explicit*_rawconnection methods for prebuiltStatementvalues. SeaQuery is now 1.0, and the direct MariaDB vector driver uses SQLx 0.9. Existing databases require no application data migration; fresh PostgreSQL schemas retain serial-backed primary keys. - Three more unused dependencies removed.
pretty_assertionsandqrcodeleave the framework crate (totp-rsalready carries theqrfeature, so QR provisioning for two-factor enrolment is unaffected), andnotify-debouncer-minileaves the CLI (notifyitself stays - theserveandgenerate-typeswatchers use it directly). All three were confirmed unused bycargo-udepsplus a source-wide search that covers doc tests. suprnova-macrosno longer depends onserdeorserde_derive_internals. Neither was used: the::serde::Serializepaths the macros emit resolve in the downstream crate, not in the macro crate itself. No effect on generated code.MergeStrategy'smatch_onnow carries more than one field name.Append,Prepend, andDeepeach widen frommatch_on: Option<String>tomatch_on: Option<Vec<String>>, soInertiaResponse::merge_with/merge_lazy_withcan dedupe on several fields the same way.prop(key, Prop::eager(v).match_on([...]))already could - before this, the response-builder shortcuts were strictly less expressive than building aPropdirectly. See Upgrading.- Scroll props now emit Laravel-identical
resetand merge semantics.scrollProps[key].resetistrueexactly when the client namedkeyinX-Inertia-Reset, matching Laravel'sresolveScrollProps- nottrueon every visit lacking anX-Inertia-Infinite-Scroll-Merge-Intentheader, as before. A scroll prop now also carries merge metadata unconditionally, defaulting to append: a fresh visit (no headers at all) emitsreset: falseplus amergePropsentry, where it previously emittedreset: trueand no merge metadata. A key inX-Inertia-Resetis excluded frommergeProps/prependPropsfor that response, the same exclusion a regular merge prop already had. ssr:checknow verifies the SSR worker'sGET /healthroute answers 2xx, rather than only confirming that something accepted a TCP connection. Every@inertiajs/{vue3,react,svelte}/serverworker answers/healthout of the box, so this needed no change on the worker side - matches Laravel'sInertia\Ssr\HttpGateway::isHealthy().- The Inertia
errorsprop now carries one string per field, not an array. A session-flashed validation bag renders as{ email: "The email field is required." }rather than{ email: ["The email field is required."] }, matching Laravel's default and Inertia's ownErrorValue = string.InertiaConfig::with_all_errors(true)restores the array shape. Anerrorsprop a handler sets itself is passed through untouched, and the session flash (Redirect::with_errors,session.pull_errors_flash()) still stores arrays - only the rendered page prop changes. Model::TOUCHESmoved from an inherent const toEloquentModel. The parent-touch cascade lives on aModeltrait default, and a trait default can't read an inherent const.Comment::TOUCHESstill resolves - it now needsuse suprnova::EloquentModel;in scope. Models without atouchesattribute get the trait's empty default.RelationEntrygainedrelated_updated_at_column. Anything constructing aRelationEntryby hand needs the extra field; nothing in-tree does, the macro emits them all.Router::viewnow rejects props that aren't a JSON object. It previously ignored them silently, registering a route that rendered an empty prop bag with no diagnostic.nullis still accepted as "no props";Router::try_inertiais the fallible form.- The Inertia asset version now defaults to a hash of the Vite build manifest instead of the
literal
"1.0", so a deploy invalidates long-lived clients without anyone remembering to bump a string.InertiaConfig::manifest_path(...)re-points the resolver with it; an explicit.version(...)/.version_with(...)still wins. With no manifest on disk - local development - the version falls back to"1.0", which is what every app saw before, so nothing changes until you build. NewVersionResolver::from_manifest(path)exposes the resolver directly.
Deprecated
Cookie::read_encryptedis now the v1-only legacy reader. Code that mints withCookie::encryptedand reads withread_encryptedfails at runtime on the first value written after this release; switch toread_encrypted_for(name, wire). The un-contextedCryptPurpose::Cookieentry points are also superseded. Both removals are scheduled for 1.4.0.
Upgrading
-
Cookie decrypt warnings now have two independent axes. A
KeyOrigin::Previous(index)warning means re-encrypt the value under the currentAPP_KEYand remove that previous key only after the rotation tail is gone; anAadVersion::Legacywarning means re-issue the cookie through the name-bound API before the 1.4.0 fallback removal. A value can report both. -
SESSION_COOKIE_PREFIXis opt-in. Deploy__Host-only with HTTPS,SESSION_SECURE=true,SESSION_PATH=/, and noSESSION_DOMAIN; local HTTP scaffolds leave it empty.CsrfMiddleware'swith_session_configkeeps the literalXSRF-TOKENname; use.xsrf_cookie_name("__Host-XSRF-TOKEN")when a client is configured for that separate name. -
DecryptOriginis now a two-axis#[non_exhaustive]struct. Read itskeyandaadfields independently and keep a wildcard-compatible match strategy for theKeyOrigin/AadVersionenums. -
SessionConfigandCookieOptionsare now#[non_exhaustive]. Struct literals and functional record updates in application code must move toType::default()followed by public-field assignments or builder methods. -
FrameworkErroris now#[non_exhaustive]. Amatchon it in your own code needs a wildcard arm. This is the last release in which adding a variant would have been a breaking change. -
MergeStrategy::Append/Prepend/Deep'smatch_onfield is nowOption<Vec<String>>, notOption<String>. A call site constructing the struct-literal form directly -MergeStrategy::Append { match_on: Some("id".into()) }- no longer compiles; wrap the field name in aVec:Some(vec!["id".into()]).match_on: Noneis unaffected and needs no change. -
A matched partial reload no longer emits
deferredProps. Code readingpage.deferredPropsoff a partial-reload response - a custom deferred-loading component, a test snapshot, an end-to-end assertion - will now find the key absent where it used to list the deferred props the request did not name. Read the announcements off the initial (non-partial) visit, which is where Laravel puts them and where the official client reads them. -
A bare
exceptentry now drops dotted prop keys beneath it.X-Inertia-Partial-Except: authpreviously left a prop registered underauth.userin the response, because the gate compared whole keys. It is dropped now. If a page relied on a bareexceptentry pruning only the exact key, name the exact key (except: ['auth.user']) or narrow with a dotted path instead. -
errorsignoresonly/except. A partial reload that filtered a handler-supplied.with("errors", …)prop out, or narrowed it with a dotted entry, now ships it whole. Tests asserting a sliced or emptyerrorsobject on a partial reload need updating. To keep the bag out of a response deliberately, flag it -.prop("errors", Prop::eager(…).optional())- rather than relying on the partial-reload lists. -
Prop::resolve_with_ownergates flagged props too. It previously resolved any prop that was notProp::is_lazy()- an eager value or a resolver carrying a flag - without consulting the include set. It now gates every resolver-backed prop and only lets an already-materialized value through ungated. A#[data(lazy(deferred))]field consequently needs?include=<field>on the request before it resolves or is announced, the same as every other lazy flavor. Add the field to the request's?include=list, or drop thelazy(...)attribute if it was never meant to be opt-in. -
Scroll prop
resetno longer follows the merge-intent header. Code that readspage.scrollProps[key].resetdirectly - a custom infinite-scroll component, a test snapshot - will seereset: false(plus amergePropsentry) on a plain revisit that used to readreset: trueand carry no merge metadata. The official<InfiniteScroll>component behaves differently only on a plain revisit: it listens forreseton everyroutersuccessevent, not only an explicitrouter.reload(), so a normal revisit no longer clears its accumulated state unless the server actually named the key inX-Inertia-Reset, which matches Laravel. SendX-Inertia-Reset: <key>explicitly wherever the old "any non-append/prepend visit resets" behavior was relied upon. -
Prop::match_ontakesimpl MatchOnFields, notimpl Into<String>. The new bound is what lets one call name several fields (match_on(["id", "slug"])), and its impl list is deliberately closed -&str,String,[T; N], andVec<T>only. A blanket impl overIntoIteratoris not available: coherence rejects it against the&strandStringimpls, since nothing stops those types from gaining anIntoIteratorimpl later. Three argument types that compiled before no longer do:&String,Cow<'_, str>, andBox<str>. Pass a&strat the call site instead -match_on(name.as_str())for a&String,match_on(name.as_ref())for aCow<'_, str>,match_on(&*name)for aBox<str>. -
A dotted
only/exceptentry now narrows its top-level prop instead of excluding it entirely. Before this fix,X-Inertia-Partial-Data: user.namemadeshould_include_eagerlook for an exact-match"user"entry, found none, and silently dropped the wholeuserprop - a client asking for one field ofusergot nothing. Any frontend page component that happened to rely on that gap (treating a dottedrouter.reload({ only: [...] })as equivalent to omitting the key) now receives{ user: { name: ... } }instead. No code changes are required - this is what the Inertia v3 protocol already specifies the request/response contract to mean. The same fix applies toshould_include_optional, and its effect is operationally bigger: a dottedonlyentry (permissions.read) now counts as an explicit request for anOptionalorDeferprop's top-level key, which previously required a bare entry (permissions) to trigger at all. A request that used to skip that prop's resolver entirely now runs it - if the resolver hits a database or an external service, a client already sending dotted partial-reload requests starts issuing that work on requests that previously did none. Watch resolver call volume after upgrading if your app hasOptional/Deferprops with dotted partial-reload traffic. -
InertiaSharedData::sharenow takes the page component name. Add acomponent: &strparameter afterreq:-async fn share(&self, req: &dyn InertiaRequestExt) -> Result<IndexMap<String, Prop>, FrameworkError> +async fn share(&self, req: &dyn InertiaRequestExt, component: &str) -> Result<IndexMap<String, Prop>, FrameworkError>Ignore it (
_component) if your provider doesn't need to vary by page - Laravel'sRenderContextcarries the same pairing (component,request) forProvidesInertiaProperties::toInertiaProperties. -
Propis a struct, not an enum. Its variants are gone; construct and read props through methods:Prop::Eager(v)->Prop::eager(v)Prop::EagerNone->Prop::absent()Prop::Always(v)->Prop::eager(v).always()Prop::Lazy(r)->Prop::from_resolver(r)(Prop::lazy(closure)is unchanged)Prop::Optional(r)->Prop::from_resolver(r).optional()match prop { Prop::Eager(v) => … }->prop.as_value()matches!(prop, Prop::Lazy(_))->prop.is_lazy();matches!(prop, Prop::EagerNone)->prop.is_absent()TheDeferConfig,MergeConfig,OnceConfig, andScrollConfigpayload structs are removed - their fields are flags onPropnow.Prop::is_deferred()is renamedProp::has_resolver(), which is what it always meant.DeferOptions,OnceOptions,MergeStrategy,ScrollMetadata, and everyInertiaResponsebuilder method are unchanged, so an app that only uses the response builder needs no edits. Apps that build props by hand - typically anInertiaSharedDataimplementation - need the renames above.
-
This fix protects sessions you already have, not only requests from here on. Upgrading alone is enough: a session cookie written by an earlier release can carry a
_previous.urlthat was never sanitized, andSessionData::previous_url()now discards it on read the first time that session is used post-upgrade, rather than trusting it because it's already stored. You don't need to invalidate existing sessions, migrate the session table, or force a re-login. A request whose path looks protocol-relative (//host) also no longer updates the recorded previous URL going forward - if your app'sfallback!route (or any 200-answering route reachable on an unusual path) ever legitimately relied on such a path becoming theRedirect::back()target, it won't anymore. Either way, the previous, safe value in the session is left in place instead (orRedirect::back(fallback)'s own fallback wins, if nothing safe was ever recorded). No code change is needed unless you were depending on the exact edge case this closes, which was already an open-redirect risk. -
Drop the
[0]from everyerrors.<field>binding in your pages. With the new default shapeerrors.emailis a string, soerrors.email[0]renders its first character instead of the message. Change the TypeScript type fromstring[]tostringat the same time. If you would rather not touch your pages, setInertiaConfig::with_all_errors(true)on the config you pass toInertia::installand add theerrorValueType: string[]module augmentation for@inertiajs/core. The starter frontends ship the new shape. -
A handler that hand-rolled the redirect-back after a validation failure can delete it. The bridge is automatic now; a handler that still redirects itself keeps working, because the middleware only acts on a
422that carries a populatederrorsobject. -
A crashed
suprnova servechild now respawns instead of ending the session. If you relied on a crash stoppingsuprnova serveoutright (a CI smoke check, a script that treats exit as "something's wrong"), pass--no-restartto restore that behaviour exactly. Retries are also bounded by default: a process that crashes 5 times in a row stops being retried (raise the limit with--restart-tries, or use--no-restartfor the original one-crash-and-done behaviour). -
Model::TOUCHESis no longer an inherent const. Code that readComment::TOUCHESdirectly needsuse suprnova::EloquentModel;(orsuprnova::eloquent::EloquentModel) in scope - the const moved there so the parent-touch cascade, aModeltrait default, can read it. Agrep -rn TOUCHESover your app finds every call site; most apps have none, since the const previously did nothing at runtime. -
RelationEntrygained a field. Only code that constructs aRelationEntryby hand needs a change - addrelated_updated_at_columnto the literal. The macro-generated relation registrations the framework ships already emit it, so an ordinary app doing nothing but declaring relations through#[suprnova::model]is unaffected. -
Router::viewwith non-object props now panics at boot. It previously registered silently with an empty prop bag;viewdelegates toRouter::inertia, which requires an object (ornull) and panics otherwise. If aviewcall might carry non-object props, switch toRouter::try_inertiaand handle theErr- otherwise nothing changes for you. -
The Inertia version manifest default can change your version string the moment a build exists. An app or test that hardcodes
X-Inertia-Version: 1.0keeps working only until a Vite manifest shows up on disk; once one does, the version becomes the manifest hash instead. If you need the old constant, read it fromVersionResolver::from_manifest(path)yourself or pin.version(...)explicitly. Expect the first deploy after upgrading to force one full-page reload cycle for already-connected clients - one-time, and the point of the change. The no-manifest fallback value is exported assuprnova::MANIFEST_VERSION_FALLBACK, so you never need to hardcode"1.0"again. -
Move
Inertia::installandglobal_middleware!registration out ofbootstrap::register. Put them in a new function and pass it to.http_bootstrap(...)instead - the scaffold's new shape is a syncregister_http_stack()called as.http_bootstrap(|| async { bootstrap::register_http_stack() }). Apps that skip this keep today's behavior, worker-boot failure on a missing frontend manifest included.
1.2.4 - 2026-08-18
Security
-
The maintenance-mode bypass secret is compared in constant time.
MaintenanceMiddlewarematched the secret URL with a plain string compare, which returns at the first differing byte. Because the secret is a bearer credential carried in the request path, that timing difference told an attacker how long a prefix they had guessed correctly. The compare now runs over the full byte length viasubtle::ConstantTimeEq, short-circuiting only on a length mismatch - the same shape as the bypass-cookie compare next to it. -
rules::Urlnow rejects script URIs. The rule accepted any schemeurl::Urlcould parse,javascript:andvbscript:included, so a validated URL could still be a script-execution sink when rendered into anhref. It now applies Laravel'surlrule shape (Illuminate\Support\Str::isUrl's^(PROTOCOLS)://HOSTpattern): the scheme must be on Laravel's allowlist, be followed by://, and be followed by a non-empty host - Laravel's host group has no?, so an absent or empty host never matches even with a listed scheme. The scheme list and the://-plus-host requirement are Laravel's verbatim; the host itself is parsed by theurlcrate rather than Laravel's regex, so a few edge cases still differ - an out-of-range port is rejected here and accepted there, and IDN hosts normalise differently. NewUrl::protocols(&[...])mirrors Laravel'surl:http,https;HttpUrlis now literal sugar for it and keeps its own message. Behaviour change: a URL with an unlisted scheme that used to validate now fails - name the scheme withUrl::protocols(&["myapp"])if you meant to accept it. Two more behaviour changes:mailto:,data:, andtel:are on Laravel's allowlist by name but don't carry an authority component, so they now fail; andfile:///etc/passwd-style paths -scheme://with nothing between the last two slashes - now fail too, since an empty string isn't a host either. Both follow from Laravel's own://-plus-host rule. -
Inertia responses now advertise
Vary: X-Inertiaeverywhere. The header was set only on the page-object responses themselves. Redirects, 404s, 422s, and static responses carried none, so a shared cache keyed on the URL alone could serve the JSON page object to a hard browser navigation, or the HTML shell to an Inertia XHR. The newInertiaHeadersMiddleware- registered byInertia::installas the outermost of the three - sets it on every response, and turns an empty200on an Inertia visit into a303back rather than a response the client rejects as non-Inertia.InertiaVersionMiddlewarenow re-flashes the session before its409, so a flashed error survives the client's follow-up full-page GET. -
Three Inertia response fixes.
InertiaResponse::location_for(&req, url)returns409+X-Inertia-Locationfor an Inertia XHR and a plain302+Locationfor a hard navigation, so an OAuth or SSO bounce entered outside the SPA no longer dead-ends on a body-less409. The existinglocation(url)keeps its always-409shape. NewApp::clear_history()flashes the history-clear flag into the session so it survives the logout redirect and lands on the page that actually renders - the per-response.clear_history()marked only the redirect the browser throws away, leaving the previous session's encrypted history decryptable. And aonceprop is now skipped only on a full Inertia visit: an explicitrouter.reload({ only: ['stats'] })re-resolves it instead of returning nothing. -
The SES transport now sends custom message headers.
Mail::to(..) .header("List-Unsubscribe", ...)andMailable::headers()were dropped silently underMAIL_DRIVER=ses: theContent.Simplerequest body had noHeadersfield and the raw-MIME builder never readOutgoingMessage:: headers, even though every other transport forwards them. Both SES paths now carry them -Headersas SES v2's{Name, Value}list, raw MIME as real header lines - so unsubscribe links, threading headers and routing hints survive a driver swap. Header names are validated up front on both paths - CR, LF and NUL (the injection bytes, as the Mailgun transport already refuses) and anything that is not a valid RFC 5322 field name (spaces, colons, non-ASCII) - so attaching a file never changes whether a message is accepted.
Fixed
-
Nested validation failures now reach the 422 body.
#[validate(nested)]failures on a nested struct or on an element of a validatedVec<T>were dropped between the validator and the response: the request was correctly rejected with 422, but theerrorsmap came back empty, so no message rendered and the client could not tell which field was at fault. Nested failures are now flattened into Laravel's dotted notation -address.street,items.1.name,order.items.2.sku- alongside the top-level ones. -
The Inertia page object's
urlkeeps the query string.page.urlwas the request path only, so the client recorded/usersfor a visit to/users?page=2&sort=name. Every back/forward navigation and everyrouter.reload()then replayed the page without its pagination cursor, sort, or filters. It is now path plus query - the same derivationInertiaVersionMiddlewarealready used forX-Inertia-Location, so by default the two agree byte for byte. NewInertiaConfig::url_resolver(...)overrides how the page object names the page (Laravel'sInertia::resolveUrlUsing); the version bounce keeps naming the URL that arrived, because that is the URL the browser has to fetch. -
Inertia::installnow applies its config to every response. The config handed toInertia::installwas read for three fields and then dropped, so everyInertiaResponsebuilt without an explicit.with_config(...)rendered fromInertiaConfig::default(). An app scaffolded with--frontend reactserved the Svelte entry point and no React refresh preamble unlessSUPRNOVA_FRONTENDwas set in the environment; SSR enabled on the config never reached a response; and the page object's asset version came from a different config than the version middleware's resolver. The installed config is now retained on the container's Inertia registry and is whatInertiaResponse::newstarts from. Per-response.with_config(...)still overrides, apps that never callInertia::installare unchanged, and a failed (fail-closed) install retains nothing. As a side effect the production Vite manifest is now parsed once per process rather than once per response. -
Scaffolded apps now install the Inertia protocol middlewares. The
bootstrap.rswritten bysuprnova newregistered the session, locale, CSRF and include middlewares but never calledInertia::install, so a generated app had neitherInertiaVersionMiddlewarenorInertia303Middleware: a browser still running the previous bundle was never told to reload after a deploy, and aPUT/PATCH/DELETEthat redirected stayed on a302the client could follow with the original verb. The call now lands afterSessionMiddleware- where the version middleware's session re-flash works - with a namedINERTIA_VERSIONconstant to bump when assets change, and it pins the frontend the project was generated with (.frontend(Frontend::React)for--frontend react), so the HTML shell loads that framework's Vite entry point instead of falling back to Svelte's. The generated.envnow setsSUPRNOVA_FRONTENDto match. The--apistarter is unchanged; it has no frontend. -
Queue::push_uniqueno longer reports a queued job as skipped. The return value was computed withmatches!(outcome, Idempotent::Fresh(())), which foldedIdempotent::FreshUnfencedintofalse- the outcome where the envelope was pushed but the dedupe lease was lost mid-push. Callers branching on that boolean were told a job that was about to run had been suppressed as a duplicate. All three outcomes are now matched exhaustively: a lost lease returnstruewith awarnnaming the job and its unique key, and only a real duplicate returnsfalse.push_unique_laterandlater_uniqueshare the path and are fixed with it.
Changed
- Parity baseline moved to Laravel 13.25.0. The 13.23.0, 13.24.0 and
13.25.0 release notes were traced item by item to the framework's own
surface. Everything that reached a Suprnova code path is either fixed in
this release or has a row in
manual/parity.mdmarkednot yetorby design no.
Upgrading
Two changes can alter a running app without any code change on your side.
-
Settings on the config you pass to
Inertia::installnow take effect. They were read for three fields and dropped. If your install config sets.ssr(...), SSR is now on: start the worker (suprnova ssr:start) before deploying, or drop the.ssr(...)call..entry_point,.assets_base_url,.default_titleand.encrypt_history(...)set there also reach the page now. -
rules::Urlrejects more. Values that used to pass and no longer do: any scheme outside Laravel's allowlist,javascript:andvbscript:among them;mailto:,data:andtel:, which are on the allowlist but carry no://host; andscheme://with an empty host, such asfile:///path. If you meant to accept a scheme, name it:Url::protocols(&["myapp"]).
1.2.3 - 2026-08-16
Fixed
- Datetime casts now read database-native
CURRENT_TIMESTAMPtext.AsDateTime,AsImmutableDateTime, andAsOptionalDateTimecontinue to write canonical RFC-3339, while reads also accept PostgreSQL's timezone-bearing text and timezone-free SQLite/MySQL text. Timezone-free values are interpreted as UTC, matching the framework's UTC timestamp contract.
1.2.2 - 2026-08-14
Fixed
- Nullable non-text values now work across attribute-based writes on
PostgreSQL. Typed
Builder::update_allandBuilder::upsert, model-lessDB::table().insert/update, and many-to-many pivot extras render explicit JSON nulls as SQLNULLwhile continuing to bind every non-null value. This preserves the target column's type instead of sending a text-typed null parameter that PostgreSQL rejects for bigint, integer, boolean, timestamp, and other non-text columns. Multi-row upserts now also reject missing or extra columns instead of silently converting a malformed row shape to null. Automatic many-to-many pivot timestamps are bound as typed UTC datetimes instead of text.
Security
- The release gate now distinguishes dormant lockfile metadata from compiled
dependencies across the whole workspace. Cargo records rust_decimal's
unused optional rkyv 0.7 compatibility dependency in
Cargo.lock; the gate now proves that neither rkyv nor its derive crate is reachable from any workspace member, feature, target, or dependency edge. The corresponding RustSec exception is owned, expires on 2026-11-14, and must be removed when rust_decimal no longer records that legacy optional dependency.
1.2.1 - 2026-08-09
Changed
- Suprnova moved to the
eas4aiGitHub organization. Repository URLs in package metadata, documentation, dependency examples, and scaffold templates now usegithub.com/eas4ai. New projects also use the monitoredshawn@eas4ai.comauthor email. This release made no runtime behavior changes.
1.2.0 - 2026-08-05
Added
-
The manual ships in seven languages.
manual/es/,manual/fr/,manual/de/,manual/pt-BR/,manual/ja/andmanual/zh-Hans/each carry the full 104-chapter manual - every chapter, the table of contents, and this changelog - translated from the English source. English remains canonical: chapter structure, code blocks, identifiers, CLI commands and environment variables are held byte-identical to the source, so a translated chapter can never disagree with the English about what the framework does, only say it in the reader's language.The translations were produced and reviewed for suprnova.app, which renders this manual as its
/docs. Every section carries a review ledger there: verdicts are recorded against content hashes of both the English and the translation, two independent reviewers must pass the exact bytes for a section to count as approved, and per-locale glossaries pin the terminology rulings (which terms stay English, which take the native word, and why). Corrections are welcome in either repo - a fix here reaches the site on its next sync.
1.1.0 - 2026-08-02
Added
-
Per-locale fallback chains.
LocalizationConfiggainsparents(APP_LOCALE_PARENTS, comma-separatedchild=parentpairs, or the chainable.parent(child, parent)builder): a locale can inherit from a configured sibling before falling further back to the globalfallback_locale-pt-PTfrompt-BR,en-AUfromen-GB, and so on, transitively.Lang::get/try_get/get_with/try_get_with/hasall walk the chain, current locale first, so this works for anyTranslatordriver, not just the bundled one. A malformed pair, an invalid locale, a child named twice, or a cycle (including a locale naming itself as its own parent) fails loudly at config load rather than degrading at request time.Served catalogs stay chain-flattened ahead of time:
FluentTranslatornow builds each locale's/_suprnova/lang/<locale>.ftlcatalog as a fold - the embedded framework catalog at the bottom foren/en-*locales, then the locale's configured parent chain, then its own*.ftlfiles - so a chained locale is still one self-contained file the browser fetches once, with no client-side chain awareness needed. Flattening covers configured parents only; the terminalfallback_localeis still aLang-facade-level fallback, not baked into the served bytes.This makes delta-style catalogs practical: a
lang/pt-PT/directory can hold only the handful of strings that actually differ fromlang/pt-BR/, rather than a full duplicate catalog. The merge that makes it possible works at the Fluent AST level - a child's value replaces the parent's, attributes merge by name (an override that doesn't mention an attribute no longer loses it), select expressions replace whole (CLDR plural categories are locale-dependent, so variant-by-variant merging isn't coherent), and child-only entries append. Seemanual/localization.md's new "Fallback chains" section for the full contract.
Changed
LocalizationConfiggained theparentsfield.from_env()and the builder are unaffected; a literal struct constructor (tests building aLocalizationConfigby hand) needs one more field.- Served catalog text is now serializer-normalized for every locale,
and intra-locale multi-file merging (several
.ftlfiles in one locale directory) now goes through the same AST-level merge as parent chains rather than simple bundle-overriding. Resolved translations are unchanged except for the two strict improvements below; the underlying bytes rotate regardless -ETag/?v=<hash>rotates once on upgrade. The improvements: an override no longer silently drops the attributes it doesn't mention, and an attributes-only override no longer strips the message's own value (previously an error or a fallback resolution; it now resolves to the earlier override's value).
1.0.0 - 2026-08-02
Added
-
Localization. Message catalogs in
lang/<locale>/*.ftl(Fluent), aLangfacade with the__!("key", name: value)macro, per-request locale detection (LocaleMiddleware: session → cookie →Accept-Language→APP_LOCALE), and locale-aware formatting for numbers, currency, dates, times, lists, and relative times over ICU4X.manual/localization.mdis the chapter.The built-in validation rules stop hardcoding English. Each returns a keyed message (
validation-minplus its arguments and an English fallback), translated once at the serialization boundary - so a Spanish app gets Spanish validation errors by dropping inlang/es/validation.ftl, with no rule wrapping and no forked copy of the framework's messages. Field names humanize through afield-<name>lookup.Rule::passes(andContextualRule/AsyncRule) now returnResult<(), ValidationMessage>; a custom rule'sErr("…".into())body still compiles and still renders verbatim, but the signature in yourimplneeds the new type.The browser gets the same bytes the server resolved: the merged catalog is served at
/_suprnova/lang/<locale>.ftlwith an ETag and an immutable?v=<hash>form, the three starter kits parse it with@fluent/bundle, andsuprnova generate-typesemits aMessageKeyunion so renaming a message points the TypeScript compiler at every call site.Fluent rather than Laravel-style PHP arrays because one format has to serve both the server and the browser, and because CLDR plural categories are what gets Russian, Polish, and Arabic right -
trans_choice's integer ranges cannot, which is why there is notrans_choicehere. Behind a default-onlocalizationfeature;--no-default-featuresstill compiles and still validates, using the embedded English fallbacks. -
IntoInertiaScrollforPaginator. The trait was implemented forLengthAwarePaginatorandCursorPaginatorbut not for the simple paginator, sosimple_paginateresults could not feedInertia::paginateat all - despitesimple.rs's own module docs pointing at it as the URL-generation path. That left offset-paginated Inertia collections with a choice between aCOUNT(*)per request and hand-rolling the scroll metadata.next_pagecomes from theLIMIT n+1overflow probe rather than a computed last page, there being no total to compute one from.
Fixed
-
suprnova generate-typesemitted a different file on every run. The topological sort seeded its work queue by iterating aHashMap, and Rust randomises hash iteration order per process, so consecutive runs ordered the same interfaces differently. The output is a checked-in artifact, so every run produced a diff - and a generated file that churns for no reason is one people stop regenerating, after which it quietly stops describing the Rust it claims to. The directory walk is sorted too, so the output no longer depends on filesystem order either. Two runs of the same source are now byte-identical. -
topological_sortdid the opposite of its doc comment, emitting dependents before dependencies. Harmless - a TypeScript interface may reference one declared later in the same file - so the comment is corrected rather than the order, which would have reshuffled a tracked file for no benefit.
0.9.1 - 2026-08-01
Three defects, all found by running the dogfood app under a containerised harness rather than by reading the code. Every one of them is invisible to a test suite that never stops a process the way production stops it.
They compound in a specific order: a rolling deploy SIGKILLs a worker mid-job (the first), and that job then takes a reclaim path that never counted the attempt (the second).
Fixed
-
schedule:work,queue:workandworkflow:workignored SIGTERM. Each selected ontokio::signal::ctrl_c()alone, which installs a SIGINT handler - so SIGTERM had no handler anywhere in the process, and SIGTERM is whatdocker stop, Coolify, systemd and Kubernetes send. All three already had a careful bounded drain behind thatselect!; none of it had ever executed under a supervisor. Measured before the fix: adocker stopon aqueue:workcontainer burned its whole 40s grace window and exited 137 with the in-flight job destroyed. As PID 1 - which is what a container runs - the kernel discards an unhandled SIGTERM outright, so the process did not die badly; it did not die at all until SIGKILL.Server::runalready handled both signals correctly and its listener is now shared, which also closes a missed-signal window in the scheduler's loop. -
A job that killed its worker could never be dead-lettered. A job whose handler fails is nacked and its attempt counted, so it dead-letters after
max_tries. A job that kills its worker - OOM, abort, segfault, or the SIGKILL above - settles nothing; its reservation merely lapses, and every driver used to redeliver it byte-identical. Such a job is immortal: it kills each worker that claims it, comes back unchanged, and kills the next one, for as long as anything restarts workers. All three drivers now charge the attempt where they learn a worker died, because swappingQUEUE_DRIVERmust not change whether a poison job can be stopped.attemptsnow means "deliveries to a worker" rather than "handler failures" - documented inmanual/queues.md, because a worker lost for unrelated reasons burns an attempt too. -
…and the exhausted job is now dead-lettered before it is dispatched. Counting the attempt was necessary and not sufficient. Every dead-letter decision lived in the worker's settlement path, which assumes the handler returns - so it never ran for exactly the jobs that could not return. With the driver fix alone the counter climbed (measured: 0 → 1 → 2 across three killed workers) and nothing acted on it. The budget is now spent before the handler runs. Caught only by re-running the container experiment after the first fix looked correct.
-
The daemons had no tracing subscriber.
servegets one frominit_telemetry;queue:work,schedule:work,schedule:runandworkflow:workcome through a different boot path and got nothing, so everytracing::line they emit went nowhere andLOG_LEVELwas inert for them. That is most of what they have to say - a worker dead-lettering a job, a scheduler skipping a tick it lost, a lock it could not release. In a container the only visible output was the startup banner, and the process looked idle while doing all of it. Two of the defects in this release were invisible until this was fixed. -
A dead-letter with no failed-jobs store bound was a silent deletion. The persist step sat inside
if let Some(store) = .., so with no store the arm did not match and execution fell through to the ack - quieter than the failure path directly above it, which at least leaves the reservation intact. An absent store was treated as more successful than a broken one. It now logs the full envelope at ERROR, because that is whatqueue:retryre-pushes: the difference between work recoverable by hand and work that ceased to exist. -
QUEUE_DRIVER=databasenow binds a failed-jobs store.failed_jobsis part of that driver's contract -queue:retryreads it andQueue::retry_failedcannot work without it - butbootstrap_from_envwired the driver and left the store unset, so a database-backed queue dead-lettered into nothing unless the app bound one by hand. Configurable viaQUEUE_FAILED_DB_TABLE. Only for this driver:memoryis ephemeral by construction andredishas no table to write to. -
Redis reclaim latency now follows
--visibility-timeout. The flag sets XAUTOCLAIM's idle threshold, but a separate clock governs how often a consumer looks, and the driver left it at sea-streamer's 30s default - so--visibility-timeout 5really meant "up to 35 seconds". The interval now tracks the configured timeout, clamped to 1s..=30s so a short timeout cannot become an XAUTOCLAIM storm and a long one can only make reclaim faster than before.
Added
-
TaskBuilder::on_one_server()/on_one_server_for(ttl)- run a scheduled task exactly once per due tick across replicas. Without it nothing elects a leader for a tick: eachschedule:workprocess evaluates the schedule independently, and three replicas were measured running every due task three times, every minute, with no variance. A nightly billing job on three replicas billed every customer three times.without_overlapping()does not cover this and cannot: its lock is keyed on the task and released when the handler returns, so a fast task frees it before a second replica looks.on_one_serverkeys on the task and the tick and holds the lock past the handler, letting it expire on TTL. The two compose.Opt-in, matching Laravel. Diverges from Laravel in failing closed: the election is only as shared as the cache behind it, so a production boot with
CACHE_DRIVER=memoryand a single-server task is refused, naming the offending tasks, withSCHEDULE_ALLOW_MEMORY_LOCK_IN_PRODUCTION=truefor deployments that genuinely run one scheduler.
Changed
manual/deployment.mdno longer says "run exactly oneschedule:workprocess" as the only option, and gains a Stopping cleanly section covering the drain windows per subsystem, how to size a platform's termination grace above them, and why PID 1 makes a missing signal handler worse than it sounds.
0.9.0 - 2026-07-31
Security
-
Auth issuance could only be throttled per caller, never per recipient. An address-keyed limit answers "is one client noisy"; it cannot answer "is one mailbox being flooded". An attacker spread across a botnet or a single IPv6
/64stayed under every per-IP budget while filling one victim's inbox with password-reset mail, and nothing in the framework could express the limit that would have stopped it - a key function could read the path, headers, and query string, but not a form-encoded body, so the address was invisible on exactly the route that carries it.identity_keykeys a bucket on the account being acted on. It reads the query string first and then a buffered form body, so one key function covers both shapes; the value is trimmed and lowercased, becauseAlice@Example.comreaches the same mailbox asalice@example.comand a limit bypassed by holding down shift is not a limit; and it is hashed, because a rate-limit backend is frequently a shared Redis with weaker access control than the primary database.Two new middleware builders support it.
key_reads_body(cap)buffers the body before keying - opt-in, because buffering is work an unauthenticated caller gets to make you do, and a body over the cap is refused with 413 rather than passed through unkeyed.only_when(pred)skips a limiter entirely for requests it has nothing to say about, which is what keeps a stacked per-recipient budget from silently becoming the binding limit on routes that name no recipient.The dogfood app now stacks both on its issuance group: 10 per 5 minutes per address, 3 per 15 minutes per recipient.
A review of Torii's session, password, OAuth, and passkey paths turned up
eight defects, all fixed in the pinned fork (suprnova-torii-rs 968b0be).
- Expired sessions could be refreshed back to life. The SeaORM session
repository's
refreshhad no expiry predicate and unconditionally extendedexpires_at, andOpaqueSessionProvider::refresh_sessionskipped theis_expired()check thatget_sessionperforms. A token held past its expiry could be renewed indefinitely. Fixed at both layers. Not reachable through Suprnova's own surface - neitherToriinor the framework exposes session refresh - but it is public API of both crates. - The login form leaked which accounts exist, by timing. Authentication returned as soon as the email missed, skipping Argon2 entirely: measured at 54µs for an unknown address against 719ms for a wrong password, a ~13,000x gap readable over a network. Both failure paths now verify against a dummy hash so they cost the same. This one was reachable through Suprnova's password login.
- The JWT
issclaim was written but never verified. Algorithm pinning was already correct -alg: noneand HS/RS confusion were never possible - but the issuer was decoration, so two services sharing a signing key would accept each other's sessions. Now enforced when an issuer is configured. - A single-use PKCE verifier could be claimed twice. Consumption was a
read followed by a delete, so two OAuth callbacks for the same
csrf_statecould both read it before either delete landed. Now claimed in one operation -DELETE ... RETURNINGon Postgres, a primary-key delete whose affected-row count picks the winner on SeaORM. - Expired sessions were listed as active.
find_by_user_idhad no expiry filter, and expired rows survive until cleanup runs, so a "devices you're signed in on" screen offered users dead sessions to revoke while saying nothing about the live one. - A passkey lookup was named
authenticate. Torii'sPasskeyService::authenticate_credentialtook a credential ID and returned the owning user, andPasskeyAuth::authenticateminted a session from it. Torii stores passkeys - it carries no WebAuthn dependency and cannot verify an assertion, so the only thing those calls proved was that the caller knew a credential ID: a value the browser sends in the clear andallowCredentialshands to anyone who can start a ceremony. Renamed tofind_user_by_credentialandcreate_session_for_verified_credential, both documenting that verification is the caller's job. Not reachable through Suprnova, which driveswebauthn-rsitself (seetorii_integration::passkey) and reaches Torii only for credential storage. - A WebAuthn challenge was replayable for its whole TTL. Neither backend
consumed a challenge on read, and the SeaORM
get_challengealso ignoredexpires_atentirely, returning expired challenges as live. Reads now exclude expired rows on both backends, and a newtake_challengeclaims one exactly once - the same delete-decides-the-winner shape as the PKCE fix.
Breaking
-
Azure Blob Storage and Google Cloud Storage moved behind the new
filesystem-azureandfilesystem-gcsfeatures.Storage::register_azblob,register_azblob_with,register_gcs,register_gcs_with,AzBlobConfigandGcsConfigno longer exist unless you enable the matching feature. If you use either backend, add it to your dependency:suprnova = { git = "…", tag = "v…", features = ["filesystem-gcs"] }You get a compile error naming the missing item, not a runtime failure.
Both opendal service crates pull
rsa, which carries RUSTSEC-2023-0071 (the Marvin timing attack) with no fixed release upstream. They were the only crates enablingreqsign-core/jwt, the featurereqsign-core's optionalrsasits behind, so gating them severs all three opendal paths to it at once.rsais now avoidable:--no-default-features --features filesystem,database-postgresresolves without it and still has the storage subsystem. Previously no feature combination could shed it while keeping storage at all.A stock default build still carries
rsa-database-mysqlis a default feature andsqlx-mysql 0.8.6depends on it non-optionally - so the audit exception stays open. S3 is deliberately not gated:reqsign-aws-v4takesreqsign-corewithoutjwt, so the S3 driver never contributed a path, and gating it would break the most-used cloud backend while removing nothing.
Added
suprnova --version, with-vas well as clap's default-V. Asking a CLI its version with the flag every other CLI uses should not print a usage error.
Fixed
- Two Redis operations had no upper bound. The cache's tag flush read a
tag's whole member set with
SMEMBERSand deleted key by key, so a tag with a large membership stalled the connection and a concurrent write could be lost between the read and the delete; tags are now generation-based, flushed atomically, and scanned with a boundedSSCAN. The delayed-queue promotion pass moved every due job in one unboundedZRANGEBYSCORE, so a backlog that came due together produced a single enormous script; it now promotes in batches. - Two shutdown drains waited forever.
schedule:workon Ctrl-C and the workflow worker after cancellation both awaited every in-flight task with no deadline, so one task that never returned held the process open untilSIGKILL- an operator sees a daemon that "doesn't stop". Both now wait a bounded grace, then abort what remains and report the count. - The release version-pin sweep only recognised one of the two pin
syntaxes, so every file carrying a
cargo install --tag vX.Y.Zline and no dependency snippet was never discovered.suprnova-cli/README.mdhad been telling readers to install v0.6.0 for three releases;manual/cli.mdandmanual/cli-new.mdsat at v0.7.2;manual/installation.mdcarried both forms and had one bumped while the other froze. Discovery and rewrite now read from one pattern table, and a file's rules are derived from its content. cargo docfailed for any build withfilesystembut withouttesting- sevenStorage::fakeintra-doc links could not resolve, andlib.rsdenies broken links.testingis a default feature, so no gate step had ever built that combination;check-feature-matrix.shnow does.- Torii's migrations could not be replayed over their own schema, so a
database holding it without the
torii_migrationstracking table - restored from a dump that skipped it, or migrated by hand - could not be brought under management. EveryTable::create()carried.if_not_exists(); none of the 19Index::create()calls did, nor did theADD COLUMN locked_atalter, so replay sailed through the tables and died on the firstCREATE INDEX. Fixed in the pinned fork (suprnova-torii-rsa0f956d) viahas_index/has_columnrather thanIF NOT EXISTS, which sea-query silently drops for MySQL - the syntactic fix would have left a default-featured build broken. - A failed Torii migration aborted the process instead of returning an
error.
SeaORMStorage::migrateunwrapped the migrator and returnedOk(())unconditionally, soinit_torii's mapping of the failure into aFrameworkErrorwas unreachable code. - An app's own
userstable silently suppressed Torii's, because.if_not_exists()cannot tell "already mine" from "already somebody else's". The migration reported success and authentication failed later on a missing column - the reason the--apistarter names its tableapp_users. Torii's migration now warns at migrate time when an existinguserstable lacks columns it requires, naming the columns and the remedy. It stays a warning rather than a hard failure so existing deployments keep booting. - The Railway and DigitalOcean deployment guides pointed the platform
health check at a path that could probe Postgres. Both platforms restart
the container when that check fails, so following the advice turned a
database blip into a restart loop across every replica. Both now use
/_suprnova/health/live, with the database probed by hand from the console. The legacy paths still resolve; nothing already deployed needs changing.
0.8.0 - 2026-07-30
Remediation of an external red-team audit. The audit returned 19 P1 findings and a NO-GO verdict for 1.0; this release closes all nineteen, plus a number of defects found while fixing them that the audit had not named.
Several fixes deliberately turn a silent misconfiguration into a refused boot. Read Upgrading before deploying - a production app that has been running happily may not start.
Upgrading
Three configurations that used to boot with a warning (or in silence) now fail closed in production. Each error names the variable that unblocks it, and each has an explicit override for the deployment where the risk is genuinely absent.
- A non-delivering mail driver.
MAIL_DRIVERunset,log,memory, or an unrecognised value all resolved to a transport that renders mail and discards it - so password resets reported success while nothing was sent. Override:MAIL_ALLOW_NON_DELIVERING_IN_PRODUCTION=true. - Cleartext SMTP. Three of the four credential combinations landed on
an unencrypted transport, and the both-unset case logged a warning and
sent anyway. Override:
MAIL_ALLOW_INSECURE_SMTP_IN_PRODUCTION=true. - The in-memory rate limiter. Its buckets live in one process's heap,
so behind N replicas every quota is really N× and each deploy resets
them. Point
RATE_LIMIT_DRIVERatredis, or setRATE_LIMIT_ALLOW_MEMORY_IN_PRODUCTION=trueif you genuinely run one process. An unrecognised driver value fails for the same reason, because it fell back to memory -RATE_LIMIT_DRIVER=Redis, capitalised, is the case most likely to reach production because it looks configured.
Development, testing and staging are unchanged in all three cases. Staging is deliberately not gated: hard-failing it pushes teams to set the override globally, which disarms the check where it matters.
Two behaviour changes that are not boot failures:
fillandfirst_or_newreject malformed values. A value that cannot decode into its field's type used to become that field'sDefaultand returnOk-fill(attrs!{ age: "abc" })setage = 0and reported success. It now returns aValidationErrornaming the field, and leaves the model untouched. Unknown columns are still skipped silently (Laravel parity), and numeric widening still works./_suprnova/health?db=trueno longer returns the driver error. The detail moves to the log; the body keeps"database": "error". Debug builds still include it. Dashboards parsingstatus/databaseare unaffected.url::signature_has_not_expirednow requires a valid signature, and is deprecated. It used to answertruefor a forged URL - a bad signature is not "expired", because it never had an expiry to miss - so any handler guarding on it alone accepted forgeries. It is now identical tohas_valid_signature. If you were using it to tell expired from invalid (to render "request a fresh link" rather than a 403), switch tourl::signature_verdict, which returns all three states. This diverges from Laravel'sURL::signatureHasNotExpired, deliberately.
Two additions that need something from you only if you opt in:
QueueDrivergainedsettleandrelease, both with default implementations, so existing driver impls keep compiling unchanged. Implementsettleif your backend can commit a follow-up write and an acknowledgement in one transaction; implementreleaseif it can requeue a reserved message in place.- Batch accounting can now be durable.
DatabaseBatchRepositoryneeds two new tables,job_batchesandjob_batch_settlements- add them to your migrations, as withjobsandfailed_jobs. The schema is inmanual/queues.md. Nothing changes if you stay onMemoryBatchRepository.
Security
-
Slowloris (SEC-07). hyper's header-read timeout was documented as 30s but inert - it only arms when a timer is installed on the connection builder, and none was. A client could hold a connection, and a
SERVER_MAX_CONNECTIONSpermit, indefinitely. Now armed and configurable viaSERVER_HEADER_READ_TIMEOUT. -
Multipart uploads (SEC-05). The cap applied to individual part payloads but not to the raw stream, so a body could exceed the limit in aggregate. Now capped at the stream.
-
Webhook HMAC with an empty key (SEC-08). Both payment adapters accepted a blank secret, which verifies anything. Refused on both.
-
Paddle signature parsing (P2-11). An odd-length or non-hex
paddle-signaturereached the pinned SDK and panicked inside it. Now validated first: a malformed signature is a 401. -
Passkey enrolment and reset tokens (SEC-01, SEC-02). Anonymous enrolment against an existing email, non-owner enrolment, and owner enrolment without recent reauth are each refused with distinct statuses. A password login now stamps the reauth window.
-
dev:tls(SEC-10). A project could choose the CA the command trusts. -
Generated Docker Compose (P2-12). Published Postgres and Redis on all interfaces with credentials committed in this repository. Now bound to loopback with per-scaffold generated passwords,
.envwritten 0600, and symlinked targets refused. -
Health endpoint (P2-01, CI-05). It decided whether to query the database with
query.contains("db=true")- a substring test, so?nodb=trueran the probe too. Now parsed properly. The 503 no longer embeds the driver error, which named hosts, ports, schemas and versions. -
Credential issuance throttling (P2-02). The four auth-issuance routes in the reference app carried no rate limit at all, and the one route that did keyed its bucket on the raw
x-forwarded-forheader - which any client can vary per request to get a fresh bucket. Both fixed; the issuance budget is shared across the four routes so rotating between them does not multiply it. -
A redelivered chain step re-pushed its successor under a new id (DATA-02b, partial). Settlement pushes the next chain link before acking, deliberately: acking first means a crash in that window loses the chain permanently, and a duplicate is recoverable where silent loss is not. But the successor's envelope got a fresh
Uuid::new_v4()on every push, so the duplicate produced by that trade was indistinguishable from a legitimate new step - to the driver, to an outbox, and to the handler.That last one is the real cost. The framework's delivery contract is at-least-once and its answer to duplicates is "handlers must be idempotent" - but a handler keyed on
env.id, the only identifier it receives, could not satisfy that contract for a chained job, because the duplicate arrived under a new id every time. The contract was unsatisfiable by construction.The successor's id is now a UUIDv5 derived from its predecessor's, which is stable across that predecessor's own redeliveries. A redelivered step re-pushes the id it pushed before. No schema change, no new field, no new dependency.
This makes the duplicate detectable, which is the primitive the rest of DATA-02b was missing. It does not make the push atomic with the ack (that needs the outbox), and nothing yet rejects the duplicate on the way in. Both remain open.
-
Signed URLs verified one URL and executed another (SEC-04). The canonical form collapsed query pairs into a map, so a repeated key kept only its last value - while
Request::query_paramreturned the first. A legitimately signed?user=victimcould therefore be replayed as?user=attacker&user=victimwith the original signature untouched: verification canonicalised overvictimand passed, and the handler acted onattacker.The canonical form now carries every pair, sorted by
(key, value), so the signature covers the exact multiset of parameters - adding, removing, or substituting any value breaks the HMAC. A repeatedsignatureorexpiresis refused outright, since two of either leaves no non-arbitrary answer to which one governs.Request::query_paramnow resolves a repeated key to its last value, matchingquery_paramsandContext::query_param; it was the only one of the three that disagreed, and that disagreement was the other half of the defect. Existing signed links keep working - with no repeated keys the payload bytes are unchanged, which a test pins, because a canonical-form change that silently invalidated every outstanding password-reset link would be worse than the bug.Six regression tests, including both attack orderings, a legitimately repeated key that must still sign and verify, and the reordering guarantee. Not changed:
signature_has_not_expiredstill reports a forged signature as "not expired". That is Laravel's behaviour, was settled deliberately as a documentation fix, and has its own test pinning it against a well-meaning "correction". -
RBAC under Postgres. Verified against a real Postgres rather than SQLite alone.
-
Four RustSec advisories eliminated, not renewed. The Pinecone driver was rewritten against Pinecone's REST API, dropping
pinecone-sdk 0.1.2- whose newest release dates from 2024-09-06 - and with ittonic 0.11 → rustls 0.22 → rustls-webpki 0.102and RUSTSEC-2026-0049 / -0098 / -0099 / -0104. All four were fixed upstream inrustls-webpki >= 0.103.13, which this workspace already resolved for its other TLS users; one abandoned crate held the tree on the vulnerable line..cargo/audit.tomlis down from five ignores to one. See Changed for what this means for the driver's API. -
Audit exceptions now expire. Every entry in
.cargo/audit.tomlcarries anOWNERand anEXPIRESdate, andscripts/check-audit.shfails the release gate on a missing owner, a missing or unparseable date, or a lapsed one.cargo audithas no notion of an expiring ignore, so one added "temporarily" stayed until somebody re-read the file. The remaining entry (RUSTSEC-2023-0071,rsa, which has no fixed release at all) is owned and dated. -
Reachability claims are checked, not asserted.
scripts/check-feature-matrix.shresolves real dependency trees and asserts that no build - including--all-features, which is whatcargo auditactually reads - containspinecone-sdk,rustls-webpki 0.102.xortonic 0.11.x. An exception justified by a comment nothing verifies stops being true the first time someone adds a dependency.
Fixed
- Every release on a database-backed queue was silently a no-op.
JobOutcome::Released- a busyWithoutOverlappinglock, a rate-limiter backoff - was implemented as "push a copy, then ack the original". The envelope id is thejobstable's primary key, so the copy collided with the row still holding the live reservation and the push failed withUNIQUE constraint failed: jobs.id. The worker then correctly declined to ack, so the requested delay was never applied, noJobReleasedevent fired, and the job simply parked until visibility expiry redelivered it. Releases are now one driver call, done in place. - A partial batch dispatch orphaned the jobs it had already queued
(DATA-02). When a
driver.pushfailed mid-loop,PendingBatch::dispatchdeleted the batch row - but the envelopes already in the queue were still stamped with that batch id, so each of them settled against a batch that no longer existed, returningErr(batch not found)on every delivery, forever. The batch is now settled instead: undispatched jobs are recorded as failures and the batch is cancelled, so the queued ones settle normally and the terminal callbacks still fire. - Nothing tested that
url::has_valid_signaturerejects a forged URL. Found while verifying the SEC-04 fix: the entire framework suite passed with the primary signed-URL guard rewritten to accept any signature. - A scaffolded app could not migrate its database or build its image
(REL-01b). Neither scaffold declared
default-run, so all nine CLI wrappers that shell out tocargo runfailed on a fresh project. The generated Dockerfile had five independent defects - a missing lockfile COPY,npm ciwithout a lock, a cache stage stubbing one of two declared binaries, a frontend build copied from a path vite never creates, and a missingfrontend/src/pagescopy thatinertia_response!validates at compile time. A stock scaffold's image could not build. docker:initemitted one Dockerfile for every project type. On an--apiproject its first instruction,COPY frontend/package.json, failed outright. API projects now get a frontend-free Dockerfile.- SQL placeholders (DATA-01). Rendered per backend rather than assuming one dialect.
- Queue settlement (DATA-02a, P2-06c). Follow-ups settle before the reservation is acked, and a lock-release error no longer converts an already-succeeded job into a retry.
- A cancelled batch fired
Catch, neverThen. Builder::clonesilently dropped the eager-load plan (P2-09a).User::query().with("posts")cloned anywhere - pagination,count(), any scope that clones - returned rows with no relations and no error.- Presence rosters lost members (P2-08). The roster was snapshotted before subscribing, so anyone joining in that window appeared in neither, permanently.
- Pinecone serialised every index acquisition (P2-14). The write lock
was held across two network round trips, and
tokio's fairRwLockmeant one cold index stalled every warm one. - The type watcher discarded bursts (P2-13). Leading-edge debounce regenerated on the first file of a burst and dropped the rest with no trailing run, so the last save never took effect.
ssr:checkcould hang, and tried one address (P2-13). DNS ran outside the timeout entirely, and only the first resolved address was tried - so a host with an AAAA record and no IPv6 route reported the worker down while it was listening on v4.suprnova serveinstalledcargo-watchunpinned (P2-13). Now--lockedwith a major-version bound.- The release bumper rewrote five READMEs and nothing else. Four manual chapters and a public doc comment pinned tags that no release ever updated - the doc comment was two releases stale. Discovery now replaces the hand-maintained list, and the smoke test greps the bumped tree independently rather than trusting the bumper's own verify step.
db:synctreated the database schema as trusted input (CLI-01).migrate:freshis gated behind--forceplus a typed confirmation (CLI-02), in the app binary as well as the CLI.- The
logmail driver now logs the whole message, as Laravel does, and no longer writes bearer links to the log in production.
Added
- Atomic terminal settlement (
QueueDriver::settle, DATA-02). The chain successor and the acknowledgement now commit together onDatabaseQueueDriver, closing the window where a crash between them either lost the rest of a chain or ran its next step twice. The reservation-keyed delete doubles as a fence: a worker whose visibility expired mid-run commits nothing and reportsSettled::Stale, so it cannot enqueue work for a message another consumer now owns. Drivers that cannot do this answerSettled::Unsupportedand keep the documented push-before-ack ordering. DatabaseBatchRepository(DATA-02). Batch accounting survives a restart, andpending_jobs/failed_jobsare derived from settlement rows keyed(batch_id, job_id)rather than stored and decremented - so a redelivered job cannot drive a batch to "finished" while its other jobs are still running, and the guard holds across processes rather than within one./_suprnova/health/liveand/_suprnova/health/ready. Liveness touches nothing; readiness probes dependencies. Wiring a database check into a liveness probe turns a database blip into a rolling restart of every replica, which the single previous endpoint invited./_suprnova/healthkeeps working exactly as documented.SERVER_HEALTH_READINESS_TOKEN. Optional shared secret for the readiness probe, compared in constant time. Without it, readiness answers 404 - indistinguishable from an unrouted path, because it is the router's own 404. Unset by default so existing probes keep working.MAIL_SMTP_ENCRYPTION-starttls|tls|none, withsslandnullaccepted as Laravel-compatible aliases. Unset derives from the credentials, reproducing the previous behaviour exactly. This also makes implicit TLS on port 465 reachable: the transport supported it, but no combination of environment variables could select it.SERVER_MAX_CONNECTIONSandSERVER_HEADER_READ_TIMEOUTdocumented inmanual/env-vars.md, where they had been missing entirely.
Changed
The audit's own conclusion was that the gate passed in 470s and caught none of the 19 P1s. Most of this release's test work is aimed at that.
- Postgres runs in the gate. Twelve tests across six files had never
executed. Two of them turned out to aim
DROP TABLEat whatever Postgres was onlocalhost:5432by default, and neither had ever initialisedCrypt, so both failed the first time they ran. - Scaffold assertions read the bytes a user receives, after
substitution, rather than the template source. Found an API project
shipping a doc comment naming a database literally
{package_name}, and a.env.exampleadvertising five mail keys the framework never reads. - Queue fault injection. ACK loss, redelivery, lease lapse and partial dispatch are driven by a decorator that fails a named operation on a named call, so every case is deterministic rather than a sleep race.
- Payment adapters have negative tests. Stripe's
verify()had never been exercised with a valid signature, so every rejection path that depends on reaching the HMAC comparison was unproven. - The Pinecone driver speaks REST. Breaking, behind the
off-by-default
vector-pineconefeature. Motivation is under Security; the surface changes are:client()is gone - there is noPineconeClientany more. Replacing it arecontrol_plane_get,control_plane_postanddata_plane_post, which reach any Pinecone endpoint with your own request and response types over the driver's authenticated, host-resolved transport. That is strictly more reach than the old trapdoor had.json_to_metadata→metadata_from_json, and metadata is nowserde_json::Maprather thanprost_types::Struct.decode_match_fields→decode_match, taking aPineconeMatch.namespace()returns&str.- New:
with_control_plane,with_api_version,with_index_host(pins a known host and skips the control-plane round trip),index_host, and thePineconeVector/PineconeMatchwire types. from_envstill readsPINECONE_API_KEYandPINECONE_CONTROLLER_HOST, and now alsoPINECONE_API_VERSION.- The REST API version is pinned, not floated -
2025-04, the version the driver's request and response shapes were written against. - Nothing serializes any more. The old driver cached one
Indexper name behind atokio::Mutexbecausepinecone-sdkexposed it only behind&mut self; the new one caches a host string and sharesreqwest's connection pool. - A host learned from the control plane is always contacted over
https, whatever scheme the response carries. Debugis implemented by hand with the API key redacted, so a#[derive(Debug)]on a struct holding a driver can't print it.
- Wire-contract tests for Pinecone. The live integration tests need a
PINECONE_API_KEYand so cannot run in the gate - which left a REST rewrite's field names (topK,includeMetadata,vectorCount) resting on nothing. Thirteen tests now drive the driver against a localwiremockfake and assert the exact method, path, headers and JSON body it puts on the wire, plus that a non-2xx is never decoded as a result and that an error message never carries the API key. They pin the driver to Pinecone's documented contract; only the#[ignore]d tests can confirm the documentation matches the live service.
0.7.2 - 2026-07-28
Fixed
generate-typesresolves nested prop structs without derives. 0.7.1's generator degraded any prop field whose type didn't deriveInertiaProps/Datatounknown- so re-running the generator (or thesuprnova servewatcher) over a project with a committed types file replaced real interfaces likeArray<AdminArticleRow>withunknownand broke type-checking across the app. Plain structs defined anywhere insrc/now resolve to their real interfaces, transitively from the prop roots;unknown(with a warning) is reserved for types the project genuinely doesn't define - external crate types, enums, tuple structs.
Changed
-
routes.tsgeneration is opt-in.generate-typesno longer dropsfrontend/src/types/routes.tsinto every project unasked; pass--routesto generate it. -
Frontend starter dependencies refreshed. New scaffolds from
suprnova newnow pin current versions: Vite ^8.1.5, Tailwind CSS ^4.3.3, Svelte ^5.56.8 (vite-plugin-svelte ^7.2.0, svelte-check ^4.7.4), React ^19.2.8 (plugin-react ^6.0.4), Vue ^3.5.40 (plugin-vue ^6.0.8, vue-tsc ^3.3.8), and@types/node^24 (the Node 24 LTS types line). TypeScript stays at ^6.0.3 deliberately: it is the latest 6.x, and svelte-check's peer range (^5 || ^6) does not yet admit TypeScript 7. All three starters were verified end to end (npm install+npm run build) against the refreshed set.
0.7.1 - 2026-07-27
A defect-fix pass over 0.7.0's queue routing, from a full post-release review.
Fixed
-
Chained jobs no longer lose their declared queue.
ChainLinkcaptured a job'smax_tries,timeout, andbackoffat chain-build time but not itsJob::queue(), so a job that landed on its declared queue when pushed directly landed ondefaultwhen dispatched as part of a chain - the "job" tier of the route → job → default resolution order silently vanished for chains. The declared queue is now captured on the link and resolved exactly like a direct push. Chain payloads written before this release decode unchanged (serde(default)), and a link with no declared queue serializes byte-identically to what 0.7.0 wrote. -
Failed-job records carry the queue the job died on. The worker's dead-letter path hardcoded
queue = "default"into everyFailedJobrecord, so failures of a routed job were invisible to an operator filtering the failed store by the pool that owns them. The record now carries the envelope's queue (defaultfor unrouted jobs). -
The 0.7.0 upgrade note understated the
jobsmigration. It read "unfiltered workers are unaffected and need no migration", butDatabaseQueueDriver::pushnames thequeuecolumn in itsINSERTwhether or not the job is routed - a 0.7.0 binary against an un-migrated table fails every push, filtered or not. The 0.7.0 section below andmanual/queues.mdare corrected: on the database driver theALTER TABLEis required for every deployment, and it must run before binaries roll (older binaries list their columns explicitly, so migrating first is safe). -
README no longer advertises a
#[job]macro. No such macro exists - jobs implement theJobtrait. The queues row now describes the real surface, including 0.7.0's queue routing.
Changed
- The release path now bumps README version references.
bump-workspace-version.pyrewrites the README's pinned install tag, the distribution-model example, and the MSRV line atomically with the manifests, and a reworded README that stops matching a pattern fails the release loudly. The README had advertised v0.6.0 since v0.7.0 shipped because nothing in the release path touched it. - Connection routing is documented as name-resolution only.
Job::connection()and the connection field ofQueue::routeresolve the connection name carried on theJobQueueing/JobQueuedlifecycle events; a single process-global driver still receives every push, so they do not select a different driver. The rustdoc andmanual/queues.mdpreviously implied driver selection that does not exist. The queue dimension is unaffected - it is honored end to end. Per-connection drivers remain future work. ChainLinkgained a publicqueue: Option<String>field, which breaks struct-literal construction of chain links. Links built throughChainLink::from_job- the normal path - are unaffected.
Upgrading
Coming from ≤ 0.6.x on the database queue driver, apply the 0.7.0 migration
below before rolling binaries; it is required for every deployment on
that driver, not just ones using --queue. 0.7.1 itself needs no migration.
0.7.0 - 2026-07-26
Security
- Upgraded
ammoniato 4.1.4 (RUSTSEC-2026-0213). Versions through 4.1.3 allow XSS via SVGanimateandsetanimation tags.ammoniais the sanitizer at the end of Suprnova's markdown pipeline (comrak→syntect→ammonia), so any app rendering user-supplied Markdown throughcontentwas exposed. The advisory was published 2026-07-21 - after v0.6.5 shipped - so every release up to and including v0.6.5 is affected. Upgrading the framework is the fix; no application code changes are required.
Added
- Queue routing. Jobs can be dispatched to a specific queue and connection,
and workers can be dedicated to specific queues - the Laravel 13
Queue::route(...)surface, typed. A job states its own home withJob::queue()/Job::connection(); an operator overrides it centrally withQueue::route::<SendInvoice>(Some("redis"), Some("billing"))inbootstrap::register(), without editing the job. Resolution is route, then job, then global default, and aNonefield in a route defers rather than clearing.queue:work --queue=billing,defaultdrains only those queues. Unrouted jobs belong todefault, so they are never stranded. Chained jobs resolve routes by name, since a chain link stores its job erased. QueueDriver::pop_from. Filtering pop, with a default implementation that rejects a filter it cannot honor rather than silently draining every queue - a worker told to drainbillingthat quietly drains everything is indistinguishable from a working deployment until the wrong pool eats the wrong jobs. The memory and database drivers filter natively. Custom drivers keep compiling and inherit the loud default.- Documented the
jobstable schema.manual/queues.mdnow carries the DDLDatabaseQueueDriveractually expects, which was previously only discoverable by reading the driver's SQL. - Documented Inertia's
serverHeadoption. Server-driven<head>elements (Inertia 3.5.0) need no framework support: the client reads them from an ordinary prop, so any handler can already supply them. Seemanual/frontend-inertia-responses.md.
Changed
Envelopegained aqueue: Option<String>field. It isserde(default)and skipped when absent, so an unrouted envelope serializes byte-identically to what previous versions wrote - the frozen wire-format test passes unchanged, there is noschema_versionbump, and mixed-version fleets interoperate during a rolling upgrade.WorkerConfiggained aqueues: Vec<String>field (empty = drain everything, the previous behaviour).- Removed
ROADMAP.md. Its design principles live inmanual/introduction.md, the working agreement inmanual/contributions.md, and the deployment and scale-out material inmanual/deployment.md; the shipped/planned checklists had gone stale.README.md's pointer to it for "the relationship to upstream" was already dangling - that attribution lives inLICENSE. - Scaffold frontends now pin
@inertiajs/{svelte,react,vue3}at^3.6.1(from^3.4.0). The 3.4.0 → 3.6.1 range is client-side only - audited against the upstream changelog and thePagecontract inpackages/core/src/types.ts, everyX-Inertia-*header the 3.6.1 client sends was already handled. scripts/release.shnow publishes the GitHub release itself, with notes taken from the version'sCHANGELOG.mdsection. Previously this was a manual "next step" that got skipped, which is why v0.5.10 and v0.6.1-v0.6.3 are tag-only and the Releases page sat on a stale version. Preflight runs before the gate so a missingghor changelog section fails in seconds, and publishing is skipped automatically unlessoriginis GitHub.
Upgrading
Existing jobs tables on the database queue driver must add the new
column - push names it in its INSERT whether or not the job is routed, so
an un-migrated table fails every push. Migrate first, then roll binaries
(older binaries list their columns explicitly and ignore the new one, so that
order is safe):
jobs ADD COLUMN queue TEXT NULL;
ON jobs(queue);
(Corrected in 0.7.1 - this note originally claimed unfiltered deployments needed no migration.)
0.6.5 - 2026-07-21
Added
- Hosted one-off Checkout in the Stripe adapter.
Checkout::start_sessionwithSessionMode::OneOffand non-emptyprice_refsnow creates a hosted Checkout Session (mode=payment, one line item per price ref,allow_promotion_codes=true) and returnsSessionPayload::StripeCheckoutRedirect. Theamount_hint-only Elements path is unchanged; the two shapes are picked per request. - Stripe Managed Payments (merchant-of-record) support.
StripeProvider::with_managed_payments(true)- orSTRIPE_MANAGED_PAYMENTS=trueinfrom_env()- sendsmanaged_payments[enabled]=trueon hosted one-off session creation. Off by default; the field is omitted entirely so non-enrolled accounts are unaffected. Checkout::session_status. New trait method (default:PaymentError::NotSupported) reporting a session's provider-side state as the new neutralCheckoutSessionState(Open/Complete { paid, payment_ref, amount_total }/Expired). The Stripe impl mapsGET /v1/checkout/sessions/{id};payment_refcarries the session's PaymentIntent id for mirror-table correlation. This is the server-side verification primitive for redirect return pages and reconciliation sweeps.Promotionscapability trait.create_promotion_codemints a customer-restricted, optionally expiring, redemption-capped code off a pre-created coupon. Queried via the newPaymentProvider::as_promotions()(defaultNone). Implemented for Stripe (POST /v1/promotion_codes) and the mock.MockPaymentProviderupgrades for the above. Records everystart_sessionrequest (recorded_sessions()), scriptssession_statusper session id (script_session_status()- unscripted known sessions reportOpen, unknown idsNotFound), and implementsPromotionswith recorded requests (recorded_promotion_requests()).
0.6.4 - 2026-07-17
Fixed
- Eloquent aggregates decode consistently across database backends. Generated
count,sum,avg,min, andmaxexpressions now use one stable internal result alias. PostgreSQL no longer returns false zeroes orNonebecause its driver labels aggregate columns differently from SQLite, and missing-column or incompatible-type errors now propagate instead of being silently defaulted. - Mass deletes cannot use caller-supplied table expressions. Executable
delete SQL always derives its target from the model's validated static
M::TABLE. The legacy public renderer argument remains source-compatible but cannot redirect or inject the delete target.
0.6.3 - 2026-07-15
Added
- Typed raw reads can stay on a transaction's pinned connection.
Transaction::backend()exposes the active backend andTransaction::query_all(Statement)executes typed aggregate or custom SQL through the transaction while preservingQueryExecutedinstrumentation. Applications no longer need a pool-level query or private executor access when a lock-scoped decision depends on computed result columns.
0.6.2 - 2026-07-15
Fixed
- Bound raw predicates are backend-neutral. Eloquent
filter_rawandwhere_rawnow accept portable?bind markers on every database backend; PostgreSQL rendering rebases them to monotonic$Npositions across prior predicates, relationship subqueries, HAVING clauses, and UNION arms. Existing numbered PostgreSQL fragments are normalized by their local marker order, while mixed styles and bind-count mismatches fail validation before I/O. The SQL-aware scanner preserves question marks inside quoted strings, identifiers, comments, and dollar-quoted bodies;??emits a literal question-mark operator in a bound raw fragment.
0.6.1 - 2026-07-15
Added
- Observable supervised session cleanup.
SessionMiddleware::installuses the configurableSESSION_GC_INTERVALcadence (one hour by default), whilesession_gc_metrics()exposes process-local run, success, failure, removed-row, and last-result timestamps for protected operations surfaces. - Bounded sliding-session touches.
SESSION_TOUCH_INTERVALcontrols the minimum activity-write cadence (five minutes by default) and is capped at half the session lifetime so active sessions cannot expire between touches.
Fixed
- State-free requests no longer create durable sessions. Requests without a valid session cookie perform no session-store read or write and receive no session cookie unless handling creates state. Existing clean sessions avoid unconditional upserts and cookie churn, legacy cookies migrate on their next request, and cookies whose backing rows have expired are cleared without recreating empty sessions.
0.6.0 - 2026-07-10
Added
- Opt-in framework subsystems with backward-compatible defaults. Filesystem
storage, SQLite/Postgres/MySQL database drivers, the MariaDB vector driver,
and Web Push now have explicit Cargo features. Existing default builds retain
all of these capabilities, while
default-features = falseconsumers can select zero drivers or only the storage/database/vector/push surface they use. The executable feature matrix verifies zero-driver, individual-driver, Nation X minimal, default, and all-feature profiles. - Raw P-256 VAPID private-key import.
VapidKey::from_bytesaccepts a validated 32-byte big-endian P-256 scalar alongside the existing PKCS#8 PEM import/export path.
Changed
- VAPID JWTs are signed directly with P-256. Web Push now serializes the
RFC 8292 ES256 header/claims and signs them with
p256, removing the generic JWT dependency while preserving generated keys, PEM round trips, public-key encoding, and the 24-hour lifetime bound. - Security dependency refresh. Updated vulnerable framework dependencies, including bcrypt and ammonia, and narrowed Comrak's enabled features while retaining syntax highlighting.
- Rust 1.91.1 is the release MSRV. Every workspace package declares the
same
rust-version, generated Dockerfiles pin the matching builder image, and the full release gate compiles the supported filesystem profile with the exact Rust 1.91.1 toolchain. - OpenDAL 0.58 security pin. The filesystem feature pins
eas4ai/opendalcommit88717391eb72c9839d3f8e79fccad9f22fc3a1b4, a minimal fork based exactly on official Apache OpenDAL commitae99a3b016e354a1b2bb2baf0c70f9f9e134970a. The fork changes only the Reqsign declarations used by OpenDAL core plus S3, GCS, and Azure Blob so downstream consumers resolve official Apache Reqsign commitb49cd2996b9d2d9944e84481f8835ff55b188b97andquick-xml0.41.0. A fork is required because a dependency repository's root Cargo patches do not propagate to consumers; the published graph could otherwise restore vulnerablequick-xml0.38/0.40.
Fixed
- Atomic release version metadata. The release bump now updates
workspace.package.versionand every versioned internal path dependency in one validated operation, stages every affected manifest, and proves a temporary0.6.0workspace withcargo check --workspacebefore release. Release versions are validated as strict SemVer 2.0, including the numeric prerelease leading-zero rule. Version-agnostic disposable bare-remote smokes derive a later patch release from both the current source and an already0.6.0source, reject staged/unstaged/untracked release trees before the gate, prove atomic commit/tag publication rolls both refs back when a tag is rejected, and prove the normal release sequence without touching the real remote. Release versions must increase by SemVer precedence, including prerelease transitions. Smoke build artifacts always stay inside their temporary workspace, ignoring any callerCARGO_TARGET_DIR. - Rustdoc covers every supported feature boundary. The OAuth module links
to public
OAuthAuth::complete, and the executable matrix builds zero-driver, default, and all-feature rustdoc with no dependencies. - Filesystem stream validation is session-scoped. Local filesystem writers, listers, and copiers resolve and confine their paths once before first I/O instead of once per chunk/item, while activated close/abort operations always reach the backend for cleanup. Existing traversal and symlink confinement remain enforced for a trusted filesystem; canonicalize-then-open checks do not eliminate races against a principal concurrently mutating the tree.
Security
- The release gate fails closed.
release.shdelegates to the canonical full gate before editing manifests or creating commits/tags; that gate always runscargo audit, treats a missingcargo-auditbinary as an error, and stops on any audit failure. It also builds and audits an isolated downstream filesystem consumer, asserting exact OpenDAL/Reqsign source revisions and noquick-xmlbelow 0.41. No new advisory ignores were added.
0.5.10 - 2026-07-03
Fixed
generate-typesno longer drops self-referencing structs. A struct with a field that references its own type (a tree node withchildren: Vec<Self>, e.g. a threaded-comment view) created a self-edge in the type-dependency graph, pinning its in-degree above zero so Kahn's topological sort never emitted it - leaving every interface that referenced it with a dangling type name that failedsvelte-check/tsc. Self-edges are now stripped before sorting, and any structs trapped in a reference cycle (mutual recursion) are emitted in arbitrary order rather than dropped, since TS interfaces may reference one another regardless of declaration order.
0.5.9 - 2026-07-01
Added
MAIL_FROM_NAME- optional display name on auth-flow emails. The email-verification, password-reset, and password-changed mailables now render theirFromheader as"Name <address>"whenMAIL_FROM_NAMEis set (read at send time so it survives the queue's serde round-trip).MAIL_FROMstays a bare address; leavingMAIL_FROM_NAMEunset or blank keeps the previous bare-address behavior. No change to any call site - the mailables read the env var themselves.
0.5.8 - 2026-06-30
Fixed
generate-typesroute helpers are always valid TypeScript. When several routes in a module share one handler (e.g. astatic_files::servewhitelist mapping many favicon/asset URLs), the first kept the handler name and the rest got a key derived from the route path - but the path was only partly sanitized (/ { } -→_), so a file extension leaked a.into the key:favicon_16x16.png: (...) => .... That is member access, not a property name, sotsc/svelte-checkrejected the generatedroutes.ts. Derived keys are now sanitized to legal identifiers - every non-alphanumeric character becomes_and a leading digit is prefixed - sofavicon-16x16.png→favicon_16x16_pngand2fa.json→_2fa_json. Unique handler names are untouched.
0.5.7 - 2026-06-30
Fixed
generate-typesno longer emits dangling type references. A prop field whose type is a struct that doesn't deriveInertiaProps/Data(or an external type the generator can't see) was emitted as a bare identifier - e.g.user: UserInfo- producing TypeScript that failstsc/svelte-checkbecause that interface is never written. Such references now degrade tounknown(user: unknown;Vec<T>→Array<unknown>;Option<T>→unknown | null), so generated output always type-checks, andgenerate-typesprints a warning naming the unresolved type and the field that references it, with the fix (deriveInertiaProps/Dataon it). Generic parameters and resolved nested InertiaProps/Data types are unaffected.
0.5.6 - 2026-06-29
Changed
- Sign in with Apple: RS256 JWKS verification. Bump
suprnova-apple-rsto v0.3.1 - Apple ID tokens are now verified against Apple's published JWKS (RS256) instead of being trusted structurally.
0.5.5 - 2026-06-28
Added
MagicLinktoken purpose. NewMagicLinkvariant on the auth-flowTokenPurposeenum, for passwordless magic-link sign-in tokens.
0.5.4 - 2026-06-28
Changed
- Composable OAuth completion. Split the generic OAuth completion into
verify_oauth_identity(verify + resolve the identity) and a thincomplete, so apps can verify an OAuth identity without triggering the full session-completion side effects.
0.5.3 - 2026-06-28
Fixed
- Correct workspace version metadata. v0.5.2 was tagged and pushed before
its
Cargo.tomlversion bump was staged, so the pushed v0.5.2 tag still readsversion = "0.5.1". v0.5.3 re-cuts the release with the correct workspace version - no code change (the v0.5.2 OAuth split is unaffected).
0.5.2 - 2026-06-28
Changed
- Composable Apple completion. Split Apple Sign-In completion into
verify_apple_identity+ a thincomplete_apple, mirroring the generic OAuth split. (Note: the pushed v0.5.2 tag carries a stale0.5.1version field - fixed in v0.5.3.)
0.5.1 - 2026-06-28
Changed
- Renamed Apple crate. Repoint the Apple dependency to the renamed
suprnova-apple-rsrepository.
0.5.0 - 2026-06-28
Added
- Sign in with Apple. OAuth token exchange + ID-token verification + user
upsert for Apple; Apple well-known endpoints and the
form_postresponse mode; Apple-specific fields onOAuthProviderConfig;AppleKeyPairre-exported so apps configure Apple Sign-In without a directappledependency.
Fixed
- Omit PKCE parameters from the Apple authorize URL (Apple rejects the request when they are present).
Dependencies
- Consume the
toriimagic-auth fix; addapple-rsv0.3.0.
0.4.1 - 2026-06-26
Performance
- Pre-size
MiddlewareChainto eliminate per-requestVecreallocations.
Fixed
- Make the maintenance down-file path collision-proof under parallel test runs.
Docs
- Compile-check the framework's doc examples (
ignore→no_run); reconcile the distribution notes with the tagged GitHub Releases; ignore the wholedocs/tree.
0.4.0 - 2026-06-22
Changed
- Distribution is git-tracked; you don't pin to tags. Scaffolded apps
depend on
suprnova = { git = "…/suprnova.git" }and track the default branch; pull updates withcargo update -p suprnova. Versions are published as tagged GitHub Releases (v0.4.0, …) for the changelog, butCargo.lockalready pins the exact resolved commit - so builds stay reproducible without hand-pinning atagorrev. The installation docs no longer present commit-pinning as the update path.
0.3.0 - 2026-06-21
Added
- Query instrumentation for Eloquent reads -
Builder::get,Model::find,find_many, andallnow emitQueryExecuted, so model SELECTs and eager-load queries surface inDB::listenand the in-memory query log alongside writes and raw queries. Adds the instrumentedExecutorChoice::statement_allread terminal. - Resource-route authorization -
ResourceRoutes::authorize_resource::<U, R>()attaches the conventional ability check to every generated resource route as per-route middleware (LaravelauthorizeResourceparity). The action→ability map isindex/show→view,create/store→create,edit/update→update,destroy→delete. One call gates the whole seven-action surface instead of relying on every controller body to remember aGate::authorize. - Atomic rate-limit hit -
RateLimiter::hit_and_check(key, max, decay)increments a fixed window and tests it in a single round-trip, returning whether the bucket is now over its limit (i64::MAXmeans unlimited). - Constant-time comparison helper -
constant_time_eq(a, b)(subtle-backed) for webhook signature verification;WebhookHandler::verifydocs now mandate constant-time digest comparison. - Inertia client to 3.4.0 - the Svelte/React/Vue scaffolds now pin
@inertiajs/{svelte,react,vue3}at^3.4.0(from3.1.1), picking uprouter.pollmodes, dynamicusePoll,Inertia.once, the InfiniteScroll cancel fix, and awaited FormonSuccess. The server already emits the full 3.4.0 page-object and header surface (once-props, the prepend/deep-merge scroll family,matchPropsOn, rescued/shared props), so this is a client-currency bump with no protocol change. - Optional connection cap -
SERVER_MAX_CONNECTIONS(and the programmaticServer::max_connections(n)) bounds concurrently active connections with a semaphore on the accept loop, applying back-pressure at the TCP level. Unset - or0- leaves connections unbounded (the default, unchanged). A backstop to pair with a reverse proxy andLimitNOFILE, not a replacement for upstream rate limiting. - Opt out of redirect-following -
RequestBuilder::no_redirects()routes a request through a non-following HTTP client so a3xxis returned as-is instead of chased. Use it when the request URL is influenced by untrusted input, to close a redirect-based SSRF vector (a hostile endpoint redirecting toward an internal or cloud-metadata host). The default client still follows redirects, matching general-client convention.
Security
- Resource routes fail closed on the authorization registry's type-erased
downcast instead of panicking, and
authorize_resourcedenials / unauthenticated requests are refused before the handler runs. - Rate limiter closes a fixed-window check-then-hit race by incrementing and
comparing atomically (
hit_and_check). - Queue
RateLimitedmiddleware now admits jobs through that atomichit_and_checkinstead of a separatetoo_many_attempts+hitpair, so concurrent workers can no longer all pass the budget check before any of them increments and over-admit pastmax_attempts. - Upload validators (
mimetypes/mime) content-sniff the uploaded bytes instead of trusting the client-suppliedContent-Type. - Filesystem path guard canonicalizes paths to catch symlink traversal out
of the storage root, beyond the prior lexical
..// absolute / UNC checks. - Auth closes a passwordless-login timing oracle - a matched-but-passwordless
account given a password now runs a fixed-cost verify, across both the Eloquent
and database user providers - and
dummy_verifydrives the configured hasher so the unmatched-user path is constant-time. - Eloquent validates column identifiers on the
pluck/value/pluck_keyed/sole_valueandsum/avg/min/maxprojection paths. - Payments - the mock provider's verifier fails closed outside a development
environment, and webhook source IPs resolve through
TrustedProxiesConfig(req.ip()) rather than a rawX-Forwarded-Forheader. - Filesystem path guard now walks to the nearest existing ancestor when a write target doesn't exist yet, closing a symlink escape where a planted intermediate symlink with a missing immediate parent slipped past the guard.
DB::init_withvalidates the environment before connecting (matchingDB::init), so the dev SQLite fallback can no longer boot silently in production through that entry point.- Static-file serving rejects dotfiles (
.env,.git/config,.htpasswd, any leading-.segment), not just./..traversal. - Payment webhooks serialize concurrent retries of the same unprocessed
event with a
FOR UPDATElock + re-check, and treat mirror-table unique violations as benign already-applied;payments_subscription_itemsgains aUNIQUE(subscription_id, provider_item_id). - RBAC defaults the model discriminator to the fully-qualified type name, so two authenticatable types sharing a leaf name can no longer inherit each other's roles/permissions.
invalidate_session()rotates the session id (not just flushes), closing a session-fixation gap; the queueWithoutOverlappingmiddleware releases its cache lock even when the job panics.- Mail providers cap error-response body reads (8 KiB), matching the web-push client, so a hostile endpoint can't drive sender memory.
- Web push disables HTTP redirect-following on the default client, so an
attacker-influenced push endpoint can no longer
3xx-redirect a notification POST toward an internal or cloud-metadata host (SSRF). A redirect now surfaces as a rejected push rather than a silently followed request. - Stripe adapter
Debugredacts the webhook signing secret and prints a placeholder for thestripe::Client(which carries the API secret key in its auth header), so neither secret can reach logs through a{:?}ofStripeProvider, regardless of the upstream client's ownDebug. - Stripe adapter
from_envrejects present-but-blank credentials, failing closed instead of constructing a client with an empty (and therefore forgeable) webhook HMAC secret. - OAuth email verification fails closed for unrecognised providers: a
userinfo payload carrying an
emailbut noemail_verifiedflag is no longer treated as verified. An unknown provider must now assertemail_verified: trueor expose a verified-emails endpoint, closing an account-link/takeover vector for apps that key accounts on email. Google (explicit-true-only) and GitHub (verified-by-the-/user-contract) are unchanged.
Fixed
- Nested eager loading (
with(["posts.comments"])) is now a constant number of queries - the tail segment loads in one batched IN query across all parents instead of one query per parent (N+1). where_has/where_doesnt_havequalify closure columns with the target table, so a column present on both pivot and target no longer produces an ambiguous-column error on many-to-many relations.- Soft-delete
delete/force_delete/touchand factorypersisthonor a model's#[model(connection = "…")]routing (matchingrestoreand the other write paths) instead of falling back to the primary pool. - JSON:API
Maybe::Missinguses a non-collidable wire sentinel, so user data shaped like{"__missing__": true}is no longer silently stripped. - Queued notifications honor
should_send(per-channel veto) andafter_sending, re-checked on the worker - previously only the synchronous path did. - Released jobs push the retry copy before acking the original, so a transient driver push error no longer drops the job.
- Paddle adjustment (refund) webhooks key the mirror update off the referenced
transaction id and read amounts from
data.totals, instead of inserting a zero-amount row under the adjustment id. - SQLite URLs carrying a query string (
sqlite://db.sqlite?mode=rwc) build a valid single-query connection URL and a clean on-disk filename. - HTTP clamps
Acceptq-values to[0,1]and enforces aFormRequest'smax_body_byteseven when the body was pre-buffered; WebSocket config rejectsmax_missed_pings < 2(1 closed every connection on its first ping). - Cron day-of-month and day-of-week use OR semantics when both are restricted
(Vixie/POSIX parity); Markdown
plain_text/excerpts preserve intentional spaced punctuation;CachedEvaluatorbounds its cache growth;SupervisorRegistry::start_allno longer double-spawns on a second call; the test container recovers in place from a poisoned lock. - Supervisor restart backoff resets to the 100 ms floor after a run that stays up at least the 60 s cap, so a daemon that ran healthily for a long stretch and then exits restarts promptly instead of inheriting backoff that climbed during an earlier failure burst. A crash loop whose runs never reach the threshold still ramps to the cap, so the reset never masks a flapping supervisor.
- Corrected stale docs on
filter_op(operators are allowlist-validated), signed URLs (not byte-compatible with Laravel's default absolute signatures),UniqueIdKind::is_valid(a caller helper, not auto-wired intofind), and the identifier length cap (128, not 64).
Documentation
- Documented resource-route authorization (
authorize_resource) in the routing and authorization chapters, and the atomichit_and_checkcounter in the rate-limiting chapter.
0.2.0 - 2026-06-21
Adds role-based access control, a Markdown content / docs-rendering pipeline, and native static-file serving.
Added
- Tier-2 RBAC -
HasRolestrait; roles + permissions with arole_has_permissionsjoin;PermissionMiddleware/RoleMiddleware(both fail-closed / default-deny); theCreateRbacTablesmigration; andcreate_role/create_permission/give_permission_to_rolehelpers. - Content rendering - Markdown rendering and a docs-build pipeline:
MarkdownRenderer,build_docs,DocsCatalog/DocsChapter, heading extraction andslugify_heading. Rendered HTML is sanitized (comrak + syntect + ammonia). - Native static-file serving -
StaticFiles::public()fallback handler for serving apublic/directory at the web root, replacing hand-rolled per-asset whitelist controllers in apps.
Fixed
- Freshly generated apps inherit a framework-level
time = 0.3.47compatibility pin, avoiding Rust 1.96 coherence conflicts fromtime 0.3.48in fresh scaffold dependency resolutions.
Documentation
- Documented the two shipped starter kits - Nebula (Breeze-tier auth) and Pulsar (product site + community) - across the manual, README, and roadmap; restructured the roadmap around the shipped surface; and reconciled version references throughout the docs.
0.1.0 - 2026-06-10
The initial Suprnova release. Suprnova is a Laravel-inspired web framework for Rust, forked from Kit and taken in its own direction. Today's parity target is Laravel 13.x.
This release uses the git distribution model: framework consumers depend
on suprnova = { git = "https://github.com/eas4ai/suprnova.git" },
and the CLI installs with cargo install --git.
Added
HTTP, routing, and middleware
Routerwith route groups, prefixes, parameter constraints, named routes- Compile-time-validated route registration via the
routes!macro - Resource routing (
Router::resource) producing the seven standard routes - Signed URLs (
url::signed_route/url::temporary_signed_routefree functions, plusRedirect::signed_route/Redirect::temporary_signed_route) - Redirect helpers -
Redirect::to,Redirect::back,Redirect::route,Redirect::with_input,Redirect::with_errors,with_flash - Middleware trait with global, group, and per-route layers
- Built-in middleware - CORS, CSRF, session, request timeout, request ID, throttle / login throttle, signed-URL verify, authenticated, email-verified, brute-force
- Abort helpers (
abort,abort_unless,abort_if) suprnova::handle_request(...)- public adapter to serve a single hyper request against a router + middleware chain
Inertia.js frontend bridge
#[derive(InertiaProps)]with TypeScript type emissioninertia_response!macro with compile-time component validation- Three first-class starter frontends - Svelte 5 (runes-on), React 19, Vue 3.5 - all on Inertia 3.1.1 + Vite 8 + Tailwind v4
- Partial reloads (
only/except), deferred props, persistent layout, encrypted history, scroll preservation Inertia::paginate(component, key, paginator)for paginator → Inertia prop wiring
Eloquent-style ORM (over SeaORM)
#[suprnova::model]attribute macro that emits a SeaORM entity and the user-facing Eloquent struct in one shot- Full
Modeltrait -create,find,find_or_fail,find_many,all,query,save,update,delete,force_delete,refresh,fresh,replicate,replicate_into,increment/decrement,destroy,is/is_not,to_array/to_json - Fillable / guarded mass-assignment with
Attrsenvelope - 22 attribute casts - booleans, integers, floats, dates, enums, hashed, encrypted, JSON, collections, money, datetime with timezone
- Accessors / mutators via
#[suprnova::model] - Auto-timestamps (
created_at,updated_at) - Soft deletes (
deleted_at) withforce_delete,restore,trashed,only_trashed,with_trashed - Eleven relation kinds -
HasOne,HasMany,BelongsTo,BelongsToMany,HasOneThrough,HasManyThrough,MorphOne,MorphMany,MorphTo,MorphToMany,MorphedByMany - Per-family morph enums + morph registry with
APP_KEY_PREVIOUSrotation - Eager loading via
.with(...),.with_count(...),.load_missing(...) - Correlated EXISTS engine for
has/where_has - Sixteen lifecycle events (retrieving, retrieved, creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored, force-deleting, force-deleted, replicating, trashed)
Observer<M>trait with per-method auto-registration via inventory- Local scopes via
#[scopes(M)], global scopes viaGlobalScope Collection<M>Laravel surface -pluck,key_by,group_by,where_in,first_where,contains_where,partition, etc.- Three paginators -
paginate(length-aware),simple_paginate,cursor_paginate- all serializing to Laravel-shape JSON chunk/lazy/cursorfor bulk-row iteration without OOMlock_for_update/shared_lockrow-level lockingDB::table(...)query builder withDynamicRowfor ad-hoc queriesDB::transaction(...)with savepoints, retry-on-deadlock, multi-connection read/write splitDB::listen(...)+QueryExecuted/TransactionBegan/TransactionCommitted/TransactionRolledBackeventsPrunabletrait +model:pruneconsole commanddump/ddquery-helper methods#[model(unique_id="...")]for UUID / ULID primary keys
Auth
Authenticatabletrait +EloquentUserProvider<M>Auth::attempt,Auth::login,Auth::user,Auth::user_or_fail,Auth::user_as<T>,Auth::logout,Auth::check- Multiple named guards (web session, API token)
- Email verification flow -
EmailVerification,EnsureEmailVerifiedMiddleware, signed verification URLs,EmailVerificationMail - Password reset flow -
PasswordReset, throttled tokens,PasswordChangedMail,PasswordResetLinkSentevent - Two-factor TOTP - enroll, verify, recovery codes, replay protection
- Brute-force / login throttle - IP + identifier keyed,
LoginThrottleMiddleware - Remember-me cookies with stable opaque tokens
- Six auth events -
LoginAttempted,LoggedIn,Authenticated,LoggedOut,PasswordResetLinkSent,EmailVerified - Browser sessions backed by the Torii fork at
github.com/eas4ai/suprnova-torii-rs
Authorization
Gatefacade -define,allows,denies,authorize,any,none,check(sync + async variants)#[policy(Model)]macro for policy registration- Resource-route auto-authorization
Payments
- Provider-agnostic five-trait surface -
Checkout,Payment,Subscription,CustomerStore,WebhookHandler PaymentProviderumbrella trait + capability-querying viaas_payment()- DB mirror -
customers,subscriptions,subscription_items,payments,refunds,payment_webhook_events(UNIQUE for idempotency) - Flow-tagged
SessionPayloadenum (one-shot vs subscription) - Two reference adapters as workspace crates -
suprnova-payments-stripe(gateway, fullPaymentimpl),suprnova-payments-paddle(Merchant of Record, noPaymentimpl) - Mock provider for tests
Queue, jobs, batches, chains
Jobtrait -handle,max_tries,backoff,timeout,fail_on_timeoutQueue::push,Queue::push_later,Queue::push_unique,Queue::push_unique_later- Drivers -
sync,null,redis,database JobMiddlewaretrait - six built-in middleware- Batches and chains -
Queue::batch(jobs).dispatch(), fluent chain builder, cancellation, progress tracking - Failed-jobs store with replay
- Worker with graceful shutdown, configurable concurrency, panic
recovery via
catch_unwind, settlement metrics - Twelve queue events covering queueing, processing, failure, release, worker lifecycle
Broadcasting and WebSockets
ws!()macro +Router::wsfor typed WebSocket endpointsWsSocketSink/Stream split- Auto-restart supervisors via
Supervisortrait BroadcastHubwithChannel,Private,Presencechannels- JSON-envelope protocol, presence join/leave/here, configurable presence TTL with crash recovery
Broadcastablebridge toEventDispatcher- Close-on-no-pong heartbeat with configurable WS_TASKS drain
- Per-route WebSocket middleware
- 1 MiB / 64 KiB safer defaults +
WsConfig::generous()factory - Origin policy + 1011 close-on-protocol-violation
Notifications and mail
Notificationtrait +Notify::send(recipient, notification).await- Mailable + Markdown template rendering
- Database / mail / broadcast / web-push channels
- VAPID signing + RFC 8291 ECE payload encryption (via
suprnova-web-push) - VAPID subject validation, retry-after parsing, 8 KiB rejection-body cap
- Notifiable trait for recipient typing
Events
- Typed event dispatcher -
EventFacade::dispatch,EventFacade::listen<E, L>,EventFacade::forget - Cancellable saving/updating events (return
EventResult::cancel) - Queueable listeners
Filesystem
Storage::disk("name")with multi-driver support - local, S3, Azure, GCS via OpenDAL- Move, copy, exists, size, mime, last-modified, prepend/append
- Streaming uploads and downloads
Cache
Cache::store("name")+ driver registration- Drivers - memory, redis (with bounded connect-timeout), database, file
remember,forever,tags, atomic increment/decrement, locks
Vector DB
VectorDrivertrait with four drivers - in-memory, Qdrant (UUID-5 ID mapping), Pinecone (native string IDs), MariaDB nativeVECTOR(N)+ HNSW indexes (11.7+)- Cosine / dot / euclidean distance
Console binary and CLI
- Per-project
consolebinary - Rust analogue ofphp artisan, runs user-defined commands via#[suprnova::console::command] #[derive(Command)]for typed argumentssuprnovaCLI -new,serve,migrate,db:sync,generate-types,key:generate,make:{controller,middleware,action,error,inertia,migration,task,command},db:seed,model:prune--versionflag- Scaffold templates for backend + API starters across three frontends
Feature flags
DatabaseEvaluatorwith snapshot loadingCachedEvaluatorwith TTLFeatureMiddlewareextractor- Admin CRUD surface
FeatureSynctrait for sub-second propagation across processes
Schedule
- Cron expression parser
Schedule::task(...)with composable predicates- Single-server locks, overlap prevention, dispatch tracking
schedule:runconsole command
Validation
validator0.20 integration#[request]+#[derive(FormRequest)]macros#[form_request(max_body_bytes = N)]per-form size cap#[form_request(custom_hooks)]opt-out for user-writtenimpl FormRequest- Lifecycle hooks -
authorize,after_validation,after_validation_async
Database drivers
- SeaORM-backed support for SQLite, Postgres, MySQL, MariaDB
- URL-based driver detection
- Migration system +
migrate,migrate:rollback,migrate:status,migrate:fresh,migrate:refresh
HTTP client
Httpfacade -get/post/put/patch/deletereturning aRequestBuilder;.send().awaitproduces aClientResponse- rustls TLS, 30s default timeout,
suprnova/<version>user-agent json/form/body/header/bearer_token/basic_auth/timeoutchainable methodsRequestBuilder::retry(max_attempts, base_backoff)- exponential backoff for transient failures and 5xx; respectsRetry-AfterHttp::fake(|| async { ... }).awaittest guard withfake_response(method, url_substring, status, body)+assert_sent/assert_not_sent
Encryption
Cryptstatic facade +EncryptionKey(crypto::*); AES-256-GCM with 12-byte random noncesencrypt_string/decrypt_string/encrypt<T>/decrypt<T>CryptPurposeAAD binding preventing cross-protocol replayAPP_KEY_PREVIOUSrotationsuprnova key:generateCLI command for minting fresh keys
Testing
#[suprnova_test]async test macroTestDatabase::fresh::<Migrator>()with parallel-safe instancesTestContainer::bindfor per-test mocks- HTTP test helpers -
Test::get,Test::post, JSON / form / multipart - Queue / Mail / Notification / Event fakes
assert_emitted,assert_dispatched,assert_dispatched_times
Changed
- Auth verification and password-reset flows now operate through the configured user provider instead of Torii internals.
- Generated apps must implement
get_auth_password; scaffolded examples now fail loudly instead of allowing login to always fail silently. - The local release gate is wired into
scripts/release.sh, and the repo includes an enforced pre-push hook for fmt, clippy, tests, docs, and feature builds. - Scaffolded dev-port documentation moved to the current backend/frontend
defaults (
8765/5765), withdev:tlsand--with-portlessdocumented. MAIL_FROMis validated before verification or reset tokens are issued, avoiding orphaned auth-flow rows when mail configuration is invalid.
Fixed
- React scaffold template drift from the released starter.
- Root route groups no longer generate duplicate
//paths. - Literal-path redirects now dispatch through the intended routing path.
- Broadcasting fanout tests now handle
track/untrackresults. - The mail log driver emits the rendered text body, so verification and password-reset links surface in local development logs.
- Password-reset coverage pins session and remember-me revocation behavior.
Notes
- Distribution model: git-based end-to-end.
suprnova = { git = "https://github.com/eas4ai/suprnova.git" }; CLI viacargo install --git. Nothing is published to crates.io.
