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