Releases: eas4ai/suprnova
Release list
v1.2.1
GitHub account migration: every repository URL (manual in all seven languages, README, Cargo repository fields, git dependency sources, scaffold templates) now points at github.com/eas4ai instead of the renamed github.com/entrepeneur4lyf. Scaffolded projects also carry a monitored author email. No behavior changes.
Suprnova v1.2.0
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.
Suprnova v1.1.0
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 global
fallback_locale—pt-PTfrompt-BR,en-AUfromen-GB, and so
on, transitively.Lang::get/try_get/get_with/try_get_with/has
all walk the chain, current locale first, so this works for any
Translatordriver, 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:
FluentTranslator
now 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 terminal
fallback_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 from
lang/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).
Suprnova v1.0.0
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.md
is 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 in
lang/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 return
Result<(), ValidationMessage>; a custom rule'sErr("…".into())body
still compiles and still renders verbatim, but the signature in your
implneeds 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 aMessageKey
union 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 no
trans_choicehere. Behind a default-onlocalizationfeature;
--no-default-featuresstill compiles and still validates, using the
embedded English fallbacks. -
IntoInertiaScrollforPaginator. The trait was implemented for
LengthAwarePaginatorandCursorPaginatorbut not for the simple
paginator, sosimple_paginateresults could not feed
Inertia::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 the
LIMIT 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.
Suprnova v0.9.1
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: a
docker 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 aftermax_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 from
init_telemetry;queue:work,schedule:work,schedule:runand
workflow: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 insideif 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_jobs
is part of that driver's contract —queue:retryreads it and
Queue::retry_failedcannot work without it — butbootstrap_from_env
wired 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
withCACHE_DRIVER=memoryand a single-server task is refused, naming
the offending tasks, withSCHEDULE_ALLOW_MEMORY_LOCK_IN_PRODUCTION=true
for deployments that genuinely run one scheduler.
Changed
manual/deployment.mdno longer says "run exactly oneschedule:work
process" 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.
Suprnova v0.9.0
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, because
Alice@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'srefreshhad no expiry predicate and unconditionally extended
expires_at, andOpaqueSessionProvider::refresh_sessionskipped the
is_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 samecsrf_state
could 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's
PasskeyService::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 and
allowCredentialshands to anyone who can start a ceremony. Renamed to
find_user_by_credentialandcreate_session_for_verified_credential, both
documenting that verification is the caller's job. Not reachable through
Suprnova, which driveswebauthn-rsitself (see
torii_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 SeaORMget_challengealso ignored
expires_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,AzBlobConfig
andGcsConfigno 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-v4
takesreqsign-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 withSMEMBERSand 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 until
SIGKILL— 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 acargo 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.md
andmanual/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 without
testing— sevenStorage::fakeintra-doc links could not resolve, and
lib.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 thetorii_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 19
Index::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 returned
Ok(())unconditionally, soinit_torii's mapping of the failure into a
FrameworkErrorwas 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 table
app_users. Torii's migration now warns at migrate time when an existing
userstable 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...
Suprnova v0.8.0
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. PointRATE_LIMIT_DRIVERatredis, or set
RATE_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's
Defaultand returnOk—fill(attrs!{ age: "abc" })setage = 0
and 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 to
url::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 in
manual/queues.md. Nothing changes if you stay on
MemoryBatchRepository.
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 withquery.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 rawx-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 freshUuid::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 onenv.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 — whileRequest::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 repeated
signatureorexpiresis 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, droppingpinecone-sdk 0.1.2
— whose newest release dates from 2024-09-06 — and with it
tonic 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.toml
carries anOWNERand anEXPIRESdate, andscripts/check-audit.sh
fails 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 "...
Suprnova v0.7.2
Fixed
generate-typesresolves nested prop structs without derives. 0.7.1's
generator degraded any prop field whose type didn't derive
InertiaProps/Datatounknown— so re-running the generator (or the
suprnova 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 in
src/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 drops
frontend/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.
Suprnova v0.7.1
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 its
Job::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 hardcodedqueue = "default"into everyFailedJob
record, 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", but
DatabaseQueueDriver::pushnames thequeuecolumn in itsINSERT
whether 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 and
manual/queues.mdare corrected: on the database driver theALTER TABLE
is 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.md
previously 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 through
ChainLink::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.
Suprnova v0.7.0
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 with
Job::queue()/Job::connection(); an operator overrides it centrally with
Queue::route::<SendInvoice>(Some("redis"), Some("billing"))in
bootstrap::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 DDL
DatabaseQueueDriveractually 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. See
manual/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):
ALTER TABLE jobs ADD COLUMN queue TEXT NULL;
CREATE INDEX idx_jobs_queue ON jobs(queue);(Corrected in 0.7.1 — this note originally claimed unfiltered deployments
needed no migration.)