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