Release notes
The current release is v1.19.0.
Nucleus is on the stable v1.x line (v1.0.0 tagged 2026-07-10): stable
surfaces are frozen by contract tests, and every v1.x upgrade is designed
to be drop-in for code that uses them — see
Support & compatibility and the
upgrade guide. Commit-level detail for every
release, including the pre-1.0 history, lives on
GitHub Releases.
v1.19.0 (2026-08-30)
An external coverage audit ran the extension surface that v1.13–v1.17 added — pluggable authentication, session stores, storage, interceptors, federated sign-in — and measured what it does rather than reading what it claims. This release is what it found: one fail-open, one kill switch that deleted data, and four seams that could not be reached by the documented route.
A backend that rejects now ends the login attempt
This one is a breaking change. A chain of auth_backends treated a
rejection and an unreachable backend the same way: both moved to the next
backend. So an employee whose directory account had been revoked, but whose
local row still carried the old password, was let in — the directory's
certain no was worth exactly as much as a timeout.
The two look alike, because neither produced a user, but their first causes are opposite. A rejection is the identity source's verdict on these credentials. An unreachable backend proves nothing at all. Now only the second falls through, which is the break-glass path the ordering exists for.
The consequence is worth stating plainly: a chain is a fallback for
unavailability, not a way to federate several user populations. Every
account must be acceptable to the first backend that recognises the request,
because anything behind a rejection is unreachable by design. If you were
relying on [ldap, local] to serve accounts that exist only in the local
table, see the upgrade guide.
What makes this a correction rather than a decision is that the framework
already published the corrected behaviour in four places — the README, the
auth_backends reference, the backendtest conformance kit, and orbit's own
documentation. Only the function's own doc comment described the fail-open,
and it was the one that was wrong.
storage.cleanup.enabled: false no longer deletes your objects
The switch did not switch anything. Cleaner did not record the flag
anywhere, and the two branches that were supposed to act on it returned
identical values, so Start swept regardless — immediately on boot, before
the first tick. Anything under the cleanup prefix older than max_age was
gone on every start, with nothing logged to say a disabled cleaner had run.
If you use _tmp/ as a working area for uploads in progress or staging,
this was silent data loss.
The extension surface is reachable by the documented route
Three separate defects with one shape: what v1.13–v1.17 added so that somebody else could extend the framework could not be used the way the documentation describes.
- A configured session store kept its capabilities. Installing a store
through configuration wrapped it in an adapter carrying only the three
contract methods, and both the session library and
ActiveSessionsdiscover enumeration and the context-taking variants by type assertion. Withsession_store: redisorsql, the active-sessions view went blind, the documentedSCS().Iterateescape hatch panicked, and every session read and write dropped the request context. - A third-party session store now boots. Layer 3 validated
session_storeagainst a hand-written{memory,sql,redis}, so a store registered withRegisterSessionStorewas built by the container and refused by the runner and by every CLI subcommand — the same file, two verdicts. It is validated against the registry now, and an unknown name still fails at boot, naming what is actually available. - The fluent builder reads the same subtrees the CLI does.
interceptors.<name>.*was not captured at all on that path, andauth.<instance>.*was missed for federated instances, so both were built from theirdefault:tags instead of from the file. Silently: the interceptor mounted and the instance existed, just without the settings the operator wrote.
Two smaller corrections
auth.ErrUserNotFound is an alias of backend.ErrUserNotFound again, as the
package documentation promised. They were two distinct values with identical
message text, so a leaf backend's error was unrecognisable to code comparing
the other — and nothing in a log could show it.
A second App in one process now logs a warning when it reuses an
already-registered backend name. The registry is per-process, so the second
App's UserProvider is dropped and the first one's user table answers in its
place. That is intentional, but it is stronger than "reuses" and it was
happening in complete silence.
v1.18.0 (2026-08-29)
Deciding whether a write was rejected by a unique constraint no longer depends on what language the database server speaks.
db.IsUniqueViolation
A handler that writes a row needs to tell "that e-mail is already taken"
apart from "the database is broken": the first is a 409 the caller can
act on, the second is a 500. The only portable signal database/sql
offers is the error value, and the obvious shortcut — looking for
duplicate key or unique constraint in the driver's message — is wrong
in a way that never shows up in development.
PostgreSQL, MySQL, Oracle and SQL Server all translate their messages when
the server is configured in another language. A PostgreSQL server started
with lc_messages='es_ES.utf8' answers a rejected insert with
llave duplicada viola restricción de unicidad «users_email_key»
and MySQL with --lc-messages=fr_FR answers
Duplicata du champ 'a' pour la clef 't.u'
No English substring appears in either. Every such check quietly returns false, and the branch it guards becomes dead code on that deployment — with no error and no warning to say so.
db.IsUniqueViolation(err) classifies on the code the driver reports,
through errors.As: SQLSTATE 23505 on PostgreSQL, 1062 on
MySQL/MariaDB, 2627/2601 on SQL Server, ORA-00001 on Oracle, and the
SQLITE_CONSTRAINT_UNIQUE / PRIMARYKEY extended codes on SQLite. It is
unaffected by the server's locale and by wording changes between driver
releases, and because errors.As walks the unwrap chain, an error your own
code has wrapped still classifies.
PostgreSQL is matched through an assertion on the SQLState() string
method rather than on a concrete driver type, so it covers pgx — the driver
pkg/db registers — and lib/pq alike, without either import. The SQL
Server and Oracle branches live behind the mssql and oracle build tags
that register those drivers, so a default build links neither.
It deliberately does not report foreign-key, not-null or check violations. Code acting on "unique" wants to point at one field, and widening the predicate later would silently change what that branch catches.
v1.17.2 (2026-08-29)
Release machinery only; no change to the framework's behaviour.
The LDAP provider module now requires the release before this one, rather than one from two releases back. The module depends on the root of its own repository, an edge that can never be perfectly current — any release that contains the requirement is by definition later than it — and the suite's manifest tolerates exactly one release of lag before calling it staleness. v1.17.1 was a documentation-only release and left the requirement where it was, which put it two behind and made the suite unable to certify a set. Re-pinning it here restores that, and the release checklist now carries the step explicitly, marked as applying to patch releases too: skipping it leaves that release certifiable and the next one not.
v1.17.1 (2026-08-29)
Documentation only; no change to the framework's behaviour.
Two defects in the published documentation site, both of which were only visible from outside the repository.
Every archived snapshot announced a version that was not its own — the documentation kept for v1.14.0 said the current release was v1.13.0, and so on for five of them. The snapshot freezes the pages exactly as they stand before a release is cut, and at that moment the version marker still names the previous one. It said so on the page the site serves at its root, since the newest archive is what a reader gets by default. Each snapshot now states its own version, the release script sets it when cutting, and a check fails the build if one ever drifts again.
The marker that carries the version was also being picked up as the page description. It was invisible on the page itself, which is why it went unnoticed for so long, and visible exactly where a first impression is formed: in a search result, or in the preview shown when someone shares the link.
v1.17.0 (2026-08-29)
Two new seams for third-party code, and a defect in the one that already existed.
Federated sign-in. A directory is a username and a password, and an
identity provider is not: the person leaves for it, authenticates there,
and the answer arrives at a different URL some time later. That does not
fit the authentication backend contract, so it has its own. An operator
declares identity providers in the configuration file, naming each one and
the protocol that implements it — two providers of the same protocol, a
corporate tenant and a partner one, is an ordinary thing to want, so the
name is yours and the protocol is a separate field. Settings live under
auth.<name>.*, the same place a credential backend reads them.
Nucleus keeps the anti-forgery state itself. A provider never sees it: the framework issues it, holds the pending sign-in and refuses a callback that does not carry it back, before the provider is consulted at all. That is the part of a redirect flow that works perfectly well when it is missing, right up until somebody attacks it, so the author of a provider is not given the chance to leave it out. No provider ships in this release; the seam does.
Request interceptors. Middleware could always be attached, but only by whoever assembles the application. A package that an application merely imports had no way in, so an interceptor could not be shipped — only pasted into somebody's bootstrap. Interceptors now register by name like every other extension, and the operator places them:
http_interceptors: [audit, tenant-guard]
The list is ordered and the order is the behaviour — first is outermost. A name nobody registered fails at boot, naming what is registered, because a typo in a list of request interceptors must not resolve to one fewer protection.
Fixed: watching SQL used to stop other watchers. The process-wide SQL observer was a single slot, so installing one replaced whatever was there. Nucleus installs its own at startup to feed the observability bus, which means an application that watched SQL turned that bus off by doing it — with no error and no log, visible only as a live view that stopped updating. Observers now accumulate. The call is unchanged and existing code keeps working; it just no longer displaces anyone.
Also: the LDAP provider module implements the contract from its leaf package, which takes what it compiles from 235 third-party packages down to 11, and runs the authentication conformance suite against itself.
v1.16.1 (2026-08-29)
Release machinery only; no change to the framework's behaviour.
A provider module and the framework release it requires are now cut from
the same commit. They were not: the framework's release configuration
excluded the providers/ tree, so a change to providers/ldap could never
produce a framework release alongside it — and the suite manifest, which
requires a module's tag to be an ancestor of the framework's, had no way to
certify a set once the two drifted apart. providers/ldap v0.1.2 requires
this release, and both were published from one commit.
v1.16.0 (2026-08-28)
Writing an authentication backend no longer means importing the whole framework, and no longer means reading the contract carefully enough to infer its security properties.
The contract a backend implements is its own package
pkg/auth/backend holds the interface, the identity it returns, the two
sentinel errors, the configuration a backend reads and the registry — and
nothing else. Implementing three methods used to mean importing
pkg/auth, which links session stores, JWT, Redis, Prometheus and
OpenTelemetry: none of them needed to answer "do these credentials belong
to a real user".
import "github.com/jcsvwinston/nucleus/pkg/auth/backend"
func init() {
backend.Register("acme", New)
}
The same move for the other two registries that had it: pkg/storage/provider
(a storage backend no longer inherits the AWS, Azure and Google Cloud SDKs
that the built-in implementations need) and pkg/auth/sessionstore. Mail
already was a leaf and did not change.
Nothing breaks. The names in pkg/auth and pkg/storage are now
aliases of the same types, so existing code compiles unchanged and
errors.Is still matches across the boundary.
A conformance suite for your backend
The parts of the contract that are easy to get wrong are expensive to get wrong, so they are now checkable instead of only described:
backendtest.Run(t, backendtest.Suite{
New: func() (backend.Backend, error) { return New(cfg) },
ValidUser: "ana",
ValidPassword: "correcta",
UnknownUser: "nadie",
})
It checks that a wrong password and an unknown user produce the same rejection — a caller that can tell them apart can enumerate your users — that an empty password is refused, and that a source your backend cannot reach reports unavailable rather than a rejection, which is what keeps the chain falling through to the local account someone needs on the morning the directory is down.
It checks the contract, not the behaviour: passing does not mean your backend talks to the right directory.
Fixed
- An empty password is refused even when your user provider would not.
The adapter that wraps an application's
UserProviderused to delegate that decision. Pointed at a provider that returns a user without comparing — a legacy row with an empty stored hash, or a bug — it authenticated. The framework is in that path, so it now stops there.
v1.15.1 (2026-08-28)
Packaging only; no change to the framework's behaviour.
The release machinery was holding providers/ldap at version 0.1.0
permanently — a leftover pin from cutting its first release meant every
subsequent proposal came out as 0.1.0 again, so the module could never
publish a fix. It can now, and providers/ldap v0.1.1 is the first.
v1.15.0 (2026-08-28)
Nucleus can now authenticate against an LDAP directory, and the seam that makes that possible stopped being something you have to read the source to use.
LDAP authentication
The directory client ships with Nucleus, as its own module — an application that does not authenticate against a directory should not download an LDAP library:
go get github.com/jcsvwinston/nucleus/providers/ldap
import _ "github.com/jcsvwinston/nucleus/providers/ldap"
auth_backends: [ldap, local]
auth:
ldap:
url: "ldaps://dc.corp.local:636"
base_dn: "ou=people,dc=corp,dc=local"
bind_dn: "cn=svc-nucleus,ou=services,dc=corp,dc=local"
bind_password: "${LDAP_BIND_PASSWORD}"
The order is the feature: the directory answers first, and the local account still works on the morning the directory does not.
What the backend refuses to do is as much of the design as what it does. An empty password is rejected before a connection is opened — a bind with a name and no password is unauthenticated under the LDAP specification, and a directory may answer it with success. A username is escaped before it reaches the search filter. A search that matches several entries is a rejection rather than a choice made by directory ordering. And only a directory that actually says "wrong credentials" produces a rejection: an unreachable host, a service account whose password was rotated, a misconfigured base — all of those report the backend as unavailable, so the chain falls through to the account that can still get you in.
A backend brings its own configuration
auth.<backend>.* belongs to the backend named in auth_backends. The
framework checks only that the section belongs to a backend that exists;
the backend declares and validates its own settings.
Two situations now fail at startup instead of quietly doing nothing:
- A key the backend does not declare. A misspelled directory URL would otherwise sit unnoticed until the day the setting mattered.
- A section for a backend the chain does not name.
auth.ldap.*withoutldapinauth_backendsis read by nobody, so the application boots clean and the login page never consults the directory you configured.
Errors that tell you what to do
Naming a backend that Nucleus publishes but that nothing has imported no
longer answers "unknown backend". It answers with the two lines that fix
it — the go get and the import — because the name came from the
documentation and the missing piece is the import, not the spelling.
nucleus doctor --check auth reviews the chain from the configuration:
a backend declared with no settings of its own, and a chain whose every
entry depends on an outside system, which is the deployment where nobody
can log in while the directory is down, including whoever would fix it.
Fixed
- The same file no longer gets two verdicts. A configuration using a
storage provider that Nucleus does not ship loaded correctly when the
application started and was rejected as malformed by
nucleus check,doctorandconfig print— the command-line tools did not know about the provider's configuration section. Both paths now share one rule.
v1.14.0 (2026-08-26)
Two releases ago the parts you are most likely to replace became pluggable. This one makes them configurable, and connects the authentication seam to something you can actually declare.
-
A provider brings its own configuration. A storage backend that Nucleus does not ship could be selected by name and then had nowhere to read its endpoint from — its settings died as unknown keys before it ever ran. A registered provider now owns a configuration subtree:
storage:provider: cephceph:endpoint: http://ceph.internalpool: 32The framework validates that the section belongs to a provider you actually registered, and the provider validates its contents. A misspelled section still fails as an unknown key — the exemption is for registered names, not for the whole namespace — and a key your own struct does not declare fails too. Provider configuration is exactly the place a typo would otherwise sit unnoticed until the day the setting mattered.
-
The authentication chain is declared in configuration, and your own user table takes its place in it:
auth_backends: [ldap, local]Implement
auth.UserProvider— the interface that has described how to reach your users for as long as the framework has existed — register it withWithUserProvider, and it answers aslocal. Modules reach the assembled chain throughrt.AuthChain(), so a module that owns a sign-in page authenticates through the order you declared instead of going straight to the user table. Declaring an order matters little if it only applies to the doors the framework happens to own.A backend named in that list that nobody registered fails at boot, naming what is registered. A typo in an authentication list should not wait until the first person tries to log in.
-
What an extension may rely on is now frozen.
app.Extensionreceives the whole application object, and the contract used to say an extension could set fields on it. That was a blank cheque: whatever an extension reached for became part of the API in practice while being covered by nothing anyone could promise across versions. No extension ever used it. An extension now reads framework services, mounts routes and registers middleware — and what it may read is frozen like the rest of the stable surface, so adding to it is a deliberate promise and removing from it is a break somebody has to see.
v1.13.0 (2026-08-26)
The release where the parts of the framework you are most likely to need to replace stop being fixed.
Until now, exactly one subsystem could be extended from outside: mail. Every other one picked its backend from a closed list, so running Ceph instead of S3, or authenticating against a corporate directory, meant forking the framework. That is the thing that stops an ecosystem from existing, and it is what this release changes.
-
Storage backends are registered by name. A backend Nucleus has never heard of is selectable from configuration:
func init() {storage.RegisterProvider("ceph", New)}Everything the framework layers on top — the circuit breaker, tenant prefixing, the public-URL mapper — is applied around whatever your factory returns, so a provider never reimplements any of it. The four built-ins register through this same call, because a registry whose built-ins take a private shortcut is one that drifts.
A side effect worth knowing: an unknown provider name now fails, naming the registered ones. It used to fall through to the local filesystem, so a typo wrote your uploads to disk and said nothing.
-
Session stores are registered by name. Same shape, plus an optional shutdown hook for a store that holds a connection pool. The contract is a framework interface with standard-library types only, so writing a store does not mean depending on whatever session library Nucleus uses inside.
-
Authentication backends, as an ordered chain. This is the one that makes a corporate directory possible without the framework shipping an LDAP client:
chain, err := auth.NewChain("ldap", "local")The order is the feature. A backend returns one of three answers — this user is authenticated, these credentials are certainly wrong, or I could not reach my directory. The third is why the chain exists: when the directory is down, the local account you keep for exactly that morning still works.
The distinction survives to the end. If every backend rejected, you get "invalid credentials", because that is what happened. If any backend was unreachable, you get an error that says so — "wrong password" and "the directory is down" send you to very different places at three in the morning.
If you write a backend, one rule matters more than the rest: reject an unknown user and a wrong password identically, and in the same time. A backend that answers faster for a user who does not exist has published your user list.
The chain is built in Go for now. Declaring it from
nucleus.yml, and giving each backend its own configuration subtree, is the next piece of work — this release is the seam, not the whole road.
v1.12.1 (2026-08-25)
A patch release from an external audit of the shipped framework. Six findings, every one of the class "it reports success and does not do what it says".
-
A module can declare its own root. Mounting a module was supposed to stop it answering a mute 403 until the operator hand-edited the policy file. It held for every path except the one a module is most likely to serve: its own. The shortest object you could declare was
"/", which resolved to"<prefix>/"— and the enforcer treats/consolaand/consola/as different paths, neither implying the other.Object: "/"now means the root and the subtree;Object: ""means the root alone. CSRF exemptions had the mirror image, since their matcher is a raw prefix rather than a path match, and a trailing slash left a collection POST unexempted. -
A module can no longer switch CSRF off for the whole application . A module without a prefix exempting
"/"— the natural way to say "my routes" when there is no prefix — disabled CSRF everywhere, its sibling modules included, with no operator veto and no line in the boot log. That declaration now fails startup, and every module's exemptions are logged with their resolved paths. Mounting a module means trusting its routes; it was never an agreement to let it unprotect everyone else. -
Deletereaches the public bucket. With apublic_bucketconfigured, deleting a public object returned success and left the object in place — so a public object could not be deleted through the store's own API. The loop moved to the second bucket only on a not-found error, but removal is idempotent and never reports one. Retention, user-requested deletion and attachment cleanup were all affected. -
A malformed
trusted_proxiesentry fails to load. It used to be discarded in silence: with one entry and a typo the list came out empty, and forwarding headers were never read. It failed in the safe direction, which is exactly why nobody noticed — three separate tools reported the configuration as written. -
doctor --check securityjudges the proxy ranges together . It looked at one entry at a time, so a catch-all split in two passed clean while covering the same address space as the one it rejects. The message also named the wrong header: under a catch-all it isX-Real-IPthat becomes attacker-controlled, notX-Forwarded-For. -
A test can pin its own database from the builder. The test kit told you to set
Databasesin the config, and the builder had no way to do it.WithDatabasesadds one, and it beats theNUCLEUS_*environment layer — because a call written in code is not a file, and a test that pins its database should not have it swapped by whatever your shell exports. The kit now warns when it sees such a variable set. -
nucleus versionreports the version it was installed at . A binary fromgo install …@vX.Y.Zanswereddev, because only a release build stamps the version through linker flags. The binary knew all along — its build info carries the module version — the command simply never asked.
v1.12.0 (2026-08-25)
A minor release about knowing where you stand: the security posture stops being folklore, a configuration that will not boot says so at every entry point, and the checks that guard the documentation stop crying wolf.
- The default security posture is frozen, and measured. A test boots a real application, sends it a real request, and records what comes back — every security header, the attributes of every cookie, what a cross-origin caller receives — for both a development and a production profile, then compares it byte for byte against a checked-in baseline. Nothing in that file is transcribed, so it cannot claim a protection the framework does not emit. The comparison is exact in both directions: a loosened default is a regression, a tightened one changes behavior for deployments that relied on the old posture, and both have to be deliberate.
nucleus doctor --check security. A new subject for the settings that load fine, boot fine, and expose you anyway: a wildcardcors_origins(fatal when combined with credentials, which the Fetch standard forbids outright), a catch-alltrusted_proxiesrange that handsX-Forwarded-Forto the caller, ajwt_secretthat is long enough to pass a length check and still guessable,csrf_insecure_cookiein production, and rate limiting left off. It does not repeathealth --deploy.- Cookie name prefixes are judged when the file is read.
__Host-and__Secure-are enforced by the browser, which silently drops a cookie whose attributes contradict its name. Those rules lived only in the session builder, so a contradictory configuration loaded clean and killed the application at boot. They now run with the rest of the referential validation, where the file is judged. config printtells you when what it prints will not boot. It was the last CLI surface that read a configuration without validating it. It still renders an invalid file — that is what you reach for when something is wrong — and writes the loader's own rejection to stderr, so--jsonstays pipeable.- The documentation guards stop crying wolf. Keys under
modules:belong to the module's own config type, and the child of a user-keyed section (databases: primary:) is a name the operator invents; neither can ever appear in the framework's key registry. The page that teaches module configuration was permanently flagged for teaching it correctly.
v1.11.0 (2026-08-24)
A minor release about the edges: a misconfiguration that used to pass silently now fails loudly, the test kit can finally check what reached the database, and shutdown stops abandoning work in flight.
- One configuration file, one verdict. Value and cross-field validation
(
log_level: verbose,mail_driver: smtpwithout a host) ran only when the application booted. Everynucleuscommand loaded the same file without those checks, so a config the app rejected sailed through the CLI. Both layers now run wherever configuration is loaded; the error names the key, the value and the accepted set. serveandtestserversay what they serve. Both build an application from configuration alone — the modules compiled into your binary are not mounted, so their routes answer 404 there. They print that before starting, the wayroutesalready did.- The test kit reaches the database.
nucleustestgainsDB()andRuntime()(the same handle a module receives),TempSQLitefor a database per test, andMigrateDirto apply your project's migrations through the real migrator — ledger and checksums included, so a second call is a no-op. A test can now assert that aPOSTactually persisted. - Credential shapes work for every storage secret. The documented
{env_var: …}form was unusable forstorage.s3.access_key_id,secret_access_key,session_tokenandgcs.credentials: the loader only accepted a plain string, so the shape the guide prescribes was rejected at boot. Both forms now load, and a plain string keeps working. - The outbox finishes what it started. Stopping used to cancel the dispatcher outright, abandoning a delivery mid-attempt and leaving its message claimed until the lease expired. Shutdown now lets the pass in flight finish, and only cancels when the caller's deadline (or five seconds) runs out — with a warning when it comes to that.
- The documentation archive is alive again. The site serves a snapshot per published minor so readers pinned to an older release get the matching documentation. That archive had frozen at 1.2.0; it resumes here, and a check now fails when a minor ships without its snapshot.
v1.10.0 (2026-08-21)
A minor release: the vertical-slice module arc. A mounted module can now carry everything its feature needs, and a generator emits that shape.
- Modules declare their own policy rows and CSRF exemptions.
Module.Policiescontributes RBAC rows (same shape as arbac_policy.csvrow, objects relative to the modulePrefix) to the default-deny enforcer, andModule.CSRFExemptrides the same pre-startup window as the automatic webhook-prefix exemption. Rows join the live in-memory ruleset only — the host's policy file is never written, and adenyrow in the host's CSV overrides any moduleallow. Malformed declarations fail boot withErrInvalidModulePolicynaming the module and entry. - Embedded module migrations are applicable. One deliberate call —
rt.ApplyModuleMigrations(), typically inOnStart— appliesModule.Migrationsthrough the real pipeline: the module-scoped ledger (<module>/<id>) with checksum tracking, idempotent across restarts. Application boot still never mutates the schema on its own; the boot warning about declared-but-unapplied migrations now names the call and goes quiet once the module uses it. Under the hood:db.NewModuleFSMigrator, anfs.FS-backed migrator usable directly. - Embedded module templates.
Module.Templatesregisters a module's.htmlfiles under the<module-name>/namespace;app.WithTemplatesFS(prefix, fsys)is the general accumulating extension point behind it. On a name collision the host'stemplates_dirparses last and wins. nucleus generate module <name>. One self-contained package underinternal/<name>/— model + storage for the configured dialect, controller, and a module carrying its policy rows, CSRF exemption, embedded migrations and page template. Mounting it is the whole integration: norbac_policy.csvornucleus.ymledits, no manual migrate step, pinned by an executable-scaffold test that asserts the policy file stays byte-for-byte untouched.
v1.9.2 (2026-08-19)
A patch release that corrects documentation which promised more than the code delivered. No runtime behavior changes.
EnsureBucket's documented scope now matches what the code can do. The godoc suggested it could provision "the" bucket after construction, butNewS3Storeverifies the configured bucket(s) up front and refuses to construct when one is missing — so a store whose own bucket is absent can never exist to callEnsureBucketon. The contract now states the real scope: provisioning buckets other than the store's own (exports, per-tenant spaces, scratch areas); self-provisioning the configured bucket is exactly whatstorage.s3.create_bucket_if_missingis for, at construction time. The storage guide says the same, and a live MinIO contract test (TestS3Live_EnsureBucketProvisionsAnotherBucket) pins it.
v1.9.1 (2026-08-18)
A patch release from the SSR arc's re-verification.
- The outbox dispatcher starts after extensions attach. The dispatcher's
first pass is immediate, and
Extension.Attach— which ran later — is the supported way to register bridges: a message already durable in the table could be leased with an empty route registry and fail with "no bridge route matched", consuming a retry and dirtyingattempts/last_error. The dispatcher now starts only after every extension has attached; a pre-existing pending message delivers on attempt 1. Durability semantics are unchanged. - The template extension point is reachable from the documented
builder.
nucleus.New()gainsWithTemplateFuncs,WithTemplatesandWithOpenAuthz(plus package-level re-exports) — v1.9.0's template options only existed onapp.New, which the builder wraps. A parity test now enforces that every public application option has a builder counterpart, so this class of gap fails the suite instead of the next release. - API additions must regenerate the frozen baseline in the same change. The baseline only failed on removals, so v1.9.0's new symbols shipped unlisted and external coverage denominators undercounted the public surface. Regenerated, and a new check fails on unlisted additions.
v1.9.0 (2026-08-17)
A feature minor: the server-side render layer works the way the documentation prescribes. Found by the external coverage demo's first real MVC application (session login, CSRF forms, full CRUD).
- Modules with a
Prefixreceive the template engine and the session manager. The prefix sub-router was built from scratch and never inherited either — so the documented way of declaring a module answered every render with "template engine is not configured" and every session helper with "session manager is not configured". The three composition paths (Group,With,Route) now share a single derivation function, so a future Mux-level dependency cannot be forgotten by one of them. Auditing this also surfaced that nothing ever handed the session manager to the router tree at all:app.Newnow does, and theContextsession helpers (SessionPutString& co.) work everywhere. - Template functions and prebuilt bases.
app.WithTemplateFuncsregisters atemplate.FuncMapavailable to every template the startup loader parses, andapp.WithTemplatesinjects a prebuilt*template.Templateas the parse base — presentation logic (date formats, percentages, pagination URLs) belongs in templates, not precomputed in Go. Order: registered functions → recursive parse oftemplates_dir→ the engine is wired into the router. See the routing guide. csrf_insecure_cookie(development-only, defaultfalse) disables the Secure attribute on the CSRF cookies, mirroringsession_cookie_secure: false— without it the double-submit flow was unreachable for plain-HTTP non-browser clients such as Go's cookie jar againsthttp://127.0.0.1.- An SSR conformance suite now runs in CI: a module WITH a prefix, served over real HTTP, must render a loaded template by name, keep session state (write → read → destroy), enforce CSRF (419 without the token, 200 with it), serve module-mounted statics, and apply registered template functions — the five things any server-rendered application needs on day one, none of which had a test before.
v1.8.2 (2026-08-17)
A patch release: three findings from the external coverage demo's first server-rendered MVC application.
- The scaffold renders with the framework's own engine.
app.Newused to load templates with a flat glob whilenucleus startappscaffolds its template into a subdirectory — on a fresh project no template loaded and every render answered "template engine is not configured", with no startup warning. The loader now walkstemplates_dirrecursively; each template registers under its path relative to that directory (fieldservice/index.html), root files keep their flat name (base.html), and{{define}}blocks keep their declared names — flat layouts keep resolving unchanged. Startup logstemplates loadedwith the count, a present-but-empty directory logs a WARN, and the render-without-engine error now says what to check. A new executable-scaffold test boots a scaffolded project in CI and demands the generated page renders over HTTP — the class of generator/runtime disagreement that produced this finding (and the SQLite-DDL one before it) now fails the suite instead of the first user. - Outbox: per-instance lease owner and a routing-policy knob. Every
process used to write lease rows as the same literal
nucleus-app, so a co-tenant process could lease — and fail — messages another instance was able to deliver, untraceably. The default owner is now derived per instance (nucleus-<hostname>-<pid>);outbox.lease_ownersets a stable identity andoutbox.missing_route_policy(error, the previous behavior, orignore) controls what happens when a leased topic has no registered bridge. Startup logs both. SessionCache.Flushno longer panics. Flushing sliced every session key to a fixed length, panicking on unrelated keys of 8–13 bytes — ordinary session data. It now uses prefix checks and touches only cache-prefixed entries.
v1.8.1 (2026-08-17)
A patch release closing the loop on S3 bucket provisioning.
storage.s3.create_bucket_if_missingis reachable fromnucleus.yml. The missing-bucket startup error recommends that exact key, but the strict configuration validator rejected it: the key existed inpkg/storageand not in the application config schema — a circular repro with no operator exit (the environment form did not work either). The key now loads from the file and fromNUCLEUS_STORAGE__S3__CREATE_BUCKET_IF_MISSING, and reaches the storage constructor. Opt-in with defaultfalse: a missing bucket without the flag still fails startup loudly, exactly as before.- A parity test keeps the two config surfaces in sync. The application
schema mirrors
pkg/storage's own config structs; every storage key must now be mirrored or carry an explicit, reasoned exclusion — so a future divergence fails the suite instead of becoming another advertised-but-rejected key.
v1.8.0 (2026-08-16)
The developer-experience minor: the scaffolding, testing and configuration gaps measured by the 2026-08-16 developer-experience audit, on top of the fixes already landed in earlier patches.
-
generate resource(andstartapp) emit a mountable module wired to a real repository. The scaffold now produces anucleus.Contextcontroller implementing the REST Resource sub-interfaces, a repository running real SQL against the framework-managed*sql.DB(statements rendered for the configured dialect — RETURNING on PostgreSQL, OUTPUT INSERTED on SQL Server, OUT bind on Oracle, LastInsertId elsewhere), andinternal/modules/<name>_module.go:nucleus.New().Mount(modules.<Name>Module())is the whole integration. The*router.Muxhandler that forced a hand-written adapter and the in-memory map repository are gone;startappshares the same artifacts and its module also serves the scaffolded page. -
In-process test kit —
pkg/nucleustest(experimental).nucleus.RunContext(ctx, app)gives the run loop a caller-owned lifetime (cancel = graceful shutdown), and the kit wraps it:nucleustest.Start(t, builder)boots the app on a free loopback port, waits for/healthz, stops viat.Cleanup, andMintTokenissues bearer tokens against the app's ownjwt_secret. E2E suites no longer needgo build+exec.Command+ hand-rolled polling. -
profile: devboots a realistic config with zero backing services. A productionnucleus.ymlnaming PostgreSQL (+ replica), two Redis endpoints, S3 and SMTP boots unchanged withprofile: dev(orNUCLEUS_PROFILE=dev): in-memory sessions and jobs, local filesystem storage, the no-op mailer, and SQLite (an already-SQLite URL is kept; extra database aliases are dropped). Unknown profile values fail config load. Identical onapp.LoadConfigand the fluent loader.
v1.7.0 (2026-08-16)
A feature minor: the security composition the docs describe now works end to end, and object storage can provision itself.
- The global default-deny authorization layer sees JWT claims. When
JWT signing material is configured, a bearer is decoded ahead of the
global enforcement and the request's subjects are tried in order — the
token's user id, its role, then
anonymous. Role-based CSV policies (p, admin, /api/admin/*, read, allow) finally work at the global layer without re-implementing RBAC per module. Strictly non-restrictive: requests that passed before still pass (the anonymous fallback preserves bootstrap grants for authenticated callers); requests that were wrongly denied now succeed. - S3 bucket bootstrap.
storage.s3.create_bucket_if_missing: trueprovisions the configured bucket(s) at startup;S3Store.EnsureBucketis the programmatic, idempotent form. Behaviour change: without the opt-in, a missing bucket now fails the constructor loudly (with an actionable message) instead of booting green and failing on the first upload. ServiceRegistration.Healthis wired into/healthzas the checkservice:<name>; a failing service flips the endpoint to 503. New building blocks:health.FuncProbeandapp.RegisterHealthProbefor application-owned checks.
v1.6.2 (2026-08-16)
A patch release: dumpdata/loaddata round-trip on schemas with foreign
keys, plus documentation honesty fixes.
loaddatainserts in FK-dependency order. The load plan is now ordered topologically by the foreign-key graph introspected from the target database, so a fixture produced bydumpdata(which lists tables alphabetically) restores in a single invocation instead of failing on the first child table. The caller's order — the file's, or an explicit--tables— is the stable tie-break: a valid explicit order passes through unchanged (it used to be silently re-sorted alphabetically), an FK-invalid one is repaired. Self-references are skipped and FK cycles fall back to the given order rather than failing the load; the dry-run plan shows the real order.- Documentation: the health-probe comment no longer denies the mail probe
the code performs; the storage guide's import path is current; the auth
guide's policy examples use the CRUD action vocabulary the middleware
actually enforces (
read/create/update/delete), with the mapping spelled out;ServiceRegistration.Healthnow states loudly that it is accepted but not yet wired into/healthz. - The showcase example re-pins the current sibling tags (nucleus v1.6.1 at cut time, agent v0.5.7, server v0.9.2, quarkbridge v0.3.7, quarkdatasource v0.2.8, quark v1.4.1).
v1.6.1 (2026-08-15)
A patch release: the resource scaffolder targets your real database.
generate resource/startappemit migration DDL for the configured database instead of unconditional SQLite. A project configured against PostgreSQL used to receive"id" INTEGER PRIMARY KEY AUTOINCREMENTandDATETIMEcolumns, andnucleus migratethen failed with a syntax error on the very migration the CLI had produced. The scaffold now resolves the dialect from the project's config (the same confignucleus migratereads,NUCLEUS_*overrides included), and both commands accept--dialect/--config/--databaseto override it. A fresh project with no config keeps the sqlite default.- New helpers for the same decision in your own tooling:
db.SystemFromURL(URL → SQL system, no connection) andmodel.BuildMigrationScaffoldForSystem(dialect-dispatched scaffold). - The generated repository is an in-memory placeholder and now says so — a note in the generated file and in the command output replaces the previous silence about the migration's table going unused.
- Security: Go 1.26.6 (stdlib CVEs),
grpcv1.82.1,otelv1.44.0; thenucleus newscaffold inherits the same directives, and the showcase example re-pins quark v1.4.1.
v1.6.0 (2026-07-22)
Defense-in-depth hardening of the webhook surface, from the first directed security review of the continuous-audit regime. No behaviour changes for correctly-configured apps; the wire is unchanged.
Added
- Webhook registration rejects non-canonical mounts. A module webhook
registered with
path == "/"(which would mount a catch-all subtree) or a module name containing..//(which would shift the mount point) now fails boot instead of mounting something surprising. Canonical paths and names are unaffected. - Outbox payload-encoding header is informational by design. The bridge
signs the body only — byte-for-byte the module-webhook scheme, so one
verifier covers both surfaces — and
X-Outbox-Payload-Encodingis now documented as unsigned/informational. New consumer helperoutbox.CheckPayloadEncodingdecodes by the encoding a consumer expects and rejects a mismatch (ErrPayloadEncodingMismatch), rather than trusting the request header. The signed wire is unchanged from v1.5.0.
Upgrade notes
Drop-in. If a module registered a webhook at / or with a slash in its
name, that was already broken (unreachable or mis-mounted) and now fails
loudly at boot — give it a real path. Outbox consumers should verify the
body-only signature with the module-webhook verifier and check the payload
encoding against their own config (see CheckPayloadEncoding), not the
request header.
v1.5.0 (2026-07-22)
Signs and versions the outbox webhook contract, hardens module webhooks (canonical paths, opt-in anti-replay), and fixes Oracle pagination and S3/GCS not-found detection. Drop-in: the outbox wire is unchanged by default and the new webhook behaviour is opt-in.
Added
- The outbox bridge webhook has a signed, versioned contract. With
outbox.bridges.<n>.config.secret, every delivery carries an HMAC-SHA256 signature over the body inX-Nucleus-Signature(sha256=<hex>) — the same scheme module webhooks verify, so one verifier covers both. Every delivery also declares its payload shape inX-Outbox-Payload-Encoding: json|base64, so a consumer never guesses. The wire is byte-for-byte the v1.4.0 default (base64);payload_encoding: jsonopts into embedding the payload as JSON. A body-level contract test compares the emitted webhook byte for byte per variant — the gap the symbol-only freeze cannot see. Without a secret, deliveries are unsigned and a boot WARN says so. See Storage & background tasks. - Webhook anti-replay (opt-in).
WebhookSpec.TimestampTolerance > 0requires anX-Nucleus-Timestampheader inside the signed material (SignWebhookBodyWithTimestamp), rejecting stale or tampered timestamps. The default (tolerance 0) keeps the body-only scheme unchanged. The absence of anti-replay in the default scheme is now documented as a limit, with event-ID dedup as the recommended pattern.
Fixed
- Oracle pagination emits valid SQL.
FindAllandFindByIDinpkg/modelusedLIMITon Oracle (ORA-00933); they now useOFFSET … FETCH NEXT … ROWS ONLY/FETCH FIRST 1 ROWS ONLY, the twin of the earlier MSSQL fix. The admin-user CLI lookup is fixed the same way. Exercised against a real Oracle in CI. - Webhook paths must be canonical. A module webhook registered with a
non-canonical path (
..,., doubled or trailing slash) now fails boot instead of mounting an unreachable route. - S3/GCS not-found by SDK type, not error text.
Get/Existsof a missing key now map tostorage.ErrNotFoundagainst real endpoints (previously matched on the error string, which a real S3 endpoint does not produce). A real-MinIO CI lane covers it. - Security:
golang.org/x/textbumped to v0.39.0 (GO-2026-5970).
Upgrade notes
Nothing to change. If a bridge was relying on the base64 payload wire, it is
unchanged; opt into payload_encoding: json when your consumer is ready.
Configure outbox.bridges.<n>.config.secret to start signing deliveries.
v1.4.0 (2026-07-20)
Module jobs and webhooks are now executed, not just declared: the Jobs
and Webhooks closures a module registers run for real, backed by the
existing task runtime and the application router. Also fixes a stop-path
bug in the Asynq task provider and rejects, on request, primary keys
assigned by HTTP clients. Drop-in upgrade; the new surfaces are opt-in.
Added
- Module jobs run on a real scheduler.
JobRegistry.Register(name, spec)schedules background work declared by a module:Everyfor fixed intervals orCronfor 5-field cron expressions and descriptors (@hourly,@every 90s), validated at boot and identical on every provider; per-runTimeout; andSingletonto skip a tick while the previous run is still executing. Thejobs_providerkey selects the runtime —memory(default, in-process) orasynq(Redis-backed, durable, withjobs_redis_urlandjobs_concurrency). A broken registration (duplicate name, invalid cron, missing handler) fails boot instead of silently never running. See Module jobs and webhooks. - Module webhooks mount real routes.
WebhookRegistry.Register(path, spec)mounts an inbound receiver at<webhooks_prefix>/<module><path>behind a method allow-list (405), a body cap (413, default 1 MiB) and — whenSecretis set — constant-time HMAC-SHA256 verification of theX-Nucleus-Signatureheader, rejecting unsigned or mis-signed requests with 401 before your handler runs.nucleus.SignWebhookBodyproduces the signature for senders and tests. Withcsrf_enabled: truethe webhook prefix is exempted automatically — webhooks authenticate by signature, not CSRF token. A webhook registered without aSecretis flagged at boot. RejectClientPK. A per-model opt-in that rejects entities arriving throughCreatewith a client-assigned primary key (model.ErrClientAssignedPK), for apps that bind request bodies straight into models. The check runs before hooks, so server-side key assignment inBeforeCreatekeeps working.
Fixed
- The Asynq task worker stops when you stop it.
Manager.Runwaited on OS signals internally, so cancelling its context (or callingClose) shut the server down but never unblockedRun— an embedded worker could not be stopped through the API.Runnow returns promptly on context cancellation and onClose. - Boot no longer warns about declared jobs and webhooks. The "background execution is not yet wired" readiness warning is gone — both surfaces execute. The warning for embedded migrations stays: Nucleus is SQL-first and never auto-applies them.
Upgrade notes
Nothing to change in existing apps. If a module already declared Jobs
or Webhooks closures (previously inert), they now execute on the next
boot: review those closures before upgrading, set jobs_provider if you
want durability over the in-process default, and note that invalid
registrations that were silently ignored before now fail startup — which
is the point.
v1.3.3 (2026-07-19)
A correctness patch: client-assigned primary keys work through Create,
unsupported engines fail at startup instead of at runtime, and two more
surfaces emit valid T-SQL. Drop-in for most apps — read the upgrade notes
if you point the sql session store or the outbox at SQL Server or Oracle.
Fixed
- A pre-assigned primary key now travels in the
INSERT. Client-generated keys (UUIDs, natural keys) were silently dropped from the insert: SQLite stored a row with aNULLprimary key without any error, and PostgreSQL/SQL Server failed with aNOT NULLviolation. A non-zero key is now included in the statement, and the read-back / back-fill machinery is skipped, so the entity keeps exactly the key you set. A zero-value key keeps the previous behavior: the column stays out of theINSERTand the database generates the key. See Models & database — including the security note on accepting keys from HTTP clients. - The SQL session store and the outbox refuse unsupported engines at startup. Both subsystems speak SQLite, PostgreSQL and MySQL only, but an MSSQL or Oracle database URL used to be silently treated as SQLite — the failure surfaced later, mid-request, as invalid SQL. Construction now fails at startup with an error naming the supported engines.
not nullis matched exactly indb:tags.db:"not null unique"(a space where a;was intended) used to mark the field required and silently lose theunique; the malformed directive now falls through to the startupWARNintroduced in v1.3.2 instead of half-applying.- By-id operations reject models without a primary key.
FindByID,UpdateandDeleteon a model that declares no primary key return an explicit "model has no primary key" error (check witherrors.Is) instead of guessing a phantomidcolumn, and the default list ordering falls back to a real column of the model. nucleus createuserandnucleus changepasswordemit valid T-SQL. Their admin-user lookups used aLIMITclause SQL Server does not accept; on MSSQL they now useSELECT TOP 1.
Upgrade notes
If your configuration points the sql session store or the outbox at an
MSSQL or Oracle database, the app now stops at startup with a clear error
instead of failing later with invalid SQL. That configuration never worked
— it silently ran SQLite-flavored SQL against the wrong engine — but a
deployment that "started fine" before the upgrade will now refuse to boot
until those subsystems point at a supported engine (SQLite, PostgreSQL,
MySQL).
v1.3.2 (2026-07-19)
A correctness patch focused on the model layer's db: tags and on
Create across database engines. Drop-in.
Fixed
- Unknown
db:tag directives now warn at startup. A directive the parser does not recognize was — and still is — applied as nothing; the difference is that the app now logs one startupWARNper affected field, naming the unrecognized tokens and the supported syntax, instead of leaving you trusting a constraint that never existed.db:"-"now excludes a field from persistence. Createonly reads back the generated key when it actually can. TheRETURNING/OUTPUT INSERTEDread-back is now emitted only for models that declare a real, integer primary-key field. Models with string/UUID keys or without a declared primary key previously got a read-back query that could fail (for example against tables with noidcolumn); they now take the plain insert path, matching SQLite/MySQL behavior.- List pagination on SQL Server emits valid T-SQL. Paginated list
queries used a
LIMITclause SQL Server does not accept; they now use theOFFSET … FETCHform. The whole CRUD surface is exercised against a real SQL Server (and Oracle) in release validation. - The version pinned by
nucleus newcan no longer go stale. The framework version written into generatedgo.modfiles is maintained by the release tooling and cross-checked in CI on every build.
Upgrade notes
Nothing to change. If your startup logs show new WARN lines about db:
tags, those tags were already being ignored — fix the tag syntax, don't
silence the log. See
the FAQ
for the supported directives.
v1.3.1 (2026-07-15)
A one-fix patch. Upgrade if Create should hand you the generated primary
key on PostgreSQL or SQL Server.
Fixed
Createbackfills the generated primary key on PostgreSQL and SQL Server. Those drivers do not implementLastInsertId, so the entity's ID field silently stayed at zero after a successful insert.Createnow usesRETURNING(PostgreSQL) /OUTPUT INSERTED(SQL Server) to populate it. Oracle remains a declared gap — see Support & compatibility.
v1.3.0 (2026-07-13)
A minor release that completes the v1.2.0 security hardening pass and rounds out observability.
New
- Opt-in driver-level SQL instrumentation (
sql_driver_instrumentation). Off by default (zero hot-path cost); when enabled, directQueryContext/ExecContextstatements that bypass the model layer — session stores, outbox dispatch, migrations, raw SQL — also reach the observability live SQL feed, without double-recording CRUD statements. - The observability package and its hooks are now stable, covered by the same compatibility promise as the rest of the framework.
Security
- CSRF protection as a config switch.
csrf_enabled: truemounts origin verification (Sec-Fetch-Site) with a double-submit token fallback;csrf_exempt_pathsexcludes Bearer-only subtrees. Themvcscaffold enables it by default. metrics_public: falsetakes/metricsout of the anonymous allow-list and puts it behind the default-deny RBAC enforcer.
Upgrade notes
Both new switches default to the previous behavior (csrf_enabled: false,
metrics_public: true); nothing changes until you opt in.
v1.2.0 (2026-07-12)
A security-hardening minor. Existing deployments can notice these changes at upgrade time — read the notes below.
Security
jwt_secretmust be at least 32 bytes. Any non-empty value used to be accepted; a shorter secret is now a boot error. Generate a proper one (openssl rand -base64 32) or move tojwt_keys[].- Proxy headers are no longer trusted by default.
X-Forwarded-For/X-Real-IPare ignored unless the immediate peer is listed in the newtrusted_proxieskey; otherwise the TCP peer address is the client IP for rate limiting and logs. - HSTS is emitted only over TLS or when explicitly forced
(
env: production) — plain-HTTP development runs are no longer pinned to HTTPS by a stray header.
Upgrade notes
- Short
jwt_secretvalues fail the boot — rotate the secret before upgrading. - If Nucleus runs behind a load balancer, set
trusted_proxiesto its address ranges or rate limiting will see every request as coming from the balancer.
v1.1.0 (2026-07-11)
New
- SQL events report rows affected. The observability feed's SQL events
carry the driver-reported
RowsAffected. Additive; drop-in.
v1.0.0 (2026-07-10)
The first stable release. The compatibility promise starts here: stable surfaces are pinned by contract freeze tests and change only through the documented deprecation policy.
Breaking
- Cross-origin requests are denied by default. The implicit allow-all
CORS default is gone: an empty
cors_originsnow emits no CORS headers at all. Deployments that relied on allow-all must opt in explicitly — a real origin allow-list, orcors_origins: ["*"]to keep the old behavior.
Upgrade notes
If browsers suddenly report CORS errors after this upgrade, set
cors_origins to the exact origins your frontend uses. Everything else in
v1.0.0 is the certification of surfaces that already existed in v0.12.x.