Skip to main content
Version: 1.32.0

Release notes

The current release is v1.32.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.32.0 (2026-10-12)​

nucleus add now writes the wiring an entry needs, not only its import, and the catalog gains SAML sign-in, Sentry error reporting, a Redis cache, accounts, API keys, websockets and the SQL queue. An application built WithoutDefaults() now says at boot which parts of its configuration it does not act on, and can take storage, mail, rate limiting and authorization back one option at a time. An application that adds the SQLite driver resolves 92 modules instead of 134. The release is built with Go 1.26.9, which fixes a crash in the HTTP/2 server.

Nothing is removed and no exported signature moves.

Fixes a running application will notice

  • Built with Go 1.26.9. Go 1.26.6 carries GO-2026-6617, a race in the HTTP/2 header encoder of net/http that can crash a server, and eleven other standard-library advisories in net/http, crypto/tls, mime/multipart and html/template; 1.26.9 fixes all twelve. The CLI binaries and the container image of this release are built with it, and the framework and every sibling module declare toolchain go1.26.9. The go directive stays at 1.26.6, so no application needs a newer Go to build against this release — but the fix is in the standard library, so it reaches your binary only through the toolchain that builds it. Projects that nucleus new writes from this release carry toolchain go1.26.9 and build their image FROM golang:1.26.9-alpine. An existing project gets the same with go get toolchain@go1.26.9 and, if it has the generated Dockerfile, by moving its builder image to golang:1.26.9-alpine: that image ignores the toolchain line and builds with the Go it ships.
  • With on the application router ran the middleware stack twice. Mux.With copied the parent's middleware into the scope it returned. That is right for a Group, whose middleware is applied at registration, but the application router and a sub-router built by Route already run theirs in front of every request. A route registered through With on them ran the whole stack twice: two request log lines, every Used middleware called twice, and an empty RouteFromContext in the handler. The routing guide's own example broke: a route given a longer timeout than the router's with r.With(router.Timeout(...)) still answered 503 at the router's deadline. r.With(mw).Route(...) and r.With(mw).Mount(...) — the path nucleus.Versioned takes — had the same defect. A scope now inherits its parent's middleware only when the parent is itself a scope, which is what Group already did.
  • A multi-tenant /healthz could answer 503 from a healthy application. The storage probe of /healthz and /readyz went through the tenant-scoped store with no tenant, since an orchestrator sends none. With multitenant.require_tenant_storage: true the probe failed and both endpoints answered 503, which a load balancer reads as "take this instance out"; in the default mode the probe used up the one-time warning about tenant-less storage access that is meant for the application's own background jobs. The probe now checks the store below tenant scoping. The store, its policy and every operation the application makes through it are unchanged.
  • The SQL scheduler could fire a job on two replicas. With jobs_provider: sql, a scheduler leader fired on a flag that changed only when a lease renewal returned. A renewal that returned after the lease had expired — a slow database, a paused process — left the old leader and the replica that took over both firing every tick until the renewal gave up. With the default 30-second lease that takes a stall of about 20 seconds; with a lease under about 7.5 seconds, a database slower than two-thirds of the lease is enough. A tick now fires only while the lease the leader won has not run out. The trade-off: a leader whose renewal is late skips the ticks between the end of its lease and the renewal's return, even when no other replica is waiting.
  • The "server listening" line came before the port was bound. A tool that waited for the line and dialled could get connection refused, a port already in use logged the line and then failed, and port 0 was reported as :0. App.Run now binds, then logs, then serves, and a bind or certificate error returns without the line; nucleus serve's own line follows the bind the same way. The message and its fields are unchanged, and addr carries the port the socket holds.
  • db.IsUniqueViolation answered false without saying so. In a test binary whose only database dependency is pkg/nucleustest, which links SQLite, nothing recognised SQLite's errors, so a duplicate key was not a unique violation; the kit now registers the classifier the first time it is used. And an application that registers a database/sql driver itself — github.com/go-sql-driver/mysql instead of drivers/mysql — got false for every duplicate key, although the documentation promised a message at startup. db.New now logs one warning per engine, naming the module to import or the driver.RegisterUniqueViolation call to make. PostgreSQL is recognised with any driver and never warns.
  • BindJSON refused every JSON array. A body such as [{"title": "a"}, {"title": "b"}] bound into a slice answered 400 "invalid input": the array decoded, and validation then rejected it for not being a struct. Slices, arrays and maps, and pointers to them, are now validated element by element, and a failure names its position and field — [1].title — in the same 422 response a struct gets. BindXML follows the same rule. Structs, the 1 MiB cap and the 400 for malformed JSON are unchanged.
  • The test kit's storage helpers in a multi-tenant application. srv.Stored and srv.StoredKeys read with no tenant, so they used up the tenant-less warning, failed the test under require_tenant_storage: true, and could not see a file a request for tenant acme had stored. They now read below tenant scoping, with keys as the backend holds them (acme/uploads/x), and the new srv.StoredFor(tenant, key) and srv.StoredKeysFor(tenant, prefix) read one tenant's files by the keys its requests used.
  • nucleus routes and nucleus dev took a stray go.mod at the system temp root. The go tool ignores one there; the CLI did not, and in any folder under $TMPDIR failed with the toolchain's error. It now stops there as the go tool does: routes falls back to the configuration-only listing, and dev says there is no project.
  • Documentation: Router.Mount said a request for the bare pattern is redirected with 307; it serves the subtree root, and the comment now says so. The OpenID Connect example on the site declared its instance with type: oidc; the key is provider, and an instance declared the other way is refused at boot.

Core-only applications say what they ignore

An application built WithoutDefaults() builds a smaller stack — no storage, no mail sender, no rate limiter, no authorization — and until now a configuration that asked for any of them was accepted and ignored without a word. The api starter did exactly that with nucleus add s3: it built, booted and stored nothing. Each case now logs one ERROR line at boot naming the key, the option that acts on it and the deprecation notice, and the application still starts, as before.

The configuration setsWhat happenedAddNotice
a storage block, or a NUCLEUS_STORAGE__* variableno store was builtWithStorage()DEP-2026-013
mail_driver, other than noopno sender was builtWithMail()DEP-2026-015
rate_limit_requests above 0no limiter was mountedWithRateLimit()DEP-2026-016
rbac_policy_file, or metrics_public: false while metrics are servedno enforcer was builtWithAuthz()DEP-2026-017
policy rows on a mounted module (Module.Policies)discarded: the routes they keep from anonymous callers answered anyone, and deny rows refused no oneWithAuthz()DEP-2026-017
profiling_enabled: true/debug/pprof, heap and goroutine dumps included, answered anyone who reached the portWithAuthz()DEP-2026-018

Each option builds what the default stack builds, at the same point of the chain, and nothing while the configuration asks for nothing; on the default stack it changes nothing. The api starter and nucleus serve --without-defaults now carry WithStorage() and WithRateLimit(). A module is reported for its policy rows only when they would have refused something: one whose rows grant anonymous callers every route it serves loses nothing and logs nothing. Under env: production, the profiler line says what a heap dump carries — session tokens, credentials and request payloads.

nucleus doctor reads the composition root beside the configuration: its storage check warns on an ignored storage block, --check security and the deploy.rate_limit component of nucleus health --deploy no longer report a rate limit the application does not mount, --check security reports the unguarded profiler (an error in production), and --check rbac no longer says "RBAC policy file found" for an application that never reads it. When no composition root sits beside the configuration, as in a deployed image, the rate-limit and profiler findings become a note instead of a warning. See Security.

WithAuthz(): default-deny for a core-only application

WithAuthz() (app.WithAuthz, nucleus.WithAuthz, AppBuilder.WithAuthz) gives an application built WithoutDefaults() the default stack's authorization and nothing else, in the default stack's order: the RBAC enforcer loaded from rbac_policy_file with the bootstrap allow-list; the bearer decode, so API keys, the rate limiter, interceptors and handlers see the token's identity; then the default-deny gate, last. With it, module policy rows are enforced, /debug/pprof answers an anonymous request 403, and a realtime topic answers only the callers a policy grants.

With WithAuthz() and no policy at all, every registered route outside the bootstrap allow-list — /healthz, /livez, /readyz, /login, /.well-known/jwks.json, /static/*, and /metrics unless metrics_public: false — answers an anonymous request 403, exactly as on the default stack. Both stacks now say so at boot:

level=WARN msg="authz: default-deny with 0 policy rows — an anonymous request reaches only the bootstrap routes, and every other registered route answers 403 until a row allows it: …"

The api starter does not call WithAuthz(); nucleus new --template api names it as the way to add authorization.

nucleus add wires what it installs

The catalog is one table, read by nucleus add, nucleus new --with, the help of both and the runtime's "not installed" refusals, and it is pinned to the release: nucleus add <name> fetches the module at the version released with your CLI, not whatever the module proxy calls latest. nucleus new --with takes the same names. A mistyped name gets the nearest one (prometeus → did you mean prometheus?), and after installing, the command prints the key that selects the entry (storage.provider: s3, auth_backends: [ldap]).

An entry that an import alone does not wire carries a recipe: the options and mounts it needs are spliced into the nucleus.New() chain of main.go, and its configuration block is appended to nucleus.yml when none of the block's top-level keys is set there; otherwise the block is printed to merge by hand. A second run changes nothing, and --dry-run prints the plan.

nucleus addWrites
oidcMount(nucleus.FederatedSignIn()) and an auth_federated instance
samlthe module's import, the same Mount, and an instance with placeholders for the identity provider's metadata
sentrythe module's import, http_interceptors: [sentry] and an interceptors.sentry block
redis-cachethe import of pkg/cache/rediscache and cache.provider: redis
apikeysWithAPIKeys()
sql-queuejobs_provider: sql
accountsWithMail(), Mount(accounts.FromRuntime()), mail_driver: log and a modules.accounts block
websocketsWithRealtime()

nucleus add quark, orbit, quarkbridge and quarkdatasource fetch the suite products at their latest tag and import nothing. A configuration key of a backend whose module is not linked — auth.ldap.*, interceptors.sentry.* — is now refused as not installed: nucleus add ldap instead of with a "did you mean" that pointed elsewhere, and every refusal of a missing module names the nucleus add that installs it.

Two new modules: SAML sign-in and Sentry

providers/auth-saml v0.1.0 is a SAML 2.0 service provider for the federated sign-in seam: the same auth_federated declaration and routes as OIDC, plus the application's own metadata at /auth/<name>/metadata for the identity provider to import. It uses github.com/crewjam/saml v0.5.1 for the protocol and github.com/russellhaering/goxmldsig v1.6.1 for XML signatures, and never checks a signature itself. On top of the library it requires the assertion itself to be signed (a signed Response around an unsigned assertion is refused), requires an audience restriction, and requires Recipient and Destination to be the callback; it binds InResponseTo to the request it issued, refuses a replayed Response three independent ways, and refuses six signature-wrapping shapes and a Response carrying two signed assertions. Each check has a test against a stand-in identity provider that ships with the module. Not covered: single logout; encrypted assertions, IdP-initiated sign-in and the artifact binding, all refused. Pending sign-ins and the replay memory live in the process, so several replicas need sticky sessions. See SAML sign-in.

providers/errors-sentry v0.1.0 sends Sentry the errors a handler returns that the framework answers with a 500, and the panics it recovers; the panic is raised again, so the response is still the framework's 500. Each event carries the method, the route template, the status, the request id and the user id. Headers and query parameters on the framework's redaction list leave as [REDACTED], and cookies, request bodies and the client address are never sent. With no DSN the interceptor sends nothing and says so once at boot. The framework side is a seam any error tracker can use: interceptor.ErrorReporter, a method on the http.ResponseWriter an interceptor hands down, which the router calls with the error before writing the 500. See Error reporting.

Only the applications that add these modules link the SAML libraries or the Sentry SDK. Use them with v1.32.0 or later: nucleus.FederatedSignIn(), which serves the sign-in routes, and the hook that hands Sentry a handler's error are part of this release; on v1.31.0 the Sentry module reports panics only.

Accounts, API keys, sign-in routes and realtime from the running application

  • accounts.FromRuntime() builds the account flows of pkg/accounts at start-up from the running application — its default database (SQLite, PostgreSQL or MySQL), its mail sender and its session manager — configured under modules.accounts, where base_url and from are required. It refuses to start when the sender delivers nothing — no sender, or mail_driver: noop — rather than accept registrations whose confirmation links go nowhere. For development, the new mail_driver: log writes each message, links included, to the application's log; it is refused outside env: development. See Accounts.
  • WithAPIKeys() opens the key store nucleus apikey create issues into, on the default database (SQLite, PostgreSQL or MySQL), and mounts the key middleware ahead of the rate limiter and the interceptors on either stack, so the limiter keys a request by the key's owner. The default-deny layer now authorizes a request as, in order: the bearer token's user and role; an API key's owner, and scope:<name> for each scope the key carries; the account a session signed in; then anonymous. A key issued with nucleus apikey create --owner <account id> acts as that account.
  • nucleus.FederatedSignIn() serves GET /auth/<name>/start and GET|POST /auth/<name>/callback for every auth_federated instance — OIDC, and SAML with its module. The anti-forgery state rides in an HttpOnly cookie scoped to the instance; the callback rotates the session token, records the identity and answers it as JSON, or redirects to modules.federated.redirect, a path of the application (an absolute address is refused at boot). With csrf_enabled, the callback paths are exempt, and the boot log says so.
  • WithRealtime() gives the application a hub (App.Realtime, and nucleus.RealtimeFrom(rt) for a module) and GET /realtime/{topic}: a WebSocket for an upgrade request, server-sent events for Accept: text/event-stream, 406 otherwise, one-way. A topic is authorized by its path like any route; on a WithoutDefaults() application without WithAuthz() every topic is open, and the boot log says so. See Realtime.

A cache in every application

app.New now builds a cache on every stack, WithoutDefaults() included, from the new cache block (experimental): memory by default; sql, the table nucleus createcachetable creates in the default database; or redis, from pkg/cache/rediscache, which registers itself when imported (nucleus add redis-cache). A module takes it with nucleus.CacheFrom(rt) in OnStart. With Redis, entries are shared by every instance pointed at the same server, under cache.prefix (default nucleus:cache:, which YAML needs quoted), and /healthz reports the server under cache, answering 503 when it stops responding. An unknown cache.provider, or redis without the import, stops the boot; nothing falls back to the memory cache. The Redis backend adds no module to an application's build: go-redis was already linked through the session store and the health probe. See Caching.

Plugins

  • plugins.Serve is the plugin side of the plugin contract. A plugin author writes one typed handler per capability (MailSend, QueuePublish, WebhookDeliver, or Custom), and Serve answers capabilities, reads the request envelope, writes the response and exits with the contract's code. plugins.Fail chooses the exit code and whether the host may retry; plugins.ServeIO takes the streams, for a plugin's own tests. Standard library only.

  • nucleus plugin test --execute now sends a real request for each capability and checks the exit code, the response envelope and the echoed request id. Until now a plugin that answered garbage with exit code 50 passed.

  • External commands, nucleus-<name> executables on PATH, are listed by nucleus --help, nucleus help and nucleus plugin list, and nucleus help <name> runs nucleus-<name> --help.

  • An opt-in allowlist bounds which external executables run:

    plugins:
    allow_external: true # false: no external plugin or command runs
    allowed: # when set: the only capability plugins that run
    - provider: sendgrid
    capabilities: [mail.send]
    commands: [lint] # when set: the only nucleus-<name> commands dispatched

    An executable the block does not allow is refused before it runs — it is not even asked for its capabilities. With nothing set, every external executable runs, as before, and an unlisted mail plugin logs one warning at boot; from v2.0.0 only listed ones run (DEP-2026-014).

  • An outbox bridge of type: plugin hands each committed message to nucleus-plugin-<provider>, as queue.publish or as webhook.deliver (the same signed request the webhook bridge sends). At boot it checks the allowlist, the executable and the capability, and the application does not start if one is missing; with no allowlist set, it runs and warns once. outbox.Permanent(err) marks a bridge error as final, so the message goes to the dead letter on that attempt instead of being retried; the plugin bridge uses it for exit codes 10 and 30 unless the plugin marks the failure retriable.

  • nucleus new <name> --template module writes a module repository: the module, a test held to nucleustest.CheckModule, a README and a CI workflow. See Writing a module.

Two example plugins, a Maildir mail provider and a queue and webhook relay, live in the repository as tested fixtures.

The SQL queue scales with its workers

On v1.31.0 every worker of jobs_provider: sql raced for the same first rows, so more workers meant more collisions: at sixteen workers on PostgreSQL, nine claims in ten came back empty, and the worker then slept a whole poll interval with work waiting. PostgreSQL, MySQL 8 and MariaDB 10.6 or later now claim with FOR UPDATE SKIP LOCKED. The store checks once, when it opens, that the engine accepts the statement, and falls back to the portable claim with a warning when it does not (MariaDB 10.5 does). On SQLite, a worker that loses a race waits a random moment of up to 1 ms, doubling with each consecutive loss, instead of the whole poll interval.

Measured locally — PostgreSQL 16 and MySQL 8.4 in Docker, the real manager, 1-second poll, a handler that does nothing, 3,000 jobs, best of two runs — in jobs per second:

1 worker4 workers16 workers
PostgreSQL, v1.31.0620761681
PostgreSQL, v1.32.01,8394,1428,108
MySQL, v1.31.0329317330
MySQL, v1.32.03529471,811

Those figures depend on the host. On a CI runner with 4 vCPUs, which saturates at about four workers, going from 1 to 16 workers took PostgreSQL from 1,268 to 3,697 jobs per second and MySQL from 386 to 928. MySQL's single-worker throughput can come out below v1.31.0's, because its claim is a transaction with two more round trips. The claim reads its index in order, so its cost does not grow with the backlog: on PostgreSQL with 200,000 pending jobs, one claim went from 37 ms to 0.1 ms.

Stopping changed too. An application with jobs_provider: sql took about 9 seconds to stop and left its scheduler lease held; in the same measurement it now stops in 0.7 ms, after the scheduler has released its lease and before the database closes.

A smaller build

Each driver module now registers its own error classifier, typed to its own engine. Until now every driver module went through a shared package that linked all five engines, so an application that added only drivers/sqlite also linked pgx and the MySQL, SQL Server and Oracle drivers. The shared functions keep their names, so an application that upgrades the framework and not its driver module gets the fix too, and any combination of driver module and framework version builds.

The CLI moved to a module of its own, github.com/jcsvwinston/nucleus/cmd/nucleus, released from the same commit with the same version number, and the framework's own tests now link SQLite and no other engine. The framework's go.mod no longer lists pgx, the MySQL, SQL Server and Oracle drivers, miniredis or goleak, so an application's build list no longer carries them. go install github.com/jcsvwinston/nucleus/cmd/nucleus@latest and @v1.32.0 work as before, and the CLI still links all five engines. A contributor building the CLI from a clone runs make workspace first.

Measured on darwin/arm64 with Go 1.26.6; the build list is go list -m all, linked modules are the dep lines of go version -m:

ProgramBuild listLinked modulesBinaryStripped (-s -w)
pkg/app alone92 → 7939 → 3931.0 MB21.3 MB
pkg/app + drivers/sqlite134 → 9260 → 4860.8 → 36.8 MB45.9 → 25.2 MB
api starter (nucleus new --template api)137 → 9569 → 5766.2 → 42.8 MB49.6 → 29.3 MB

The binary of pkg/app alone does not move: the modules that left the build list were never linked into it. A CI check now fails when either of the first two programs grows — its build list, its linked modules, its packages, or its stripped binary by more than 2% — measured on linux/amd64.

Sibling modules

New: providers/auth-saml v0.1.0 and providers/errors-sentry v0.1.0. Patch releases, built with Go 1.26.9: drivers/postgres, drivers/mysql, drivers/sqlite, drivers/mssql, drivers/oracle, exporters/otlp, exporters/prometheus, providers/storage-s3, providers/storage-gcs, providers/storage-azure and providers/secrets-aws at v0.1.9, and providers/ldap at v0.2.13. The four drivers other than PostgreSQL carry their own error classifier. nucleus add installs each one at these versions.

Refused from v2.0.0

Each notice changes a configuration default or combination, not a symbol: nothing is refused on the v1.x line, and each case is reported at boot from this release. v2.0.0 comes no earlier than 2027-01-02.

  • A storage block on a WithoutDefaults() application without WithStorage() (DEP-2026-013).
  • An external plugin or command the plugins block does not list, which becomes denied by default (DEP-2026-014).
  • A mail driver on a WithoutDefaults() application without WithMail() (DEP-2026-015).
  • A rate limit on a WithoutDefaults() application without WithRateLimit() (DEP-2026-016).
  • rbac_policy_file, metrics_public: false or module policy rows on a WithoutDefaults() application without WithAuthz() (DEP-2026-017).
  • profiling_enabled on a WithoutDefaults() application, unless WithAuthz() guards the profiler (DEP-2026-018).

Behaviour changes to note

  • An application that imports one driver module and connects with another engine's URL is refused at boot, naming the nucleus add that installs the right one. Until now the other engines were linked anyway, so an application with only drivers/sqlite, for example, connected to PostgreSQL.
  • The authorization line at boot changed on both stacks: the "no user policies loaded" warning and the "RBAC enforcer initialized" line are replaced by authz: default-deny with N policy rows …, a warning when N is 0.
  • On a WithoutDefaults() application with profiling_enabled, the warning that told the operator to keep the profiler behind a policy is replaced by the ERROR line above.
  • With jobs_provider: sql, the jobs runtime now stops inside the shutdown, before the modules' OnShutdown and before the pools close; it used to stop after the run returned. Jobs that become available in the same microsecond are claimed in index order rather than by creation time.
  • app.New builds a cache on every stack, and an unknown cache.provider stops the boot.
  • With port 0, the listening line reports the port the system assigned.
  • nucleus add redis suggests redis-cache; redis is not an alias, because sessions and the job queue speak Redis too.
  • nucleus new ends by pointing at the quickstart and the chosen template's guide.

Upgrading

Nothing is removed and no exported signature moves; every new option and key is off, or does what the application already did, until you set it. Build with Go 1.26.9 or later (above). If a WithoutDefaults() application logs one of the new ERROR lines, add the option it names or remove the configuration it ignores: either is what v2.0.0 will require.

v1.31.0 (2026-10-04)​

An application can now be tested without writing its own harness, and its API describes itself: the document comes from the routes and the Go types, the application can be held to it, and a TypeScript client is generated from it. Handlers bind their whole input typed and answer errors in one shape, and modules find each other typed and start in the order they declare.

The test kit

nucleustest grew from a launcher into a kit. The server it starts has a client that speaks JSON, keeps cookies (including Secure ones over the loopback), fetches the CSRF token and signs a user in on the application's own session store:

srv := nucleustest.Start(t, nucleus.New().Mount(shop.Module(client)))
resp := srv.Post("/api/articles", map[string]any{"title": "probe"})
var created shop.Article
resp.JSON(t, &created)

nucleustest.Make[T] builds a persisted record with defaults, and nucleustest.Transactional runs a whole test inside one transaction that is rolled back at cleanup (the application's own transactions become savepoints). The doubles read back what the application sent out: the memory mail driver (srv.SentMail()), the memory storage provider (srv.Stored(key)), every enqueued job (srv.EnqueuedTasks()), and nucleustest.NewHTTPRecorder for the services the application calls. nucleustest.CheckModule(t, spec) runs on one module every check boot makes — and a few it does not, such as a policy row that covers no route — and reports them all at once. The test the suite starter generates speaks through the kit's client.

The API document comes from the code

WithOpenAPIDocument("/openapi.json") serves the OpenAPI 3.1 document the framework derives from the application: every route the modules register (groups and resources included) with its module as the tag, schemas read from Go structs (openapi.SchemaOf[T], following encoding/json and the validate tags), and the security the application enforces — the bearer scheme, and an explicit "no authentication" on exactly the operations the policy opens to anonymous callers. A hand-written contract is passed as the base and kept as written. nucleus openapi boots the application the way nucleus routes does and exports the same bytes.

Typed endpoints give the document its types:

func (m *module) createArticle(c *nucleus.Context, in CreateArticle) (Article, error) { … }

nucleus.Handle(r, http.MethodPost, "/api/articles", m.createArticle, nucleus.Status(http.StatusCreated))

The input is bound and validated before the function runs, the output is written as JSON, and the operation is described from the two types.

WithOpenAPIValidation() checks every request against the document before the handler runs; srv.AssertConforms(t, resp) holds a response to it from a test; nucleus openapi --check baseline.json fails naming every change that breaks a client written against a previous export; and nucleus openapi --client typescript writes a dependency-free TypeScript client over fetch. The validator, the comparison and the generator are written in Go: nothing is added to an application's dependencies.

One shape for input, one for errors

c.BindQuery, c.BindPath, c.BindHeaders and c.BindRequest bind by the query, path and header tags (and the JSON body for the last), with the conversions BindForm already had. A client that prefers application/problem+json gets RFC 9457 problem details beside the framework's envelope, and WithProblemDetails() makes them every error's shape. The router's own 404 and 405 answer in that shape for a client that asks for JSON. c.Negotiate picks JSON, XML or text by Accept; nucleus.Versioned and Module.Version mount an API version and send Deprecation and Sunset; nucleus.Timeout sets a timeout per route, longer or shorter than the router's; c.RawHTML writes a raw string.

Modules find each other

A module provides a value in its OnStart (nucleus.Provide) and another resolves it typed (nucleus.Resolve); Module.DependsOn orders the start, and the shutdown reverses it. Request values are typed (nucleus.NewKey, SetValue, Value). A boot that fails after the pools open now shuts down every module that already started, in reverse order, and closes the framework's own resources before returning the error.

Deprecated, removal in v2.0.0

  • Context.HTML(code, html) → RawHTML, or Render for a template (DEP-2026-009).
  • Context.Get / Context.Set → NewKey, SetValue, Value (DEP-2026-011).
  • auth.NewJWTManager, db.NewModuleMigrator, db.NewModuleFSMigrator and router.CSRFMiddleware, which panic on bad input → their forms that return the error (DEP-2026-012).

Behaviour changes to note

  • The router's 404 and 405 answer JSON to a client whose Accept prefers JSON; a browser, */* or no Accept still gets Go's plain text.
  • min, max and len validation messages say what they count: "must be at least 1" for a number, "items" for a slice.
  • A module's failing OnShutdown is reported as nucleus: module "x" OnShutdown: ….
  • nucleus openapi exports the document the application serves; a project whose application serves none and keeps internal/contracts exports that contract as before (--from contracts asks for it explicitly).
  • generate resource and startapp pass internal/contracts as the base of the served document in main.go.

v1.30.1 (2026-09-20)​

Dependencies only. The twelve sibling modules (five database drivers, two telemetry exporters, five providers) now require v1.30.0 of this line instead of v1.29.0, so building any of them on its own resolves the packages v1.30.0 added — the jobs, events and channels of the previous release — without a workspace. Each module gets a patch tag from the same release. Nothing in the framework changes.

v1.30.0 (2026-09-19)​

Background jobs get a queue that survives a restart without a broker, a browser gets a live channel the application did not have to hand-roll, an event can carry its payload type, and an orchestrator gets two probes that answer different questions.

A durable queue that is a table, not a broker

jobs_provider: sql runs background jobs as rows in the database the application already has. Until now a job that had to survive a restart meant jobs_provider: asynq, and that means operating a Redis; the default in-process provider holds its jobs in memory, so a restart takes whatever was queued with it. The SQL provider is the third option: no broker, and the same ModuleSpec.Jobs declarations and the same Runtime.Tasks() calls on either side of the switch.

jobs_provider: sql
jobs_concurrency: 4
jobs_queues: [urgent, default, bulk]
jobs_table: nucleus_jobs
jobs_retention: 24h

The table and its indexes are created when the queue opens, per dialect, and opening it a second time — which is what a second replica does — changes nothing. jobs_table names it; two applications sharing one database should give each other different tables, because the scheduler's leadership row is derived from that name.

It speaks Postgres, MySQL and SQLite. The dialect comes from the default database's URL, and sqlserver://, mssql:// and oracle:// are refused by name: boot fails with an error naming the three engines the provider does speak. The claim, the DDL and the timestamp handling have been exercised against those three only, and a queue that quietly spoke a dialect nobody had run it against would lose work in a way nobody would trace back to the choice of engine. The provider imports no driver of its own — it speaks database/sql over the pool the framework already manages — so selecting it adds nothing to your build.

At-least-once, held by a lease and fenced by its owner

A worker claims a job by taking a lease on it and renews that lease while its handler runs. Two kinds of row are claimable: one that is pending and due, and one that is running with an expired lease — claimed by a process that never came back. That second case is why the queue survives a crash, and it is also why the guarantee is at-least-once and not exactly-once: a job whose worker died halfway legitimately runs again. Write handlers so that running one twice is not a second charge, a second email or a second row.

The attempt is charged when the job is claimed, not when it finishes, and the outcome writes are fenced by the lease owner. A worker whose lease expired while it was working no longer owns the job, and its done can no longer overwrite the state of the worker that took over — the classic way a finished job comes back from the dead. When that happens the manager logs it and says the job will run again, rather than leaving a silent duplicate.

Three consequences worth knowing before you read the table:

  • A job that kills its process is retried, but not for ever. Charging the attempt at claim time means a handler that never returns still moves towards its budget, and what finally retires it is the sweep, not the claim: the claim predicate looks at queue, due time, status and lease only, so a running row whose lease has expired is claimable whatever its attempts. The sweep runs on the lease timer — every 30 seconds by default — and moves an expired-lease row whose attempts are spent to the dead letter. With the default budget of three attempts, a job that takes the process down takes it down at least three times: a worker polling every second can reach the row again before the sweep does.
  • A job released without having run gets its attempt back. A task type this replica has no handler for is put back as pending rather than failed — another replica may handle it, and a worker deployed later certainly will — and it would otherwise burn its whole budget in three seconds without executing once.
  • Handlers still running when the process stops get the HTTP write_timeout as their grace period, and the heartbeat keeps renewing their leases for as long as they run. Anything still held after that is released back to the queue, so the next process picks it up immediately instead of waiting out the leases.

What the enqueuer decides travels with the job

EnqueuePolicy gains BackoffBase and BackoffMax: the first retry waits BackoffBase, each one after that doubles, and none waits longer than BackoffMax. A webhook that should back off for minutes and a thumbnail that should be retried in a second used to get the same wait, whichever suited the provider.

id, err := rt.Tasks().EnqueueJSONWithPolicy("invoice.render", map[string]int{"id": 42},
tasks.EnqueuePolicy{
Queue: "urgent",
MaxRetry: 5,
UniqueKey: "invoice:42",
BackoffBase: 2 * time.Second,
BackoffMax: 5 * time.Minute,
})

The curve is stored with the job, so it is the enqueuer's decision and it survives a restart. Left at zero it is the provider's own: one second doubling to a minute for the SQL provider. A provider that cannot express a curve per job ignores the two fields and says so in its documentation rather than pretending — the in-process provider keeps its fixed curve, and asynq applies its server-wide delay function.

UniqueKey collapses jobs that mean the same thing. While a job with that key is waiting or running, enqueueing another with the same key returns the id of the one already there instead of adding a second, so a double-clicked button, a webhook delivered twice or a retry loop upstream does not become duplicated work. The second caller gets an id and no error, because "this work is already scheduled" is a success for whoever asked. The key is yours to choose and it is scoped to the queue; it rides on a unique index over a nullable column, which all three engines treat the same way, so a job without a key is unconstrained. It is cleared when the job finishes — done, dead or retired — so the same work can be scheduled again afterwards, and a job still retrying keeps its key reserved. Only the SQL provider honours it today; the others ignore it and say so.

jobs_queues in the same spirit is an order, not a set of weights: the first queue is served before the second, and a queue further down is still served once the ones above it are empty. A queue no worker is configured for is not served at all, which is the point — work put on bulk is not silently picked up by everyone. asynq keeps taking its weights from its own configuration, and the in-process provider still has exactly one queue.

Cron without Redis

A cron entry needs exactly one replica to tick it, or every replica fires every entry on every tick. The SQL provider holds that election as a lease row in the application's own database — no broker and no consensus protocol, because every replica already shares that database and the database already serialises writes to a row. Whoever's UPDATE affects the row leads; everyone else reads zero rows affected and waits.

Every replica runs the schedule and only the leader enqueues. A leader that dies is replaced within one lease (30 seconds, renewed at a third of it), and one that stops cleanly gives the lease up so nobody has to wait it out. Two details are deliberate: the leadership check happens per tick, not once per process, so a replica that loses the lease stops firing immediately rather than at its next restart; and losing the database is losing the right to fire, because a replica that cannot renew must assume somebody else has taken over.

jobs_scheduler_lock remains the asynq knob, over a Redis lock. The SQL provider's election is not optional and has no key — there is no configuration in which it fires from every replica.

Finished jobs are not kept for ever

jobs_retention is how long a finished job stays in the table before it is deleted; 24h by default, 0 means the default, and a negative value keeps them for ever for an application that audits its own queue. The purge runs on the same timer as the abandoned-job sweep. Without it the table only grows in a system that is working perfectly, and the index the claim depends on gets slower every week. Dead jobs are never removed by retention, whatever it is set to: they are the ones somebody may still want to put back, and purge-archived is the deliberate act that removes them.

The queue can be asked what it is doing

tasks.Inspector is the queue's introspection surface: InspectRuntime() returns a snapshot — per queue and in total: pending, active, completed, archived, processed and failed — and OperateQueue(queue, action) performs one operational action on it. All three providers implement it now, including asynq, whose two package functions had never been given an adapter. Until this release the framework handed out the Manager and no inspector at all, so nothing could display the queue: an admin panel asking for one was told there was none, whatever the deployment was actually running.

if inspector, ok := nucleus.TaskInspectorFrom(rt); ok {
snap := inspector.InspectRuntime()
for _, q := range snap.Queues {
logger.Info("queue", "name", q.Name,
"pending", q.Pending, "active", q.Active, "dead", q.Archived)
}
if _, err := inspector.OperateQueue("urgent", tasks.QueueActionRetryArchived); err != nil {
logger.Warn("the provider cannot do that", "error", err)
}
}

It is reached through nucleus.TaskInspectorFrom rather than a method on Runtime: Runtime is a published interface, and growing it would break every implementation outside this repository. Nothing to change in your code — an application that never asks for an inspector behaves exactly as it did.

What a snapshot means depends on the provider, and the difference matters when you read one:

  • The SQL provider's numbers come from the table, so they describe the whole deployment. The in-process provider's describe this process, because that is all it has, and it fills total_pending and total_active without breaking them down: its single queue row leaves pending and active at zero rather than claiming a count it does not keep.
  • Completed jobs are excluded from a queue's Size in the SQL provider: finished rows stay in the table until retention removes them, and counting them would tell an operator that a drained queue holds two hundred jobs.
  • Of the six action names, asynq honours all six. The SQL and in-process providers honour retry-archived and purge-archived and refuse the other four by name, with an error that says so. An operator who presses "pause" and is told this provider cannot pause is better served than one whose click quietly does nothing.

The in-process queue stops throwing work away

The default provider used to discard a job whose type no handler had registered: it counted a failure and dropped the work. That is the exact shape of a producer deployed ahead of its consumer, or of a typo in a task type — and in both cases the job was gone.

It now holds such a job instead, and registering the handler releases everything that was waiting for that type back onto the queue. Jobs that ran out of retries, and jobs the process stopped before it could finish with them, are held too, with the reason and what the last attempt said; a shutdown drains whatever was still queued into that store rather than swallowing work the caller was told had been accepted. Held jobs go back through the retry-archived action.

Four things to know, because this is a receipt and not durability:

  • The two stores are separate. A mistyped task type cannot evict the jobs that actually died, and emptying the dead letter does not throw away work that is only waiting for its consumer — purge-archived says how many waiting jobs it kept.
  • Both stores are bounded at 1000 jobs each, oldest evicted first, and an eviction is logged. An unbounded store would turn a leak of jobs into a leak of the process's memory.
  • A held job keeps the values of the context it was enqueued with — site, tenant, database alias — and loses its cancellation, so a requeued job does not run against the wrong tenant and is not cancelled by the request that is long over. A job put back is tied to the manager, so stopping the process still stops it.
  • A job held because nothing handled it, because it ran out of retries, or because the process stopped between two of its attempts, is still counted in total_failed_today. What a shutdown sweeps out of the queue is not: a job that was accepted and never ran — including a delayed one still waiting out its ProcessIn — is held with its reason and left out of the counter, because nothing about it failed. That counter is published and frozen and people have alerts hanging off it; this change is about not losing the job, not about quietly moving a number somebody already watches.

Everything here dies with the process. It is a receipt: the loss is counted and named instead of invisible. The queue that survives a restart is the SQL one above.

Channels, over SSE and over WebSocket

pkg/realtime is a new opt-in package. A Hub owns topics, a connection subscribes to one or more of them, and a broadcast reaches every subscriber of that topic. Both transports a browser understands are served from the same hub, and the protocol is the framework's: the SSE framing and flush discipline, and on the WebSocket side the handshake, the accept key, the framing, fragment reassembly and the ping/pong. Until now an application that wanted a progress bar or a notification wrote all of that itself — the framework exempted long responses from the write timeout and stopped there.

hub := realtime.New(realtime.Config{Logger: logger})

r.Get("/live", func(c *nucleus.Context) error {
return realtime.ServeSSE(c.Writer, c.Request, realtime.SSEConfig{
Hub: hub,
Topics: []string{"orders"},
User: c.UserID(),
OnConnect: func(send func(realtime.Message)) {
send(realtime.Message{Event: "snapshot", Data: current()})
},
})
})

hub.Broadcast(ctx, realtime.Message{
Topic: "orders", Event: "created", Data: payload,
})

ServeWS takes the same shape with WSConfig, plus OnMessage for what the client sends — nil ignores inbound messages, which is the right default for a one-way channel. Authorisation happens in the handler before either call, the way it does for any other route; what the handler decides it passes in as User, which is also what Hub.Presence(topic) reports, one entry per connection rather than per person.

Four things to know before you wire one up:

  • A Message carries Topic, Event, Data and ID. Over SSE three of them reach the wire: Event becomes the event name, ID comes back as Last-Event-ID after a reconnection, and Data is the payload. Topic selects who receives the message and is never written to the stream. Over WebSocket only Data is written — no envelope at all. Either way, a client subscribed to more than one topic cannot tell them apart from the framing, so put the topic in Event or inside the payload.
  • A stream with no topics answers 400 rather than opening an empty one, on both transports. A ClientID already connected answers 409 over SSE; over WebSocket the handshake has already completed by the time the subscription is attempted, so there is no status left to send — the socket closes with a normal 1000 and ServeWS returns the error to your handler. Left empty, ClientID is derived.
  • SSE sets X-Accel-Buffering: no, because nginx buffers proxied responses by default, which turns a live stream into one lump that arrives when the connection closes; and it sends a comment every 25 seconds (SSEConfig.KeepAlive), so a proxy or load balancer does not close a connection that is merely quiet.
  • ServeSSE clears the server's write deadline for its own connection. http.Server.WriteTimeout is counted from the moment the request arrived rather than per write, so a stream outlives it by definition — at the default write_timeout: 60s every live view would be severed after a minute, and the keep-alive does not help, because it proves the stream is alive and the deadline does not care. It is cleared in the handler rather than in your configuration because a process usually serves streams and ordinary requests, and the ordinary ones want the timeout. ServeWS was never affected: it hijacks the connection and sets its own deadline per frame.
  • ServeSSE needs a writer that can flush and ServeWS one that can be hijacked. Both work behind the default middleware stack as of v1.29.0, which is where the session wrapper learned Hijack.

pkg/realtime is listed as transitional in the package inventory, so unlike pkg/app or pkg/router its surface is not frozen by the contract tests yet.

The origin check, the message bound, and the close codes

A browser sends the session cookie with a WebSocket handshake and does not apply CORS to it, so a permissive upgrade is a cross-site request carrying the user's session. UpgradeConfig.CheckOrigin decides, and leaving it nil means same origin only. Be precise about what that comparison is:

  • The Origin header's authority — everything after :// — is compared with the request's Host, ignoring case. The scheme is not compared, so http:// and https:// on the same host and port both pass.
  • A request with no Origin header at all is allowed. Browsers always send one on a handshake; native clients, CLI tools and tests do not. The default stops a cross-site browser handshake. It is not an authentication or an authorisation mechanism, and nothing about it replaces the check your handler does before calling ServeWS.
  • The comparison is against Host as the process sees it. Behind a proxy that rewrites Host, that is the internal name and not the one the browser typed — set CheckOrigin explicitly there.

An inbound message is bounded at 1 MiB (DefaultMaxMessageBytes, overridden per connection with UpgradeConfig.MaxMessageBytes). The bound is checked against the frame header before the bytes are allocated, because that length is attacker controlled, and a fragmented message is bounded the same way as it reassembles, so an endless run of continuation frames is refused rather than allowed to grow the process. Over the limit closes with 1009. A client frame arriving unmasked is a protocol error and closes with 1002 — every frame from a client must be masked, and accepting one means accepting something that is not a browser. A normal close is 1000. Outbound messages are not bounded; that payload is yours.

A write acquires the connection's lock with a five-second timeout and carries a ten-second write deadline, so a peer that stopped reading produces an error instead of holding the connection. ServeWS pings every 30 seconds (WSConfig.PingInterval) and gives up when that write fails. It does not track pongs, and it keeps the *Conn to itself — WSConfig has no field that hands it out, and OnMessage receives the *Client — so Conn.SetReadDeadline is reachable only if you call realtime.Upgrade and drive the connection yourself. Under ServeWS, a peer that goes silent without closing keeps its goroutine and its subscription until a write to it fails.

A slow client is disconnected, and told so

Each connection gets a 64-message buffer (Config.ClientBuffer). When it is full, the message is counted against that client and the client is unsubscribed — Broadcast never blocks on it. The alternative is blocking the broadcaster, which makes one stalled browser everybody's problem, or growing the buffer without bound, which makes it the process's. The client reconnects and resubscribes, which is what browser-side client libraries already do.

Client.Dropped() counts the sends that could not be made, not the backlog the client then missed: the hub increments it on the broadcast that found the buffer full and unsubscribes the client on that same pass, so it is normally 1 and stops moving the moment the client is gone. The SSE transport writes a last event before closing, so a live view ends with a reason rather than going subtly wrong in silence:

event: nucleus.disconnected
data: {"reason":"too slow","dropped":1}

A WebSocket client gets a 1000 close and nothing else. The hub logs the disconnect with the client id, the topic and the buffer size — the count is not in that log line, so over WebSocket it is reachable only in the process, through Client.Dropped().

Across replicas

A hub reaches the clients this process holds. RedisRelay carries a broadcast to the ones the other replicas hold, so a live view does not quietly become live-for-half-the-users when the second pod comes up:

relay, err := realtime.NewRedisRelay(realtime.RedisRelayConfig{URL: redisURL})
if err != nil {
return err
}
hub := realtime.New(realtime.Config{Relay: relay})
if err := hub.StartRelay(ctx); err != nil {
return err
}

Each relay carries an origin id (derived when you do not set one), and Subscribe skips messages whose origin is its own. That is not an optimisation: Broadcast delivers locally first and publishes second, so without the skip every client would see every message twice.

  • It is pub/sub, not a stream. Nothing is persisted, deliberately — a broadcast is only useful to the clients connected right now, and a replica that was down had none. Messages that must survive a restart belong in the outbox.
  • Broadcast returns nothing. A relay publish that fails is logged at ERROR and the local subscribers still receive the message; the other replicas do not. If a missed fan-out matters to you, that log line is where it appears.
  • The channel is nucleus:realtime. Two applications sharing one Redis database must set distinct channels, or better, use distinct databases.

Events that carry their payload type

pkg/signals moves any: every handler asserts the type it hopes for, and a producer that changes the payload breaks its subscribers at run time, one event at a time. A topic makes the payload part of the name, so the compiler is what notices.

type InvoicePaid struct {
InvoiceID string `json:"invoice_id"`
AmountEUR int `json:"amount_eur"`
}

var InvoicePaidTopic = signals.Define[InvoicePaid]("invoice.paid")

signals.Subscribe(bus, InvoicePaidTopic, func(ctx context.Context, p InvoicePaid) error {
return receipts.Send(ctx, p.InvoiceID)
})

err := signals.Publish(ctx, bus, InvoicePaidTopic, InvoicePaid{
InvoiceID: "inv-1",
AmountEUR: 4200,
})

Publish has Emit's semantics — handlers in registration order, the first error stops the chain and reaches the caller — and PublishAsync has EmitAsync's. The typed handler receives the publishing context as an argument instead of reading it off Event.Ctx, so work it starts belongs to the request or the job that caused it.

This is delivered alongside the untyped API, not instead of it. Signal, Event, Handler, On, Emit and EmitAsync are unchanged, and it is one bus with two doors: Topic.Name() is the signal untyped subscribers use, a typed subscriber hears an untyped Emit of the same signal, and an untyped handler hears a typed Publish. A payload that travelled arrives generic — JSON bytes from the outbox, a decoded map from the Redis relay — and the typed subscription turns it into the topic's type rather than refusing the right thing in the wrong representation.

Three consequences of that coercion, before you point a topic at a signal:

  • When the value is not already the topic's type it is round-tripped through encoding/json, so what arrives is what the payload's JSON tags describe. Unexported fields do not survive, a value encoding/json cannot represent fails the coercion, and a nil payload is the type's zero value rather than an error.
  • A payload that genuinely does not fit the topic is an error, not a silent skip. The subscription returns it, so a synchronous publish aborts the operation that emitted the event.
  • Subscribing types the signal for everyone. Attach a topic to a signal the model layer already emits — post_save, say — and an untyped Emit of it carrying something that does not decode into that type now returns an error to whoever emitted it.

A handler that panics is an error, not a crash

Bus.Emit — the synchronous path, the one that runs inside a model save or a request — let a panicking subscriber unwind into its caller. It now recovers around each handler and returns signals.ErrHandlerPanic wrapped with the panic value; the stack is logged on the bus's logger, where it happens, because the error that reaches the caller cannot carry it.

A panic aborts the chain exactly as a returned error does — handlers registered after it do not run — and the emitting operation sees the failure. So a broken subscriber still costs its event; the change is that the emitter now gets an error to decide with instead of an unwinding panic. The recover covers Emit, and therefore Publish and every delivery that goes through them, including the outbox bridge below, where a panicking subscriber becomes a failed delivery the dispatcher retries. EmitAsync and PublishAsync already recovered and still only log: errors do not propagate on that path by contract.

Emitting asynchronously returns to the caller

EmitAsync took its concurrency slot on the caller's goroutine, so once the per-bus ceiling of 64 running handlers was full, the emitter waited for one to finish. The emitter is usually a model save inside a request, which made the slowest subscriber the user's latency. The slot is now taken inside the goroutine, so EmitAsync and PublishAsync return without waiting on any handler.

Not blocking has to fail somewhere, and here it fails by dropping. Up to 4096 dispatches may be in flight — running, or waiting for one of the 64 slots — and past that a dispatch is dropped with an error-level log naming the signal, the model, the limit and the running total. Bus.Dropped() returns that total as an int64, which is how an application notices without waiting for a missing side effect to be reported to it.

  • Both ceilings are constants, per bus. There is no configuration key for either, and no way to ask for blocking instead of dropping.
  • Admission is counted per handler, not per event: an event with three subscribers takes three of the 4096, and at the limit some subscribers can run while others are dropped.
  • Emit is untouched. The synchronous path has no ceiling and drops nothing.

The outbox delivers onto the bus

An outbox topic and a signal of the same name were unrelated: pkg/outbox delivered to external bridges, pkg/signals to in-process handlers, and an application had to choose a transport rather than a subscription. Pick the outbox and your in-process listeners never hear the event; pick the bus and the event dies with the transaction that produced it.

outbox.NewBusBridge is a bridge like the Kafka and webhook ones, registered and routed the same way:

bridge, err := outbox.NewBusBridge("bus", bus)
if err != nil {
return err
}
registry := outbox.NewBridgeRegistry()
if err := registry.Register(bridge); err != nil {
return err
}
router := outbox.NewRouter()
router.AddRoute("invoice.*", "bus")

cfg := outbox.DefaultDispatcherConfig()
cfg.Registry, cfg.Router = registry, router
dispatcher, err := outbox.NewDispatcher(store, nil, cfg)

Once the commit that enqueued the message lands and the dispatcher picks it up, it reaches the same handlers a direct Publish would — including the typed subscription above, which decodes the stored JSON into its own type.

  • The bridge emits with Emit, not EmitAsync, so a subscriber's error is the delivery's error: the dispatcher retries it with backoff and fails it after MaxAttempts. An asynchronous emit would report success for work nobody did.
  • The event the bus sees carries the message's topic as its Signal and the stored JSON as its Payload — a []byte to an untyped handler. ModelName is empty on this path.
  • Delivery is at least once, and a topic routed to more than one bridge is retried through all of them: if the bus handler succeeds and a webhook bridge fails, the next attempt emits on the bus again. A subscriber behind the outbox has to tolerate a repeat.
  • NewDispatcher accepts a nil handler when a registry and a router are both configured, which its documentation has always said and the code refused — an application routing everything through bridges had to pass a handler that could never run. With neither delivery path it still returns ErrHandlerMissing.

The outbox snapshot answers about one topic

The runtime snapshot counted the whole table, so a screen about one kind of message could only display the whole outbox and declare its scope: an application that queues webhooks next to mail read "4 pending" on its mail screen as four unsent mails. RuntimeSnapshot.Topics breaks the same counters down per topic, and TopicSnapshotFor asks the narrow question directly.

snap := store.Snapshot(ctx)
if mail, ok := snap.TopicSnapshotFor("mail"); ok && mail.Pending > 0 {
logger.Warn("mail is not draining",
"pending", mail.Pending,
"failed", mail.Failed,
"last_error", mail.LastError,
)
}

A count with no cause is what an operator cannot act on, so each topic also carries the last error recorded on it — including from messages that are still retrying, rather than only from the ones that have spent their attempts. A message that is delivered has its error cleared, so a topic that recovered stops reporting one.

  • The breakdown is a second query on purpose. The totals answer "is the outbox draining", which every caller wants; when the per-topic query fails they are returned intact and Topics is empty rather than the whole snapshot going blank. An empty Topics therefore means either "nothing in the table" or "the breakdown could not be read" — Enabled and Reason still describe the outbox as a whole.
  • LastErrorAt is when the message carrying that error was enqueued, not the moment the delivery failed.
  • Topics come back sorted by name, and the snapshot's JSON gains a topics array next to the counters it already had. Anything decoding it keeps working.

/livez and /readyz are two different questions

Every application now serves both, next to /healthz, and both are in the bootstrap allow-list — the orchestrator has no session and cannot be given one.

They are mounted as the server starts, and only for a path the application has not claimed. If you already serve your own /livez or /readyz — and until now you had to, because the framework shipped only /healthz — yours keeps the route and nothing changes for you. Registering ours first would not have been a silent overwrite: net/http's ServeMux refuses a duplicate pattern, so your application would have stopped booting on a minor upgrade.

/livez answers whether the process is alive, and nothing else: {"status":"alive","uptime_seconds":412}. It deliberately does not check the database, Redis, storage or mail. A liveness probe that fails when a dependency is down asks the orchestrator to restart a process that is working perfectly; restarting will not fix the dependency, and doing it across every replica at once turns a dependency's bad minute into an outage. Uptime is the one thing a liveness answer can honestly report about itself.

/readyz answers whether this instance should receive traffic. That is where dependencies belong: it runs the same per-dependency probes /healthz does and answers 200 with "ready", or 503 with "not ready" and the checks in the body, so the instance leaves the load balancer while the orchestrator leaves the process alone.

Draining, for a rolling deploy

app.BeginDraining() flips /readyz to 503 with "status":"draining" while /livez keeps answering 200. That gap is the whole reason there are two endpoints.

The framework does not call it for you, and that is on purpose, because the useful moment is before shutdown begins: http.Server.Shutdown stops accepting connections immediately, so a drain announced from a shutdown hook is announced to nobody. Call it when you take the termination signal, give the load balancer time to notice, then shut down:

stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop

app.BeginDraining() // /readyz -> 503 draining, /livez still 200
time.Sleep(5 * time.Second) // long enough for the load balancer to see it

return a.Shutdown(ctx)

app.Draining() reads the flag. It is process-wide, not per-App.

The profiler exists, and is off

profiling_enabled: true mounts net/http/pprof at /debug/pprof. It is off by default, and the default matters: net/http/pprof registers itself on http.DefaultServeMux at import time, so a framework that imported it unguarded would put heap and goroutine dumps — which contain live data — on every application's public surface. A default application answers 404 there.

Turned on, it is deliberately not in the bootstrap allow-list, unlike /healthz, /livez and /readyz. With the default-deny enforcer and no policy naming those paths, an unauthenticated GET /debug/pprof/ answers 403; you decide who may read a profile by writing the rule, the same way you would for any other route. Startup logs a WARN naming the prefix while it is mounted. It is there at all because an on-call engineer with a process burning CPU needs a profile now, and telling them to redeploy with a patched binary is telling them to reproduce the problem later.

Job metrics follow the current meter provider

The asynq provider built its metric instruments once, under a sync.Once. OTel's global provider forwards instruments created before the first otel.SetMeterProvider and only that once, so a process that installs a second meter provider — reconfiguring telemetry after boot, or a test harness that swaps one in — went on recording job metrics into the first one, where nothing was reading them. The instruments are now rebuilt when the global provider changes, which is what internal/jobstelemetry does for the other two providers. If your job dashboards went blank after a telemetry change, this was it.

An application with an outbox on SQLite could fail to start

Two causes, both fixed. The sqlite DSN was passed through bare, which left busy_timeout at 0. SQLite allows one writer at a time, and without a busy timeout the second writer does not wait — it fails immediately with SQLITE_BUSY. The framework now appends _pragma=busy_timeout(5000) to a sqlite DSN that does not already set one; :memory: and a DSN that names busy_timeout itself are left exactly as written.

And the outbox dispatcher started inside app.New, whose first pass is immediate — so it was polling and leasing rows while a module's OnStart was still creating its schema. Those were the two writers. Delivery now begins with App.StartOutbox, which the runtime calls once the modules have had their turn at the database, and which App.Run calls for an application assembled without the runtime. It is idempotent, so both paths are safe.

Both halves are now read directly in a test: PRAGMA busy_timeout off the connection the framework hands out, and whether delivery has begun before the modules are up.

A test can drive a live connection

nucleustest could make a request and read a response, and a stream is neither — it stays open, arrives in pieces, and has to be closed. So every application testing a channel wrote the same bufio loop, event parser and timeout. Server.Stream is that:

srv := nucleustest.StartApp(t, nucleus.App{Config: cfg, Modules: modules})

stream := srv.Stream("/live")
hub.Broadcast(context.Background(), realtime.Message{
Topic: "orders", Event: "created", Data: []byte(`{"id":7}`),
})

event := stream.Next(3 * time.Second) // fails the test if nothing arrives
var order struct {
ID int `json:"id"`
}
event.JSON(t, &order)

Next(limit) returns the next event or fails the test, skipping keep-alive comments. Quiet(limit) fails if anything arrives at all — the assertion that somebody did not receive a broadcast, which is the one an authorisation bug slips past when nobody writes it. StreamEvent carries Event, Data and ID, and JSON decodes the payload. The connection closes when the test ends.

Pointed at a route that is not a stream, the helper fails immediately naming the status or the content type it got, rather than hanging until the test's own timeout. It speaks SSE; there is no WebSocket client in the kit, so a test that needs one still brings its own.

Two fixes an operator meets on day one

nucleus doctor accepted jobs_provider: sql everywhere except in its own tasks check, which answered unknown jobs_provider "sql" (supported: memory, asynq). A deployment on the durable queue was told by its diagnostic that its configuration was broken. The check now opens the application's database and says whether it answers, names the table the queue will live in, and warns about a jobs_redis_url the provider ignores. It deliberately does not create the table: doctor reports on a deployment rather than changing one.

And the SQL scheduler reads cron expressions in UTC, as the in-process and asynq schedulers do. It read them in the host's local zone, and nothing in the wiring sets a zone for any of the three, so there was no configuration in which they agreed: moving an application between providers moved every 0 3 * * * by the host's offset, at night, with no error anywhere. An explicit SchedulerConfig.Location still wins.

Upgrading

Nothing is removed and no signature moves. pkg/realtime, the SQL jobs provider, the typed topics, the bus bridge and the profiler are all opt-in, and Runtime is untouched — the inspector is reached through nucleus.TaskInspectorFrom. Four things change underneath an application that adopts none of it:

  • EmitAsync no longer blocks the emitter, and drops a dispatch past 4096 in flight. An application that leaned on the old backpressure to never lose an async event will lose one under load; the error log and Bus.Dropped() are where that shows.
  • A typed subscription types its signal for every emitter of it, untyped ones included. After subscribing a topic to post_save, an untyped Emit of post_save whose payload does not decode into that type returns an error to whoever emitted it.
  • The in-process jobs provider holds an unhandled job instead of dropping it. Strictly less lossy, and total_failed_today moves exactly as it did.
  • Outbox delivery starts with App.StartOutbox. App.Run and the nucleus runtime call it for you; if you build with app.New and serve the router yourself, call it when you are ready. The outbox accepts messages from the moment it exists either way — it is delivery that waits, not acceptance, so nothing enqueued during boot is lost.

And one thing the framework will not do for you: app.BeginDraining() is never called on your behalf, so an operator who assumes /readyz goes 503 on SIGTERM gets nothing until the snippet above is wired in.

v1.29.0 (2026-09-14)​

A list can be asked a question with more than one shape in it, a pager gets a number it can divide, and a handler behind the session middleware can take the connection over.

Filters that carry an operator

QueryOpts gains Where []Filter: a column, one of twelve operators, and the value or set it compares against.

res, err := crud.FindAll(ctx, &out, model.QueryOpts{
Where: []model.Filter{
{Column: "created_at", Op: model.OpGreaterEqual, Value: "2026-01-01"},
{Column: "status", Op: model.OpIn, Values: []string{"open", "paused"}},
{Column: "title", Op: model.OpContains, Value: "50% off"},
{Column: "archived_at", Op: model.OpIsNull, Value: "true"},
},
})

The operators are eq, ne, gt, gte, lt, lte, contains, startswith, endswith, in, not_in and isnull. Filters is untouched — it still means exact match, column to value — and the two are ANDed, so a caller that never sets Where behaves exactly as it did.

The column is resolved against the model's own columns and the operator comes from a closed set, so the SQL is assembled from allow-listed tokens and every value is bound. ParseFilterOp maps the text form and reports whether it is known: an operator nobody recognizes is refused rather than quietly treated as equality, because a filter that is dropped in silence answers every row and looks like a result.

Two decisions worth knowing before you read a query plan:

  • The LIKE behind contains, startswith and endswith carries an explicit ESCAPE. Only MySQL assumes a backslash, so without it a value containing % or _ would match far more than it should, and a bare % would match everything while looking like a filter.
  • An empty in matches nothing rather than being dropped. "No values" is an answer, not the absence of a question.

A total a pager can divide

FindAll never counted a filtered query. It answered total: -1 with is_estimated: true — honest, and useless to a pager, which cannot divide it into pages. QueryOpts.ExactTotal asks for a real COUNT over the same WHERE the page uses, so the total and the rows always describe one query.

It is opt-in because it is a second query: a screen with a pager asks for it, a background sweep does not. Left unset, nothing changes.

A websocket can upgrade behind the session middleware

The session middleware handed every handler a ResponseWriter that could not be hijacked, so a websocket upgrade panicked and the request answered 500 — in every Nucleus application, since the session manager is mounted by default. The wrapper now implements Hijack through http.ResponseController, which walks the Unwrap chain to the first writer that can take the connection.

Nothing to change in your code. If you tried to serve a websocket and got a 500 you could not explain, this was it.

It was found from the outside — Orbit's admin bench booted a panel and noticed the live feed served its snapshot and never opened its stream. The tests on both sides wired the handler without the default stack, which is why they passed. The contract test added here goes through that stack.

v1.28.0 (2026-09-12)​

Authentication stops being a substrate you build on and becomes something your application mounts. The framework had sessions, tokens, password hashing, a policy engine and two extension seams — and not one route that signed anybody in.

Accounts

pkg/accounts is an opt-in module with the flows an end user touches: register, confirm an address, sign in and out, reset a password, change one, sign in with a one-time link, and be locked out after enough wrong guesses.

store, err := accounts.NewSQLStore(ctx, db, accounts.SQLStoreConfig{Flavor: accounts.FlavorPostgres})
svc, err := accounts.New(store, nil, mailer, templates, accounts.Config{
BaseURL: "https://app.example.com",
From: "no-reply@example.com",
}, logger)

app := nucleus.New().Mount(accounts.Module(svc))

The decisions that usually get re-made badly in each application are made once here. A registration form answers the same way whether or not the address is taken, because answering differently is an account enumeration oracle. A wrong password and an unknown account are indistinguishable in the error and in the time it takes. Tokens are single-use, stored hashed and scoped to a purpose, and consuming one is a single conditional UPDATE, so two concurrent uses of a reset link cannot both win. A reset ends the account's sessions. Signing in rotates the session token. Lockout answers 429 — and answers it for the correct password too.

Store is the port: an application with its own users table implements it, and SQLStore is there for one that has not got one yet.

Second factor

TOTP with recovery codes, and a sign-in that becomes two steps when a factor is confirmed: /auth/login answers 401 with mfa_required and records the pending sign-in under a different session key, so nothing that reads the signed-in identity mistakes half a sign-in for a whole one.

Factor secrets need a 32-byte MFAEncryptionKey and are refused without one: a TOTP secret is a password equivalent, and storing it in the clear works perfectly and fails once, silently, in a backup.

API keys

pkg/auth/apikeys is the credential a program uses: issued once, shown once, revocable, scoped, and recognisable in a log (nk_…, so a secret scanner can find one that leaked). nucleus apikey create|list|revoke|rotate is where they are actually issued. Rotation gives the old key an expiry rather than a revocation, so both work during the grace period.

The middleware puts the key's owner where the rate limiter already looks, so a key is throttled as an identity rather than sharing a bucket with everyone behind the same address.

OpenID Connect

The federated seam shipped in v1.15.0 with no implementation. There is one now: authorization code with PKCE (always), discovery, JWKS with refetch on an unknown kid, and an id_token verified against audience, issuer, expiry and the nonce the flow started with. Only asymmetric algorithms are accepted.

import _ "github.com/jcsvwinston/nucleus/pkg/auth/federated/oidc"

Group claims land in the identity's role list — User.Roles and Claims.Roles are new, next to the single Role that every existing reader still sees.

Revocation

SessionManager.Revoke(token) and RevokeWhere(predicate) end sessions other than the caller's — the "sign out my other devices" button, which had no API even though sessions could already be enumerated. Tokens carry a jti, and jwt_revocation: true refuses revoked ones; the list lives in the session store, so a deployment that already shares Redis gets revocation every replica honours.

Permissions on an object

authz.ObjectEnforcer decides on a resource instance — "may she edit the posts she owns" — which a path-based policy cannot express, and which every application was re-implementing inside its handlers. nucleus.Context gains Claims, UserID, HasRole, Can and CanObject; all of them answer no when the middleware is not mounted.

Transactional mail

mail.Message gains HTML and Attachments, mail.Templates renders from files (HTML through html/template, so a username in a mail is escaped per context), and mail.EnqueueTx queues a message inside the caller's transaction — so a verification email and the account it announces commit together or neither does.

A message with only a Body is emitted exactly as before.

Measured security posture

contracts/baseline/asvs_l2.txt maps the framework onto OWASP ASVS 4.0.3 level 2 — 27 requirements, one row each, every one with a test that fails when the control stops holding: 24 met, 2 the application's to complete, 1 not met, and the three that are not "met" say why. nucleus doctor security now names session_idle_timeout=0 in production.

Upgrading

Nothing is removed and no signature moves. The new modules are opt-in: an application that mounts nothing behaves exactly as it did. jwt_revocation defaults to off, and so does every other new knob.

v1.27.0 (2026-09-11)​

A generated feature is built on the Quark ORM by default, and the pages stop introducing pkg/model as the data layer you write against.

generate module builds on Quark

nucleus generate module <name> now emits a Quark model, a typed repository and its migration. Feature code knows its types, and a typed model is what a generator should hand you. --data sql gives the previous database/sql output for a slice that needs it.

Nothing already generated changes, no signature moves, and no code stops compiling — this is a change in what the generator writes. Your project resolves the Quark modules on the next go mod tidy, which the command tells you about (or does for you, unless you pass --offline).

Two defects in the generated storage

The slice listed without an explicit Limit, so Quark warned and fell back to 100 on the first request — a warning on the first request of a brand-new project teaches that warnings are noise. The cap is a named constant now.

And the search filtered after fetching a page, which the explicit cap turns from invisible into wrong: a match on row 101 did not exist as far as the caller was concerned. It is a WHERE now.

A tag written in the other grammar is named

Quark's db tag holds the column name with comma-separated options, so a model written that way arrives here with every token unrecognized and the field quietly keeps the name derived from its Go field. That already warned; now the warning says which grammar it is looking at, instead of leaving the reader to hunt for a typo.

pkg/model, described for what it is

The pages introduced pkg/db + pkg/model as "the data layer", which sent a reader writing a feature at a registry built for something else. pkg/model provides CRUD over models the caller cannot name at compile time — which is what the admin panel needs, and why the registry has an interface{} operator at all. It stays where it belongs: underneath.

v1.26.0 (2026-09-10)​

A generated project now ships with a container image, the CLI can judge it, and the header that decides which client address the rest of the stack believes stopped believing anything that is not an address.

A container image, generated and judged

  • nucleus new writes a Dockerfile and a .dockerignore for every template. The image is multi-stage — toolchain, module cache and sources stay in the build stage — builds with CGO_ENABLED=0 -trimpath, and runs on gcr.io/distroless/static-debian12:nonroot pinned by digest as USER 65532:65532, numeric so an orchestrator enforcing runAsNonRoot reads it without resolving a name. It creates one writable path, /data, owned by that user: the runtime base has no shell to mkdir in, and without it the default --db sqlite scaffold cannot open its own database. The mvc and suite templates also copy rbac_policy.csv, because their default-deny enforcer refuses to start without it.
  • nucleus doctor --check image reads the Dockerfile you have — generated or hand-written — and reports what it finds: a base pinned by tag instead of digest, a missing or non-numeric USER, a build that would link cgo, a COPY . . that ships the source tree. It explains each finding rather than only naming it.

Forwarding headers

  • X-Forwarded-For and X-Real-IP are honoured only for entries that parse as an IP address. The value the router settles on is written into RemoteAddr and read back as an address by the rate limiter and the audit trail, so a header carrying anything else used to travel intact into both: a rate-limit bucket keyed on a string an attacker chooses is a rate limit an attacker can step around, and an audit line naming it is a record of nothing. Nothing is honoured from an untrusted peer, as before.
  • Found by FuzzRealIPForwarding, one of five new fuzz targets over the surfaces that read untrusted input — route matching, the CSRF gate, forwarding headers, JWT validation and ORDER BY sanitising. Each asserts a property rather than merely not crashing; this one failed on its own seed corpus, before a single mutation.

v1.25.0 (2026-09-08)​

The release of arc A2 of the suite plan: a suite application is one command away, and the CLI reads the binary you built instead of guessing from configuration. Nothing changes the shape of a stable package; the one behaviour a running application will notice is listed first.

What a running application will notice

  • Unknown routes answer 404. The default-deny authorizer and the CSRF gate used to run before routing, so a path nobody served answered 403 (or 419 on a POST) and a path the policy granted but nothing registered answered 404. Both gates now run after the route match: an unregistered path answers 404, a registered one is judged as before, and a gate a later middleware would have skipped by rewriting the path still runs, on the path as its own level spells it. router.Matched and router.WhenMatched are the new exported seams. A clean nucleus new scaffold boots with zero warnings in development (the JWT reminder is informational there and stays a warning in production).

Start a suite application

  • nucleus new <name> --with orbit,quark,quarkbridge,quarkdatasource fetches the siblings it names from the module proxy and, with --template suite, writes the application the suite quickstart used to ask you to type: main.go mounting a shop module over Quark and the Orbit panel over quarkdatasource, nucleus.yml, the policy file, the first migration, a seeded article and the module's own test. --db sqlite|postgres|mysql|sqlserver|oracle picks the driver module and the connection URL; --offline writes the files and hands back the exact go get recipe. go.mod leaves the command tidy: the driver is required at scaffold time, so go run . is the next step. examples/showcase_demo is generated from the same template and a test keeps the two identical.
  • nucleus generate module <name> --mount splices .Mount(<name>.Module()) into the builder chain of main.go and adds the import; the module ships with a module_test.go on nucleustest; --data quark renders the storage over Quark. A module name that cannot be a Go package name is refused before anything is written. The empty scaffold no longer carries policy rows and a CSRF exemption for a /notes module it never generates.

A CLI that reads the binary

  • nucleus routes builds the project and runs it with NUCLEUS_PRINT_ROUTES set: the application boots as usual, prints its route table (framework and every module's routes, attributed by module) as one JSON line and exits without listening. The listing built from configuration alone stays behind --framework-only, and it is what a project on an older framework version gets, with a note that says so. nucleus migrate status merges the migrator's view with the ledger rows of every module namespace; nucleus migrate with no action is a usage error instead of an implicit up.
  • nucleus dev rebuilds and restarts the application when a source file, nucleus.yml, the policy file, a migration or a template changes; a build that fails leaves the last good binary serving and prints the compiler output; --print-routes prints the route table on each restart; --proxy <url> puts the command in front and forwards /static and /assets to a front-end dev server. Ending the session kills the whole process group, so nothing is left listening.
  • nucleus completion bash|zsh|fish prints a completion script generated from the command table, subcommands included.
  • nucleus <command> --help shows the command's grammar — positionals, actions, the values a flag accepts — ahead of the flag list, for the fourteen commands that have one; nucleus openapi on a project without internal/contracts fails with the command that creates it instead of the raw toolchain error.

Documentation

  • A new Start a suite app page walks through what nucleus new --template suite wrote; the CLI overview documents --with, --template suite, --db, --offline, dev, completion and NUCLEUS_PRINT_ROUTES; the reference explains why an unregistered path answers 404 (route existence is visible in the binary anyway, and a uniform 403 hid misconfiguration).

v1.24.0 (2026-09-05)​

The first release of the plan that takes the suite to 5 of 5 in every dimension: the debt the maturity audit of 2026-09-03 left in the framework, paid in three groups. Nothing here changes the shape of a stable package; two configuration keys are new and two behaviours are corrected in ways a running application will notice, listed first.

What a running application will notice

  • The request timeout is its own key. request_timeout (default 30s) bounds a handler; it used to reuse read_timeout silently and could not be switched off. A negative value disables it, and timeout_exempt_paths serves the listed prefixes — and any request that accepts text/event-stream — with the raw response writer and no deadline, so server-sent events and long downloads work behind the default stack.
  • Rate limiting keys by user and tenant. The limiter now runs after the bearer is decoded and the tenant resolved, so an authenticated request is counted as tenant:<t>|user:<id> and only an anonymous one by its IP. It used to sit before identity, where every key was an IP and rate_limit_by_role had nothing to read.
  • Flash data lives exactly one request. What a request flashes is readable in the next request and gone after it; Now() data is swept when the response goes out; Reflash and Keep buy one more request. A flashed message used to stay in the session until something overwrote it.
  • Tokens are checked against the issuer, and against jwt_audience when that new key is set. A token minted by another deployment sharing the signing material used to validate.
  • A module generated by nucleus generate module opens reads only to anonymous callers; a write needs an authenticated subject and the generated file says where to add the rows. --with-policy emits the previous open rows for a development spike, and the command prints which policy it wrote.

Router and HTTP

  • CORS headers ride on every response that leaves the stack, including a 429, a timeout or a 500; the allow-list accepts subdomain wildcards such as https://*.example.com.
  • The http.route attribute and the span name carry the matched template (/users/{id}), not the raw path; an unmatched request reports one shared value. In-flight requests are counted by method.
  • Compression decides on the first write: a 204, a 304, an image, an already-encoded or ranged body go through untouched, and the content type is sniffed before the bytes are gzip.
  • A mounted subtree answers at its bare path (/users) instead of redirecting to /users/; a resource lists at both spellings.
  • Two modules registering the same route is an error from Start naming the module, not a crash; Walk hands back the registered handler; the session manager is visible to top-level middleware, so CSRF with UseSessionToken finds it.
  • Panics are recovered through the application's logger.

Data, tasks and hygiene

  • Migrations take a session advisory lock on PostgreSQL, MySQL, MariaDB and SQL Server, so two replicas running migrate up at once apply each migration once.
  • Registering a second, different type under a model name the registry already holds is an error; the same type again stays an override.
  • Column and index names take no dot (only a table may be schema-qualified), and Oracle reserved words are quoted in generated DDL.
  • The in-memory task provider honours MaxRetry (with backoff) and Timeout, refuses a named queue instead of ignoring it, keeps a delayed task when the queue is full, and its scheduler counts the ticks it could not enqueue.
  • session_store: memcached works, with session_memcached_servers.
  • Error reporting throttles by error type and samples with SampleRate; an empty string in the session cache is a hit; custom validation messages are safe to register concurrently; an async signal handler that panics is logged, and at most 64 run at once.

Documentation and process

  • The contract inventory lists every package the freeze protects, including the plugin contracts and the provider modules; the specification says what secret_manager does today.
  • A record explains why 1.23.0 shipped as a minor with a "breaking" heading and sets the rule from now on: relocating code into a module with a guided error is a minor with the statement packaging move with guided error; a breaking change is a major, decided before the merge.
  • Commit messages and pull-request titles are in English, and CI checks the title. The eleven provider modules require the framework at v1.23.2.

v1.23.2 (2026-09-04)​

Documentation only. A code comment in the reference example named an internal design record; the suite's published site embeds that file in the quickstart of every documented version, so the reference leaked into pages a reader cannot follow. The comment now says what it meant in plain words. Nothing in the framework changes.

v1.23.1 (2026-09-04)​

A maintenance release that closes what the module split of v1.23.0 left open, plus a handful of hardening fixes. No configuration key changes.

Modules build on their own. The twelve sibling modules (five database drivers, two telemetry exporters, five providers) now require this release line instead of one that predates the packages they import, so go build inside any of them works without a workspace. A CI lane builds each one standalone from a clean checkout, and release-please no longer pins them to a version that was already published.

nucleus add otlp and nucleus add prometheus exist. The start-up message that recommended them now names a command that works. When metrics_path is left at its default and no exporter is linked, the message is informational; a metrics_path you wrote yourself still stops start-up with the exact import to add.

The reference example runs. examples/mvc_api imports the SQLite driver module and is its own Go module; CI migrates it, starts it and calls /healthz and /notes. The showcase example is pinned to the Quantum 1.26.0 set and links the driver modules of both Nucleus and Quark, so a duplicate POST answers 409 instead of 500.

Hardening. Unhandled errors answer 500 with a generic body unless the app runs with env: development (router.WithDevelopmentErrors is the new knob); session metadata and audit hooks read the client IP from the connection instead of trusting X-Forwarded-For; BindXML caps the body at 1 MiB and validates like Bind; generated modules propagate 413 for bodies over the limit; migrate create and squashmigrations stamp UTC so files sort in creation order; golang.org/x/crypto moves to v0.56.0.

Documentation. README, SPEC, the installation and configuration pages and the internal guides describe the framework as it ships: a core plus twelve optional modules, no build tags, forty CLI commands, MountOpenAPIHandler, mail as noop/smtp/plugins, cloud storage and Prometheus as modules. A new check fails the build if a retired claim comes back.

v1.23.0 (2026-09-01)​

The cloud backends, the database drivers and the telemetry exporters all move out of the framework, each into its own module. A hello-world — an application that links pkg/app and nothing else — goes from 75.6 MB to 19 MB, and from 346 modules to 87.

Worth reading before you upgrade​

Your configuration does not change. database_url, otlp_endpoint, metrics_path, storage.provider all mean exactly what they meant before. The only new thing is a blank import for what you use, and the CLI writes it for you:

nucleus add postgres
nucleus add s3

If you set a database URL without its driver module, the application refuses to start and the error prints the go get and the import _ you need. It never falls back to something else quietly.

The mssql and oracle build tags are gone. SQL Server and Oracle are modules now, like the rest. A build tag is invisible — nothing in the source says it exists — so forgetting one failed at run time with unknown driver. An import fails while compiling.

If you scrape /metrics, read this one. The Prometheus exporter also moved out, and unlike everything else here it was never something you had to ask for: metrics_path has a default, so metrics were on for everybody. On upgrade, an application that never mentioned metrics logs a warning at startup and keeps running without them — add exporters/prometheus and the endpoint comes back unchanged. An application that set metrics_path itself stops at startup instead, because a configured endpoint answering 404 is worse than a failure you can see.

The modules​

UseAdd
PostgreSQL, MySQL, SQLite, SQL Server, Oracledrivers/postgres, drivers/mysql, drivers/sqlite, drivers/mssql, drivers/oracle
S3 (or anything S3-compatible), Google Cloud Storage, Azure Blobproviders/storage-s3, providers/storage-gcs, providers/storage-azure
AWS Secrets Managerproviders/secrets-aws
Push traces and metrics to a collectorexporters/otlp
Be scraped for metricsexporters/prometheus

Why they left​

An SDK nobody calls is not free. The four cloud SDKs weighed 42.6 MB of the old hello-world, whether or not the application ever opened a bucket. The OTLP exporter pulls gRPC even in its HTTP flavour, because its internal configuration package imports gRPC regardless of transport. The Prometheus exporter pulls protobuf through client_golang — which is why removing OTLP alone would not have removed protobuf.

Added​

A driver module registers two things. The database/sql driver, and how that driver reports a unique-constraint violation. The second is the one that is easy to miss and expensive to omit: without it db.IsUniqueViolation does not fail, it answers false, and the branch that turns "that email is taken" into a 409 becomes dead code. PostgreSQL needs no such registration — every PostgreSQL driver exposes its SQLSTATE through a method, so the check works for lib/pq too.

nucleus add <name> runs the go get and writes the blank import into the right file. It takes the names people type — postgres, pg, mssql — and accepts its flags in any position.

pkg/db/driver and pkg/observe/exporter, the contracts a driver or an exporter implements. Both are leaf packages, so writing one costs nothing but the standard library; pkg/db/driver/drivertest is the conformance kit.

v1.22.0 (2026-08-31)​

Most of this release is about the first hour with Nucleus: the code the generators write now boots, the commands say what they did, and two subsystems the CLI could already prepare — translation catalogs and the cache table — have runtime behind them at last.

Worth reading before you upgrade​

A module generated with an already-plural name now boots. nucleus generate module products — or startapp, or any name that already ends in s — registered the HTML page and the JSON API on the same GET /products pattern, so the generated application panicked on startup with a route conflict, after the generator had printed that there was nothing else to do. When the name is its own plural, the page now sits at /<name>/page, a literal segment that cannot collide with /{id}. Singular names are unchanged. If you worked around the panic by hand, the generators now print the route table they mount, so you can compare.

With the asynq jobs provider, one replica drives the scheduler. Every replica used to start its own: asynq de-duplicates workers but not schedulers, so N replicas enqueued each cron entry N times per tick. Nucleus now takes a Redis lease and the holder replays the registered entries; losing the lease steps down cleanly, and shutting down releases the lock so another replica picks it up immediately. The new jobs_scheduler_lock key defaults to on and the election is logged. Turning it off restores the previous behaviour and logs a warning that every replica will fire every job. Cron entries that were quietly running once per replica will now run once in total.

nucleus doctor no longer degrades over switched-off optional features. A freshly generated, healthy project answered Overall Status: DEGRADED because jobs had no Redis, the outbox was off, no OTLP endpoint was set and tenancy was disabled — four things nobody had asked for. Those now report an informational state (-, with its own counter in the JSON output) and do not count towards the verdict; warning is reserved for half-configured subsystems and error for broken ones. The first doctor run on a new project answers HEALTHY. The tasks check also reads the jobs configuration it is actually about — jobs_provider and jobs_redis_url, not the redis_url used for sessions and rate limiting — so a correctly configured asynq setup is no longer reported as disabled.

An application that has compiled translation catalogs now mounts the translation middleware at startup, and a corrupt bundle fails startup the way a broken template does. Applications with nothing under locales_path are unaffected.

--help exits 0 in config, doctor and wizard. The three of them printed error: flag: help requested and exited 1. If a script of yours relied on that exit code, it will now see success.

The first request​

The generators now print the exact route table they mounted, and generate resource --with-policy adds the RBAC rows and the csrf_exempt_paths entry those routes need. The first POST against a freshly generated resource used to be a 403 → 404 → 419 ladder: default-deny masked the 404 of a guessed path, nothing told you which paths existed, and the scaffold's CSRF exemptions did not cover the new API. Three security concepts stood between you and the first request, and no output named any of them. The flag only adds rows that are missing, and if csrf_exempt_paths is not an inline list it prints the edit for you to make instead of rewriting your YAML.

app.Run logs the address and URL it is listening on just before it starts serving. go run . — the documented path — used to end with the last subsystem's line, with no way to know the server was up on :8080 short of reading nucleus.yml.

In development, mounting a module logs one line per route it added, with the module prefix applied, so the routes of your own binary are finally listable — which is what you need when debugging a default-deny 403. Production stays silent. nucleus routes still lists only the framework's own routes, and its note now points at these logs rather than at an inventory that did not exist.

nucleus wizard --type inspectdb no longer prints table names it never looked up. It used to announce that it was fetching tables and list users, posts, comments and tags without opening a connection, then exit with the error saying it is experimental and changed nothing. The invented output is gone and the command points at inspectdb, which does the work.

diffsettings no longer reports differences like auth_federated [] -> [] between a nil default and the empty slice the loader materialises.

Runtime and operations​

nucleus outbox requeue returns messages that exhausted their retry budget to pending with a fresh budget. A failed message used to stay failed forever, and the only remedy was hand-written SQL. Passing ids touches only those actually in failed, so a mistyped id cannot re-deliver a message that already went out. The library method behind it is available to applications.

nucleus doctor --check storage now probes the object store for real: it builds the store from the effective configuration — credentials, endpoint, TLS, the same path app.New takes — and performs an authenticated list with a timeout. The check used to delegate to a provider health check that did not exist anywhere. A full doctor run stays offline and tells you how to run the live probe.

Modules can now reach the outbox and the task manager: Runtime.Outbox() and Runtime.Tasks() follow the same degrade-to-nil contract as the other accessors, returning nil when the subsystem is disabled. Configuring a jobs broker builds the task runtime even with no cron entries registered, which is the opt-in for modules that only enqueue; the default in-memory provider keeps costing nothing.

The circuit breakers that Nucleus installs in front of mail and storage log their transitions — a warning when one opens, an informational line when it probes and closes — with a cumulative open count. They used to change state in complete silence. Applications using pkg/circuit directly can hook the same transitions and read the counter.

CSRF rejections are logged. A 419 or an origin-check 403 left no trace at all, unlike an authorization 403, so the most common wall a new integration hits was also the most silent. The log line says which check failed — missing token, token mismatch, origin — and how to fix it.

Multi-tenant applications are told when tenant scoping silently disappears. A tenant-scoped store fell back to unprefixed keys whenever the context carried no tenant, so a background job outside a request wrote tenant data into the shared space without leaving a trace. There are now two policies: a one-off warning on the first fallback, which is the default for multi-tenant applications, and a strict mode that refuses the operation instead. The new multitenant.require_tenant_storage key defaults to false so nothing changes for you without asking. Single-tenant applications are unaffected — there, unprefixed keys are the key space.

Two error messages stopped pointing at the wrong thing. A typo in a database URL scheme (sqlit://app.db) used to fall through to the SQLite driver on the .db suffix and die later in Ping with an unrelated message; any unrecognised scheme now fails immediately, naming the scheme and the supported list. And auth_backends: [local] without a user provider used to recommend registering a backend, when the actual remedy is the WithUserProvider option — which the message now says.

nucleus.Context gained Render, which delegates to the template render of the router context it embeds. Its HTML method — a raw HTML writer — shadows that render, to the point where the generators had to reach through the embedded field; they now emit Render, and both methods document the shadowing.

Translations and caching​

Two commands that had been producing artifacts nobody could use now have runtime behind them. Both packages are experimental: their surface can still change within the v1.x line.

compilemessages produced JSON catalogs that no runtime code read — default_locale and locales_path were understood only by the CLI itself, and there was no framework way to serve a translated string. The new translation package loads those catalogs, resolves keys through a deterministic fallback chain (requested locale, then its base language, then the default locale, then the key itself), and negotiates Accept-Language, q-values included, per request. Handlers translate through the request context; with no middleware mounted, translation degrades to the key rather than to an error.

createcachetable created a table the framework never read from or wrote to, while the deployment guide listed it as a multi-replica requirement as though a cache existed. There is now a small cache interface with a mandatory TTL and two backends: an in-process one, and one over the table the command creates. The SQL backend filters expiry on the server, so it uses the database clock and is immune to clock skew between replicas; it uses a native upsert on SQLite, PostgreSQL and MySQL, and a transactional delete-and-insert on SQL Server and Oracle. A prune call reclaims space.

Documentation​

The quickstart no longer ends in an error. Its fifth step told you to create a user with a command that manages admin panel accounts, not your application's users, so it failed in the very application the quickstart had just built; that step is now an optional section on adding the admin panel, which says plainly what the command manages. The pages it embeds are the complete set the example needs — copying what was shown used to fail to compile — and both quickstarts now mention the NUCLEUS_ environment variable layer, which appeared in neither.

doctor is described as it behaves rather than as an imagined check of your Go version.

Auth is now five pages by topic instead of one page of eleven hundred lines, with an index that helps you pick, and one of them is new: Your first login, an end-to-end recipe for password login — the users table, a user provider with timing equalised, the login handler, session renewal, logout, the anonymous policy, CSRF, and seeding the first account. There was no such recipe anywhere, and the framework pre-authorizes a /login path that nobody serves. The default-deny 403 that everyone meets in their first hour now opens the RBAC page instead of sitting halfway down a long one.

Background tasks have a followable standalone path — the previous example used a manager that appeared from nowhere — alongside the module path. Events and signals is a new page covering the four event mechanisms and when to use which. Using Quark with Nucleus describes how the two fit together, and the full-suite example is a visible path rather than a folder you had to find.

Some claims were simply wrong and are now corrected: the observability baseline said the circuit breaker was not wired into mail and storage when it has been installed by default for several releases; the auth guide said an empty user table leaves the admin panel open, when the panel creates its bootstrap account at startup and an empty password closes access rather than opening it; the project layout page described a scaffold that the generators do not produce. A long-expired promise of a reference plugin example is replaced by a plain statement that the contract is documented and frozen but the runnable example is not there yet.

v1.21.0 (2026-08-30)​

Debt found while closing the QCD audit arc, and one behaviour change.

Worth reading before you upgrade​

app.WithOpenAuthz() now decodes the bearer token. It always switched off authorization; it also used to skip authentication, so an app in open mode saw no identity — request interceptors and handlers alike got nothing from ClaimsFromContext even when a valid token was on the wire. Open mode now runs the JWT decode like any other app and only the enforcement step is skipped. If you relied on open mode leaving the context claimless, this is a change: handlers and interceptors will now see the caller's identity, and a decoded user_id appears in logs where it did not before. There are no new rejections — the decode never denies a request.

Release machinery​

Root-only release pull requests now tag on merge without hand intervention. The cause was mechanical: a release PR that shipped only the root module carried no component on its shared branch, and the tagger's standalone path compared that empty component against the configured module path and gave up, leaving the PR merged but untagged. Cutting the release PRs per component puts the component back on the branch. The same round also fixed released tags shipping with no build artifacts — tags created by the automation raise no push event, so the asset build is now chained explicitly.

Tooling​

The example-pin guard tolerates one minor of lag with a loud warning instead of failing outright, so a sibling repo cutting a tag no longer reddens every open pull request at once; a companion writer and a workflow close the lag mechanically. Two documentation snapshots that announced an earlier version than their own were corrected, and the guards that watch for it were widened to catch the prose form that slipped past. The documented way to mount federated sign-in routes is now backed by an executable example, which surfaced and corrected three mismatches in the prose.

v1.20.1 (2026-08-30)​

Release machinery only; no change to the framework's behaviour.

The LDAP provider module now requires this release line rather than one from three releases back. The module depends on the root of its own repository, an edge that can never be perfectly current — any release containing the requirement is by definition later than it — so the suite manifest tolerates exactly one release of lag there and calls anything beyond it staleness. It had drifted to three while the framework moved through v1.18.0, v1.19.0 and v1.20.0, which is enough to block certifying a set.

v1.20.0 (2026-08-30)​

The second half of the external coverage audit. v1.19.0 closed the six findings it ranked highest; these are the remaining six, and two of them turned out to be already fixed or overstated — which is the argument for measuring before believing a report, including this one.

Three behaviour changes worth reading before you upgrade​

A third-party request interceptor now sees who is calling. The chain was mounted outside the bearer decode, so with a valid token on the wire an interceptor got nothing from ClaimsFromContext while the handler behind it saw the same request authenticated. An interceptor that cannot tell who is calling cannot audit, cannot scope a tenant and cannot rate-limit per principal — most of what the seam is for. It now runs after the decode and still before the default-deny layer, so it also observes requests the enforcer is about to deny. Nothing moved relative to the request ID, the real-IP resolution, the rate limiter or CSRF: those still run first and still reject before an interceptor sees anything.

nucleus changepassword refuses instead of lying. With auth_backends configured and the local backend not in it, the panel authenticates through the chain and never reads the local password hash — and the command wrote one anyway, printed "Password updated" and exited 0. The operator walked away believing access was restored. Exiting 0 without producing the effect the user believes they got is bad anywhere; on the command someone reaches for when they are locked out it is the worst possible place. A chain that includes the local backend still proceeds: there the hash is the break-glass path, which the v1.19.0 chain change made more important, not less.

X-Real-IP is filtered the way X-Forwarded-For already was. The forwarded-for walk skips any hop that is itself a trusted proxy — it is looking for the real client as seen by the outermost trusted hop. The X-Real-IP fallback applied no such filter. Under a catch-all trusted_proxies that made it a spoofing vector: every peer is trusted, the walk skips every hop, and whatever the header said became the client. A correctly configured deployment sees no change, because a real client is not in trusted_proxies.

And three corrections​

nucleus doctor --json now exits non-zero when the report says unhealthy. The verdict was returned from the text renderer only, so the same report exited 1 as text and 0 as JSON — and the one mode that never failed was exactly the one CI consumes.

A module declaring Object "" — "my root is public, my subtree is not" — now actually serves its root. That spelling emitted a policy row for <prefix> exactly, and the only way to reach a module root over HTTP is <prefix>/, because the mux answers <prefix> with an unconditional redirect. The row was dead on arrival and the intent was not expressible any other way.

The startup log no longer says federated sign-in is ready when the routes it prints answer 404. The framework builds the providers and owns the flow — it issues the anti-forgery state, holds the pending sign-in and refuses a callback that does not carry it back — but the application mounts the two handlers, because what happens after a successful callback is the application's decision. The log, the godoc and the authentication guide all said otherwise; all three now agree with the code, and the guide carries the example.

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 ActiveSessions discover enumeration and the context-taking variants by type assertion. With session_store: redis or sql, the active-sessions view went blind, the documented SCS().Iterate escape hatch panicked, and every session read and write dropped the request context.
  • A third-party session store now boots. Layer 3 validated session_store against a hand-written {memory,sql,redis}, so a store registered with RegisterSessionStore was 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, and auth.<instance>.* was missed for federated instances, so both were built from their default: 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 UserProvider used 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.* without ldap in auth_backends is 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, doctor and config 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: ceph
    ceph:
    endpoint: http://ceph.internal
    pool: 32

    The 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 with WithUserProvider, and it answers as local. Modules reach the assembled chain through rt.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.Extension receives 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 /consola and /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.

  • Delete reaches the public bucket. With a public_bucket configured, 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_proxies entry 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 security judges 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 is X-Real-IP that becomes attacker-controlled, not X-Forwarded-For.

  • A test can pin its own database from the builder. The test kit told you to set Databases in the config, and the builder had no way to do it. WithDatabases adds one, and it beats the NUCLEUS_* 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 version reports the version it was installed at . A binary from go install …@vX.Y.Z answered dev, 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 wildcard cors_origins (fatal when combined with credentials, which the Fetch standard forbids outright), a catch-all trusted_proxies range that hands X-Forwarded-For to the caller, a jwt_secret that is long enough to pass a length check and still guessable, csrf_insecure_cookie in production, and rate limiting left off. It does not repeat health --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 print tells 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 --json stays 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: smtp without a host) ran only when the application booted. Every nucleus command 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.
  • serve and testserver say 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 way routes already did.
  • The test kit reaches the database. nucleustest gains DB() and Runtime() (the same handle a module receives), TempSQLite for a database per test, and MigrateDir to 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 a POST actually persisted.
  • Credential shapes work for every storage secret. The documented {env_var: …} form was unusable for storage.s3.access_key_id, secret_access_key, session_token and gcs.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.Policies contributes RBAC rows (same shape as a rbac_policy.csv row, objects relative to the module Prefix) to the default-deny enforcer, and Module.CSRFExempt rides 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 a deny row in the host's CSV overrides any module allow. Malformed declarations fail boot with ErrInvalidModulePolicy naming the module and entry.
  • Embedded module migrations are applicable. One deliberate call — rt.ApplyModuleMigrations(), typically in OnStart — applies Module.Migrations through 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, an fs.FS-backed migrator usable directly.
  • Embedded module templates. Module.Templates registers a module's .html files under the <module-name>/ namespace; app.WithTemplatesFS(prefix, fsys) is the general accumulating extension point behind it. On a name collision the host's templates_dir parses last and wins.
  • nucleus generate module <name>. One self-contained package under internal/<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: no rbac_policy.csv or nucleus.yml edits, 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, but NewS3Store verifies 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 call EnsureBucket on. 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 what storage.s3.create_bucket_if_missing is 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 dirtying attempts/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() gains WithTemplateFuncs, WithTemplates and WithOpenAuthz (plus package-level re-exports) — v1.9.0's template options only existed on app.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 Prefix receive 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.New now does, and the Context session helpers (SessionPutString & co.) work everywhere.
  • Template functions and prebuilt bases. app.WithTemplateFuncs registers a template.FuncMap available to every template the startup loader parses, and app.WithTemplates injects a prebuilt *template.Template as the parse base — presentation logic (date formats, percentages, pagination URLs) belongs in templates, not precomputed in Go. Order: registered functions → recursive parse of templates_dir → the engine is wired into the router. See the routing guide.
  • csrf_insecure_cookie (development-only, default false) disables the Secure attribute on the CSRF cookies, mirroring session_cookie_secure: false — without it the double-submit flow was unreachable for plain-HTTP non-browser clients such as Go's cookie jar against http://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.New used to load templates with a flat glob while nucleus startapp scaffolds 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 walks templates_dir recursively; 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 logs templates loaded with 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_owner sets a stable identity and outbox.missing_route_policy (error, the previous behavior, or ignore) controls what happens when a leased topic has no registered bridge. Startup logs both.
  • SessionCache.Flush no 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_missing is reachable from nucleus.yml. The missing-bucket startup error recommends that exact key, but the strict configuration validator rejected it: the key existed in pkg/storage and 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 from NUCLEUS_STORAGE__S3__CREATE_BUCKET_IF_MISSING, and reaches the storage constructor. Opt-in with default false: 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 (and startapp) emit a mountable module wired to a real repository. The scaffold now produces a nucleus.Context controller 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), and internal/modules/<name>_module.go: nucleus.New().Mount(modules.<Name>Module()) is the whole integration. The *router.Mux handler that forced a hand-written adapter and the in-memory map repository are gone; startapp shares 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 via t.Cleanup, and MintToken issues bearer tokens against the app's own jwt_secret. E2E suites no longer need go build + exec.Command + hand-rolled polling.

  • profile: dev boots a realistic config with zero backing services. A production nucleus.yml naming PostgreSQL (+ replica), two Redis endpoints, S3 and SMTP boots unchanged with profile: dev (or NUCLEUS_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 on app.LoadConfig and 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: true provisions the configured bucket(s) at startup; S3Store.EnsureBucket is 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.Health is wired into /healthz as the check service:<name>; a failing service flips the endpoint to 503. New building blocks: health.FuncProbe and app.RegisterHealthProbe for 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.

  • loaddata inserts 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 by dumpdata (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.Health now 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 / startapp emit migration DDL for the configured database instead of unconditional SQLite. A project configured against PostgreSQL used to receive "id" INTEGER PRIMARY KEY AUTOINCREMENT and DATETIME columns, and nucleus migrate then 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 config nucleus migrate reads, NUCLEUS_* overrides included), and both commands accept --dialect / --config / --database to 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) and model.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), grpc v1.82.1, otel v1.44.0; the nucleus new scaffold 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-Encoding is now documented as unsigned/informational. New consumer helper outbox.CheckPayloadEncoding decodes 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 in X-Nucleus-Signature (sha256=<hex>) — the same scheme module webhooks verify, so one verifier covers both. Every delivery also declares its payload shape in X-Outbox-Payload-Encoding: json|base64, so a consumer never guesses. The wire is byte-for-byte the v1.4.0 default (base64); payload_encoding: json opts 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 > 0 requires an X-Nucleus-Timestamp header 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. FindAll and FindByID in pkg/model used LIMIT on Oracle (ORA-00933); they now use OFFSET … 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/Exists of a missing key now map to storage.ErrNotFound against 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/text bumped 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: Every for fixed intervals or Cron for 5-field cron expressions and descriptors (@hourly, @every 90s), validated at boot and identical on every provider; per-run Timeout; and Singleton to skip a tick while the previous run is still executing. The jobs_provider key selects the runtime — memory (default, in-process) or asynq (Redis-backed, durable, with jobs_redis_url and jobs_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 — when Secret is set — constant-time HMAC-SHA256 verification of the X-Nucleus-Signature header, rejecting unsigned or mis-signed requests with 401 before your handler runs. nucleus.SignWebhookBody produces the signature for senders and tests. With csrf_enabled: true the webhook prefix is exempted automatically — webhooks authenticate by signature, not CSRF token. A webhook registered without a Secret is flagged at boot.
  • RejectClientPK. A per-model opt-in that rejects entities arriving through Create with 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 in BeforeCreate keeps working.

Fixed​

  • The Asynq task worker stops when you stop it. Manager.Run waited on OS signals internally, so cancelling its context (or calling Close) shut the server down but never unblocked Run — an embedded worker could not be stopped through the API. Run now returns promptly on context cancellation and on Close.
  • 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 a NULL primary key without any error, and PostgreSQL/SQL Server failed with a NOT NULL violation. 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 the INSERT and 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 null is matched exactly in db: tags. db:"not null unique" (a space where a ; was intended) used to mark the field required and silently lose the unique; the malformed directive now falls through to the startup WARN introduced in v1.3.2 instead of half-applying.
  • By-id operations reject models without a primary key. FindByID, Update and Delete on a model that declares no primary key return an explicit "model has no primary key" error (check with errors.Is) instead of guessing a phantom id column, and the default list ordering falls back to a real column of the model.
  • nucleus createuser and nucleus changepassword emit valid T-SQL. Their admin-user lookups used a LIMIT clause SQL Server does not accept; on MSSQL they now use SELECT 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 startup WARN per 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.
  • Create only reads back the generated key when it actually can. The RETURNING / OUTPUT INSERTED read-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 no id column); 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 LIMIT clause SQL Server does not accept; they now use the OFFSET … FETCH form. The whole CRUD surface is exercised against a real SQL Server (and Oracle) in release validation.
  • The version pinned by nucleus new can no longer go stale. The framework version written into generated go.mod files 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​

  • Create backfills the generated primary key on PostgreSQL and SQL Server. Those drivers do not implement LastInsertId, so the entity's ID field silently stayed at zero after a successful insert. Create now uses RETURNING (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, direct QueryContext/ExecContext statements 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: true mounts origin verification (Sec-Fetch-Site) with a double-submit token fallback; csrf_exempt_paths excludes Bearer-only subtrees. The mvc scaffold enables it by default.
  • metrics_public: false takes /metrics out 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_secret must 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 to jwt_keys[].
  • Proxy headers are no longer trusted by default. X-Forwarded-For / X-Real-IP are ignored unless the immediate peer is listed in the new trusted_proxies key; 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_secret values fail the boot — rotate the secret before upgrading.
  • If Nucleus runs behind a load balancer, set trusted_proxies to 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_origins now emits no CORS headers at all. Deployments that relied on allow-all must opt in explicitly — a real origin allow-list, or cors_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.