Upgrading
Version-by-version notes for zmodu changes that stop compilation or change behaviour.
Find the versions that affect you
Every entry carries a Breaking? marker.
| Marker | Meaning | Action |
|---|---|---|
| Compile error | A type or signature changed | Apply the entry’s one-line fix |
| Behaviour change | Compiles, behaves differently | Confirm your usage is outside the described range |
| Non-breaking | Bug fix or added capability | Usually nothing |
Upgrading from an old 0.x release: read Deprecated aliases and the removal plan first. Deprecated aliases are removed no earlier than 1.0, so no 0.x upgrade requires a rename.
The full change history is in CHANGELOG.md; this file records only changes that need action.
zig build test # framework self-test; keep the default -Ddb=all
bash scripts/check-production.sh # layering gate (bare catches, envelope leaks)
zmodu ci # app: build + fmt + verify + audit + deadcode
Deprecated aliases and the removal plan
Deprecated aliases are removed no earlier than 1.0. The release that removes them is announced here and in that version’s entry; nothing is deleted silently.
| Deprecated | Current name | Removal |
|---|---|---|
ctx.paramPath | ctx.nestedParam | No earlier than 1.0 |
zmodu.startAll | Application.start() | No earlier than 1.0 |
zmodu.stopAll | Application.stop() | No earlier than 1.0 |
zmodu.http.http_server | http.Server | No earlier than 1.0 |
ctx.sendSuccess | ctx.json (200, data) — the {code,msg,data} envelope is no longer added | No earlier than 1.0 |
ctx.sendFail | ctx.json (status, body) | No earlier than 1.0 |
ctx.sendPageResult | ctx.json (200, body) — build the paging shape yourself | No earlier than 1.0 |
ctx.sendJsonItems | ctx.json (200, body) | No earlier than 1.0 |
RateLimiter.acquire | RateLimiter.tryAcquire (same meaning; it never waited) | No earlier than 1.0 |
Near-identical names that are not deprecated
param → paramPath → nestedParam reads like three renames; it is one rename plus one synonym.
ctx.param— current name, not deprecated; reads route placeholders (/orders/{id}).ctx.pathParamis a synonym added in v0.15.38. Both are supported API.ctx.paramPath— the only deprecated name in this chain. Renamed toctx.nestedParamin v0.15.46 because it reads dotted paths from form/query (filter.tags), never route parameters. Removed no earlier than 1.0.- Synonymous additions without a removal plan:
ctx.jsonValue(=jsonStruct, v0.15.38) andhttp.csrf()(=csrfWith(.{}), v0.33.3).
A name is deprecated when it is in the table above and carries a DEPRECATED marker in code; src/test/ApiFreeze.zig checks both. A name with neither has no removal plan.
Names removed during 0.x
Some names left before 1.0 because keeping them was worse: they never worked, were never exported, or would have silently changed meaning. Each is recorded in its version entry: ScheduledTask.TaskScheduler (0.15.1, a placeholder), Runtime.cancelTimer(id) bool (v0.28.0, bool could no longer report a pending timer), sqlx.hasUnboundedPgReads (v0.39.6, unbounded reads no longer exist), and TransactionalEvent / src/persistence/Database.zig (never exported from root.zig).
File-level deprecation banners
Two files carry a whole-file DEPRECATED banner. They are neither table rows (no consumer-writable old name remains) nor removals (the file still exists).
src/extensions.zig— thezmodu.extensionsnamespace is gone from the top level; the file is a type-alias shim reachable only as@import("zmodu/src/extensions.zig"), an internal path.src/validation/Validator.zig— the banner says “removed in v1.0”. The file is aliases only (pub const <name> = <home>.<name>;), with nopub fn. Canonical homes:src/validation/FieldRules.zigfor the rule shape,src/validation/FieldValidation.zigfor the engine (validateStruct,validateStructCollect, violations,MessageHook, scalar checks).http.FieldRules,http.validateRequestandhttp.extractJsonValidatedimport the homes directly.
The gate pins the banner to the first 1500 bytes, checks the replacement paths resolve, checks http.FieldRules is the same type as validation/FieldRules.zig, and checks every validateStruct* declaration lives in validation/FieldValidation.zig while the deprecated file keeps one alias per spelling and no function bodies. Both files are still imported by the compile gate in src/tests.zig.
Removed
Simplified is not in the table: “no earlier than 1.0” is a promise about aliases that still exist, so a deleted name recorded that way would read as available.
zmodu.App/zmodu.ModuleImpl— removed fromsrc/root.zigin commit557190a(2026-05-12, “…+ Simplified removal”). Replacement:Application(zmodu.builder(...).build(.{...})). The Before section ofAPI-MIGRATION.mdno longer compiles; it only documents what old code did. The implementation remains insrc/api/Simplified.zig, reachable only as@import("zmodu/src/api/Simplified.zig").src/test/ApiFreeze.zigpins both names as absent from the root.zmodu.extensions— the whole namespace, includingzmodu.extensions.HttpServerandzmodu.extensions.ModuleTestContext. No commit or tag records the removal; the written evidence isCHANGELOG.md[0.15.0](2026-08-01), which already listszmodu.ModuleTestContextas aroot.zigexport.@hasDecl(zmodu, "extensions")is false. Replacements:HttpServer→zmodu.http.Server,HttpContext→zmodu.http.Context,SqlxClient→zmodu.data.sqlx.Client,Orm→zmodu.data.orm.Orm,RedisClient→zmodu.data.redis.Redis.src/extensions.zigstill holds these aliases; its banner says “This file will be removed in v1.0”.
Adding a deprecation
All three, in one change: mark the declaration DEPRECATED and name the replacement; add a table row with the removal column filled; point that version’s entry back here. src/test/ApiFreeze.zig fails on a row without a gate, a gate without a row, an empty removal column, or a target that no longer resolves.
RouteGroup and Server.group are removed
Runtime registration uses Scoped; declarative routes use routes:
pub const routes = [_]http.RouteSpec(State){
.{ .method = .GET, .path = "{id}", .handler = get, .meta = .{ .auth = .jwt } },
};
// Mount: router.scope("/api").mountAll(.{ .{ .Mod = OrderApi, .state = &st } })
// Runtime registration (plugins, admin panels, SSE): scope.get / scope.post / scope.ws
Migration is mechanical; method names are unchanged: server.group("/api") → router.scope("/api"), and get/post/put/delete/patch/head/options, use and ws keep their names.
The difference is metadata. RouteMeta keeps auth, permission, roles and OpenAPI in the same comptime table as the handler, duplicate paths fail before finish(), and Testkit.auditAuthCoverage can audit them. Scoped routes are not in the catalog: permission gates, OpenAPI and auth-coverage audits cannot see them; use routes / ws_routes / sse_routes when you need those. Framework-internal registration goes through src/api/RouteRegistrar.zig, which is not exported from http.zig.
Zig 0.17 toolchain migration checklist
Errors your own application code hits after moving to Zig 0.17. Search for the compiler message and apply the fix.
| Compile error (keyword) | Fix | Note |
|---|---|---|
no member named 'allocPrint' / 'allocPrintSentinel' | std.fmt.allocPrint(a, fmt, args) → a.print(fmt, args) (returns !void); allocPrintSentinel → a.printSentinel(fmt, args, sentinel) | Or std.Io.Writer.fixed plus print |
no member named 'builtin' in 'std' | std.builtin.… → std.lang.… (Type, SourceLocation, CallingConvention) | Type is unchanged |
no member named 'os'/'cpu'/'abi'/'object_format' | @import("builtin").os → builtin.target.os, cpu → target.cpu, abi → target.abi, object_format → target.ofmt | Old names removed in 0.18.0 |
no member named 'DebugAllocator' | std.heap.SafeAllocator | Same semantics, stricter leak detection |
hasDecl (function form) | @hasDecl | Function form removed in 0.17 |
trimRight / trimLeft | trimEnd / trimStart | Rename only |
@intFromEnum / @enumFromInt | @backingInt / @fromBackingInt | zig fmt migrates most; @fromBackingInt needs the exact backing type |
Removed outright in 0.17 — compile errors, not deprecation warnings:
| Removed | Replacement |
|---|---|
std.Thread.sleep / std.Thread.Mutex / std.Thread.WaitGroup | Busy loop or std.Io.sleep / std.Io.Mutex (lock(io), unlock(io)) / std.Io.Group |
std.time.timestamp / milliTimestamp / microTimestamp / nanoTimestamp | Time.monotonicNowSeconds / Time.monotonicNowMilliseconds for durations; Time.wallClockSeconds(io) / Time.wallClockMilliseconds(io) for stored timestamps (the wall clock can move backwards) |
std.os.getpid | @intFromPtr(&seed) for a pid-shaped value; entropy comes from std.Io.randomSecure |
std.fs.cwd() / std.fs.File | std.Io.Dir.cwd(io) / std.Io.File; file.writeAll(data) → file.writeStreamingAll(io, data) |
ArrayList(T).init(alloc) | ArrayList(T).empty plus an allocator on every method |
file.reader() / buf.writer(allocator) | allocPrint + appendSlice, or std.Io.Writer |
The full removed list for framework contributors, including POSIX signals and crc.Crc32Iscsi, is in AGENTS.md under “Zig 0.17.0 — what’s REMOVED”.
v0.41 — rename ZigModu → zmodu (compile error)
Package, module and repository names are all zmodu. The compiler reports every site.
- Dependency key and module:
.zmodu = .{ … }inbuild.zig.zon;b.dependency("zmodu", …).module("zmodu");@import("zmodu"). - The
zigmodumodule and theZIGMODU_*environment variables are gone. Rename dependency key, module name, imports and env prefix in one pass; no alias is kept. - The package fingerprint is
0xde7987a02b121580(Zig requires a new value after a rename). Dependency hash prefixes changed fromzigmodu-tozmodu-; runzig fetchagain and update.hash. - These wire contracts keep the old spelling on purpose: Prometheus metrics
zigmodu_*, migration table_zigmodu_migrations, Raft snapshot prefixzigmodu-raft-snapshot:, model fieldzigmodu_ignore_tenant. They live in dashboards, existing databases, on-disk formats and application code; the rename happens at 1.0 with dual write and migration. - Repository
https://github.com/chy3xyz/zmodu(GitHub redirects the old URL). Docs use--url-prefix zmodu.install.shinstalls to~/.zmodu;~/.zigmoduis not reused.
v0.39.11
StaticFiles.staticMiddlewarereturns!api.Middleware(compile error). Construction can fail; addtryat the call site.- Failed migrations are no longer marked applied (behaviour tightening). Only a provably redundant
ALTER TABLE ADD COLUMNis exempt, verified with aSELECT <column> FROM <table> LIMIT 0probe. Everything else returnserror.MigrationFailedand leaves the migration unapplied. - TCP dial timeouts (behaviour tightening).
KafkaProducerConfig.connect_timeout_ms = 5000now takes effect — it was declared but never forwarded, so the kernel default applied; set0for the old behaviour. The Kafka consumer gains the same field and default.DistributedEventBusgainsconnect_timeout_ms = 5000.redisandNATSgainconnect_timeout_ms = 0(kernel default, unchanged behaviour).FluvioNative.connectWithTimeoutis new;connectis unchanged. HttpClient.timeout_msis a request deadline (behaviour change). A slow-drip response can no longer take N ×timeout_ms; the budget is exhausted anderror.Timeoutis returned.0still means unlimited. Newmax_response_bytes(default0= unlimited): an oversizedContent-Lengthis rejected before allocation, and decoded chunked bodies are truncated witherror.ResponseTooLarge.RateLimiterRegistryis bounded by default (behaviour tightening).initnow usesdefault_max_keys = 65_536(LRU) instead of0(unbounded);initWithCapacity(…, 0)is still unbounded. Keys come from request input, so an unbounded registry is remotely triggerable memory growth.- Raft snapshot accumulation is bounded (behaviour tightening; only visible above 256 MiB).
ElectionConfig.max_snapshot_bytesdefaults to 256 MiB (0disables). An oversized stream is rejected and its buffer cleared; the leader resends from offset 0 on the next round. The buffer is released when a snapshot completes, is rejected, or is replaced.
v0.39.10
Per-node keys now authenticate the event bus (behaviour change). Applies only to clusters that configure own_key/peer_keys without cluster_secret: the bus previously ran bare frames, and now ClusterBootstrap forwards the same key material to it, making the bus handshake mandatory. The Raft wire format is unchanged.
Rolling upgrade of such a cluster: new nodes demand a handshake while nodes at v0.39.9 and older still send bare frames, so the bus mesh disconnects between the two sides (Raft replication is unaffected). Configure cluster_secret on every node and roll once, then roll this version; remove cluster_secret in a separate roll if you do not want it. Clusters that already set it see no change.
v0.39.6
- Middleware factories and slot-pool functions return errors (compile error). Add
tryat every wiring point.
// Old
try server.addMiddleware(http.permissionGateWith(&slot, .{ .mode = .rbac }));
const loader = zmodu.security.CatalogPermDb.loaderFromClient(db);
// New: note the nested try
try server.addMiddleware(try http.permissionGateWith(&slot, .{ .mode = .rbac }));
const loader = try zmodu.security.CatalogPermDb.loaderFromClient(db);
The 13 factories returning error{OutOfMemory}!api.Middleware: http.jwtAuthFromCatalog, http.jwtAuthFromCatalogWithPermissions, http.authFromCatalog, http.tenantResolver, http.moduleGate, http.permissionGate, http.permissionGateWith, http.compressionMiddleware, and under http.http_middleware: cors, jwtAuth, csrf, csrfWith, securityHeaders, rateLimitPerClient. The slot pools returning error{SlotPoolExhausted}: http.catalogLoaderFromTable (64 slots), zmodu.security.CatalogPermDb.loaderFromClient, zmodu.security.ApiKeyAuth (also apiKeyAuth) and ApiKeyAuthWithLoader, http.openApiFromCatalog and http.openApiRoutes (16 slots). Let the error reach main; catching it leaves later routes without an authorization loader.
Fx.Parallel/Fx.Maprun concurrently (compile error). The first parameter isio: std.Io, sozmodu.fx.Parallel(io, T, items, 4, func);Paralleldropped its unused allocator. Work is chunked bymax_workerswithstd.Io.Group, sofuncmust be thread-safe.- JWT segments use strict base64url (behaviour tightening). Segments with
=padding are rejected, per RFC 7515. Make the issuer emit unpadded segments; standard implementations already do. - Scheduler
pushwaits instead of aborting (non-breaking). When the spin/yield budget is exhausted,pushsleeps 1 ms and retries until a slot appears or the scheduler stops.ready_push_failuresnow means “a push waited the whole budget” and must still stay 0; a non-zero value points at the host, not the framework. - sqlx
query_timeout_ms = 0falls back to the default (behaviour change, one API removal). For postgres and mysql,0now means 30000 ms rather than unbounded; reads are always bounded.sqlx.hasUnboundedPgReadsis removed. Newsqlx.DEFAULT_QUERY_TIMEOUT_MS(30000) andsqlx.effectiveQueryTimeoutMs. MySQL’sMYSQL_OPT_READ_TIMEOUTnow uses this value, rounded up to whole seconds. - TimerWheel fires equal deadlines FIFO (behaviour change). Timers armed on the same deadline fire in arm order instead of reverse arm order. If you depended on the old order, reverse your arm order; better, do not depend on relative order within one deadline.
v0.35.0
ws_write_timeout_ms = 0means “inherit”, not “unlimited” (behaviour change). Pushes now useresponse_write_timeout_ms(default 30 s). To restore the old behaviour, setresponse_write_timeout_ms = 0, which disables both budgets.- Every server write path has a budget set by
response_write_timeout_ms: H1 buffered responses, chunked/streaming, SSE, HTTP/2 frames andServerWS frames.WebSocketServerandWebMonitorhave their ownsetWriteTimeout(default 30 s). The budget applies persend, so a slow but moving peer is not cut off; a peer that stops reading gets a truncated response and a closed connection. A failed write is sticky: later writes and flushes return the same error instead of a successful empty flush. - HTTP/2 responses larger than
max_pending_bytes(default 4 MiB) are sent.max_pending_bytesbounds the queue, not the body; workarounds that moved large H2 responses to H1 can be removed. New H2 streams start at the peer’sSETTINGS_INITIAL_WINDOW_SIZE(RFC 9113 §6.5.2) instead of 65535.
v0.33.6 → v0.34.0 (released as v0.34.0)
No breaking change in this release.
- New
zmodu.TomlLoader:var loader = zmodu.TomlLoader.init(allocator); try loader.loadFile("app.toml", &config);.[server]+portbecomesserver.port, and types are preserved. ScopedContainer.registerBorrowed/remove/serviceCountcomplete theContainersurface. Lookups (get,contains) fall through to the parent; mutations andserviceCountapply to the current scope only.removenever reaches the parent, because unregistering a shared service would leave other readers with a destroyed instance.zmodu.runtime.affinity:supported(comptime) andpinCurrentThread(cpu_index). Linux usessched_setaffinity(0, …)and pins the calling thread; macOS and Windows returnerror.Unsupported; a kernel refusal returnserror.PinFailed. It never reports success it did not achieve.spawnhas no.affinityfield.src/core/TransactionalEvent.zigis removed. It was a 249-line stub whose functions were_ = event;and never a public export; only path imports break. Usezmodu.outbox.*orzmodu.SagaOrchestrator.PrecisionTimer’s 10 µs median bound depends on the host. On a shared runner or container, run its measurement test first and raisespin_window_ns(or set0to opt out). The 5 msRuntimetimer wheel is unaffected.batchstays at 16: measurement rejected only1, and 8/16/32/64 share one noise band. The pooled-worker latency tail comes fromidle_wait_ms = 1polling, notbatch.- Delivery-log persistence:
src/runtime/delivery_log.zigexists but is not exported fromruntime.zig/root.zigand has no consumer, soRuntime Replaystill cannot cross a process or a restart.ReplayFromLogis load-then-replay, needs a codec, rejects holes witherror.LogHasHolesunlessallowHoles()is called, and holds an allocator. Bind withsetCodec(id, C, E)andbindDecoded(id, handle); useDeliveryLog.setCodecRef(track, C, E)for tracks declared throughRuntime.spawn(.record). Handle.afterrecords.timerandsend*records.message.Track.record(event)is unchanged and equalsrecordKind(..., .message); useTrack.recordKind(event, kind)to choose. Replay from disk always delivers.message; the recorded kind stays onLogStep.kind.Replayer.bindno longer rejects a track that declared a codec, andBindErrorno longer haserror.CodecRequired.
v0.33.4 and v0.33.5
Compile errors:
- Password verification returns
PasswordError!bool(error{MalformedStoredHash} || std.mem.Allocator.Error) fromPasswordEncoder.matchesandSecurityModule.verifyPassword. An undecodable stored hash or an allocation failure is no longer reported as “wrong password”.
const ok = encoder.matches(input, stored) catch |err| switch (err) {
error.OutOfMemory => return err, // → 5xx
error.MalformedStoredHash => { log.err("unusable stored hash", .{}); return err; },
};
if (!ok) return unauthorized();
The stored hash length must now equal the derived key length exactly; the old prefix comparison accepted a valid 32-byte digest followed by one byte of garbage.
Middleware.attachIdentityBestEffortreturns!voidand fails closed:try attachIdentityBestEffort(ctx, claims, alloc);. Normalhttp.addMiddleware(http.jwtAuthFromCatalog(...))wiring needs no change.EventBus(T).subscribereturns!void:try bus.subscribe(MyEvent, handler);. Module listeners useApplicationModuleListener.subscribe(), which already returned!void.RedisCluster.init(allocator, io)— the old implementation usedstd.testing.io, a compile error outside tests.
Behaviour changes:
- Redis infrastructure failures raise instead of returning
false,0or-1; real server answers keep their meaning. A form with more fields thanServer.Config.max_paramsreturns 400 instead of 200 with an empty form, and allocation failures while parsing return 500. CachedConntreats a corrupt cache entry as a miss, re-queries and repairs it.BufferPool.acquirereturnserror.Canceled;release/available/statswait instead of returning fabricated readings, as doHttpClient.ConnectionPool.release/discard.- Auth middleware: token failures 401, our-side failures 500, unknown
kid401 plus a warning.authFromCatalogfails the request on our-side errors, including on.public/.optionalroutes. API key comparison is constant-time. SkillRegistry.registerreplaces duplicates and returns errors instead of panicking;ProviderRegistry.registerretires the replaced provider untildeinit(retiredCount()).MemoryStore.loadJson/loadFromFilereject an unusable scope (error.InvalidMemoryScope);recallreads0as “any scope”, so the old default leaked rows across tenants.PrometheusMetricsfreezes after the first scrape orfreeze(): latecreate*returnserror.Frozen, and duplicate names across the exposition returnerror.DuplicateName. Append label values withgetCounter/getGauge.WebSocketClient.sendText/sendJsonreturn{error.NotConnected, error.WriteFailed}; a failed pong write closes the connection.- Cluster callbacks are edge-triggered: one
leaveperjoin,on_leader_change_cbonly on a real change, recovery fireson_node_join_cb. IntegrationTest.InstrumentationContext.init(self, allocator) !voidconstructs in place;instrumentationis?*InstrumentationContext.- Lock waits that swallowed cancellation now wait or return
error.Canceled(EventBus,EventStore,cache.Lru,Runtime.shutdown,Mailbox, the redis pool,aikey pool / memory / skill / audit / quota). Thesrc/ai/*lock errorsLockFailed,RegistryLockFailed,QuotaLockFailed,RateLimitLockFailedare nowerror.Canceled. AgentAuditLoghas a publicowned: []boolfield; useAgentAuditLog.init. MySQL returns a mapped error when fields exist without metadata.OutboxConsumerdead-letters unparsable rows (status = 3).
v0.33.3
Cursor.nextreturnserrors.ResultT(?*Row)(compile error). Mid-stream driver errors were folded intonull, so a truncated result looked complete.while (cursor.next()) |row|→while (try cursor.next()) |row|;cursor.next() == null→(try cursor.next()) == null.csrf()requiresCsrfConfigonuser_data(runtime panic).csrf()iscsrfWith(.{}), and a manualmw.func(ctx, next, null)panics; passmw.user_data.X-Forwarded-Host/Protoare not trusted by default; behind a proxy usecsrfWith(.{ .trust_forwarded_host = true })and overwrite both headers on every request.sign_keymakes the cookie tokennonce.HMAC-SHA256(csrfMintSignedToken).sqlx.Builderreports allocation failures fromtoSql()instead of silently dropping awhere/join/groupBy/having/orderBy/selectColumnsclause. Signatures are unchanged.OtlpExporterandSecretsManagerhold a long-livedHttpClient;max_connectionsis per instance. Do notdeinitwhile requests are in flight.- H2 reaches parity with H1: form bodies (with
max_params),path_rewriter, query splitting from:path(400 when it cannot split), and a per-requestctx.allocatorarena.ctx.streamis stillnullon H2, and streaming handlers get 501. - H2 sends handler response headers. Connection-specific fields (
connection,keep-alive,proxy-connection,transfer-encoding,upgrade) are dropped with a warning, a mismatchedcontent-lengthis dropped, and fields over the peer’s header budget are dropped with a warning. - h2c upgrade dispatches the upgrading request as stream 1 after applying
HTTP2-Settings, and methods other thanGETare allowed.HEADresponses carry no body (H1’sstartChunkedpath andwriteErrorResponsestill write one). H1 writes a singleContent-Length, and only when it matches the body. - Two
Serverinstances in one process are isolated: OpenAPI state is keyed by*CatalogSlot, the permission table byRolePermissionTablepointer. Both loaders are wiring-time APIs with fixed pools (64 and 16 slots) and returnerror.SlotPoolExhaustedwhen exhausted; do not call them in a loop or on the request path. - PG: allocation failure returns
error.OutOfMemory, noterror.DatabaseError.
v0.33.1
- Validation middleware options
structured_errorsandmessage_hook(withStructuredErrors(),withMessageHook()). With structured errors, a 422 carrieserrors: [{field, rule, message}]indataand the first message inmsg.Validator.validateStructCollectreturns all violations;Validator.MessageHooklocalises default rule messages. Defaults are unchanged: flat string messages, business code 4220. - New
zig build soak-cluster, analloc/opCI gate for the benchmark suite, and the first fuzz targets (zig build --fuzz test).
v0.32.0
- WebSocket routes must declare
.meta.auth = .public(compile error). WS upgrades are answered beforerouter.matchand before global middleware, soauth/permission/rolesonws_routeshave no execution point. Verify identity inon_connect.
pub const ws_routes = [_]http.WsSpec(State){
.{ .path = "ws", .on_connect = …, .on_message = …, .meta = .{ .auth = .public } },
};
A missing .meta, a non-.public auth, or permission / roles all fail to compile. Scaffolded IM gateways default ImGateway.verifier to null and reject connections until a verifier is set.
- CSPRNG uses
std.Io.randomSecure(io, buf)(compile error). The old seed was milliseconds, a constant, a stack address and milliseconds × 1000 — public, low-entropy and shared inside the process. Entropy entry points now takeio; failure iserror.EntropyUnavailablewith no fallback.
Old New
ApiKeyGenerator.generate(allocator) ApiKeyGenerator.generate(allocator, io)
PasswordEncoder.init(allocator) PasswordEncoder.init(allocator, io)
PasswordEncoder.initWithIterations(a, n) PasswordEncoder.initWithIterations(a, io, n)
kit.random.uuid(allocator) kit.random.uuid(allocator, io)
std.crypto.random does not exist on this toolchain. Do not substitute std.Io.random, which falls back to pid + wall clock + ASLR on failure.
db.queryrejects a tenant context without a declared column (runtime rejection). With a tenant context the statement is wrapped:SELECT * FROM ( <model SQL> ) AS _zt_tenant_scope WHERE _zt_tenant_scope.<col> = ?. The tenant value is bound as?and never enters the SQL text. No declared column →error.TenantScopeUnavailable. Multi-tenant applications must useai.business.registerBusinessSkillsWith(®istry, &.{}, .{ .db_query_tenant_column = "tenant_id" }), and the SELECT must include the tenant column.- Request boundary hardening (behaviour change plus signature change). A valid header with no space (
Host:x,Content-Length:5) is now parsed instead of dropped.Transfer-Encodingreturns 400; this server has no chunked request decoder. Conflicting, duplicate or non-decimalContent-Length, orCLplusTE, returns 400. An unknown method, including legitimate extensions such asPROPFIND, returns 501. A request line without three parts or a version other thanHTTP/1.xreturns 400.http.Method.fromStringreturns?Method, withnullfor unknown. - Three defaults became fail-closed.
.dept_customwith empty or unparsabledept_idssetsdeny_clause = "1 = 0"; only.allproducesnull(no filter).permissionGateWithkeeps its configuration per call instead of in a function-level global, and an empty catalog denies.tenantClauseparenthesises the caller’s predicate, soowner_id = ? OR is_public = 1no longer lets the tenant predicate bind to one branch. RaftElection.handleAppendEntriesaddserror.InvalidLogIndex(compile error only for exhaustiveswitch). It fixes an unsigned underflow onentry.index == 0.- Multi-node
.peersentries need@id(startup rejection)..peers = &.{"node-b@127.0.0.1:9001"}; a missing id on a cluster withraft_cluster_size > 1returnserror.PeerIdRequired. Single-node clusters need no change.PeerDiscovery.Peergained anidfield.
v0.28.0
Runtime.cancelTimer(id) bool is removed and split in two.
// Old: "it is still pending" — no longer expressible after the ticker rewrite
if (rt.cancelTimer(id)) { ... }
// Hot path: the request is handed to the runtime, effective after about one tick (5 ms)
try rt.requestCancelTimer(id);
// Control plane: waits for the owner to execute, returns the final result
if (try rt.cancelTimerSync(id)) { ... } // true = pending at call time
after now means “at least delay_ms from the call”, with an upper bound of delay_ms + enqueue delay + tick_interval_ms. A full command queue returns error.Full instead of dropping the timer.
v0.27.0
zmodu auditandzmodu ciaudit nested modules (src/modules/a/b/…, including--with-agentoutput). Re-runzmodu audit .after upgrading and fix new findings; use// audit: ignore <rule>for a false positive.- Route state comes from
ctx.state(T), which returnserror.NoRouteStateinstead of lettingorelse unreachablebecome UB in ReleaseFast.
// Old — b4 reports this
const self: *MyApi = @ptrCast(@alignCast(ctx.user_data orelse unreachable));
// New
const self: *MyApi = ctx.state(MyApi) catch return null;
- Generated scaffolds log swallowed audit and relay failures with
std.log.warn, and tenantupdate/deletereturn 404 when no rows matched (events only whenrows > 0). Regenerate. - Scaffolded
shared/response.zigwrote an invalid success envelope; regenerate. Scaffold output is now reproducible:.fingerprintis derived from the package name. sqlx.Transactiongains theClientmethod set:queryRowsPartial(Ctx),queryScalar(Ctx),queryRowBorrowed(Ctx),queryRowPartialBorrowed(Ctx),findOne(Partial)(Ctx),findAll(Partial)(Ctx),batchExec(Ctx),ping(Ctx), plus*Ctxforms ofqueryRow,queryRowPartialandqueryRows.src/persistence/Database.zigis removed. Usedata.sqlxordata.Repository(Orm(Backend)).
v0.25.0
ClusterBootstrap.tick()also drivesraft.tick(): one call runsmembership.runOnce()→view.sync()→raft.tick(). With.transportset,start()listens onconfig.port(error.RaftInboundListenFailedif it cannot bind). Do not give the same port toDistributedEventBus.start(port). Runtick()on a timer withworker.after(1000, .tick)and callcluster.tick()from the worker’s handler.- Raft counts peer votes only and does not count its own twice; a single-node cluster elects itself on the first election.
SagaStep.timeout_secondsis enforced: an over-budget step is persisted, compensated in reverse order including itself, ends in.timed_out, and returnserror.SagaStepTimeout.0disables the budget.runcallers must handle the new error.- New
zmodu.TransactionJournalfor in-doubt transactions: append-only, INSERT-only, dialect-neutral, in-memory when no backend is configured, and fail-closed once a backend is set.recover()reports “prepared with no outcome” and does not retry or roll back.
v0.24.0
examples/testing/,examples/deprecated/,examples/cluster-demo/andexamples/example_tests.zigare removed; their content moved intoexamples/basic/src/tests.zigandexamples/distributed/README.md.zig build test -Dexample=testing→zig build test -Dexample=basic.build.zig.zonsetsminimum_zig_versionto0.17.0; older toolchains are rejected by Zig.
v0.23.0
- Multi-node
ClusterBootstrapis fail-closed. Withraft_cluster_size > 1(the default is 3),start()returnserror.RaftTransportUnavailableunless one of three options is set: a real transport, an explicit acknowledgement that elections happen elsewhere, or single-node mode.
var cluster = try zmodu.ClusterBootstrap.init(allocator, io, .{
.node_id = "node-1",
.port = 9000,
.transport = my_raft_transport, // real transport
// .allow_stub_raft_transport = true, // or: elections happen elsewhere
// .raft_cluster_size = 1, // or: single node
});
RaftTransport(src/core/cluster/RaftTransport.zig) implements the wire format (4-byte length prefix, 1-byte tag), the peer-to-address book, outbound votes and log replication, and inbound dispatch with replies on the same connection. A custom transport only needs to send;ClusterBootstrap.start()listens and dispatches.NetworkTransport.connectis dead code.
v0.22.0
ctx.runtime()is the only way a module creates workers or timers.Application.stop()joins workers before stopping modules; a bareLifecycle.startAllWithharness returnserror.RuntimeUnavailableinstead of creating a runtime. The first call starts the ticker, and modules must not callRuntime.initorrt.shutdown().
pub fn initWith(ctx: *zmodu.ModuleContext) !void {
const rt = try ctx.runtime(); // the application runtime
_ = rt; // rt.spawn(...) / handle.after(...)
}
skill.Tool.actiondefaults to.execute. Read-only tools must declare.action = .reador.propose, orallow_execute = falsepolicies reject them.Agent.memoryinjects memory for the run’s tenant and user. A missing or zero identity injects nothing, becauserecalltreats0as “any scope”. Do not feed agents throughMemoryStore.formatContext.src/test/DocSnippets.zigchecks fencedzigblocks in docs and doc comments. The most common failure is chaining builder methods onto thezmodu.builder(…)temporary, which is*const: bind it first withvar b = zmodu.builder(allocator, io); defer b.deinit();and callb.withName(…).build(…).
v0.15.46
- Upload content policy (optional).
http.Multipartonly enforced size, so applications reimplemented content checks, usually with one of three holes: extension only,Content-Typeonly, or SVG allowed.
const mp = zmodu.http.Multipart.Config.forBodyLimit(64 << 20);
var form = try zmodu.http.extractMultipart(ctx, mp); // 415 / 413 / 400 → ProblemDetails
defer form.deinit();
try zmodu.http.UploadGuard.checkForm(&form, .{
.extensions = &.{ "jpg", "jpeg", "png" },
.formats = &.{ .jpeg, .png },
.max_bytes = 5 << 20,
});
Check order decides the error: size → active content (SVG/HTML rejected by default) → extension allowlist → format allowlist → extension/content agreement. http.extractMultipart maps failures to ProblemDetails: 415 (not multipart), 413 (too large), 400 (missing boundary, malformed, too many parts).
Multipart.Config.forBodyLimit(n)alignsmax_total_byteswithmax_body_size. The server rejectscontent_len > max_body_size(default 8 MB) beforeMultipart.parseruns, so the defaultmax_total_bytesof 32 MB never triggers on its own.ctx.paramPathis deprecated in favour ofctx.nestedParam(removal no earlier than 1.0). The names differ by source:
| Call | Reads |
|---|---|
ctx.param / ctx.pathParam | Route placeholders only (/orders/{id}) |
ctx.queryParam / ctx.formValue | Query only / form only |
ctx.requestParam | Form first, then query |
ctx.nestedParam (was ctx.paramPath) | Dotted paths in form/query (filter.tags) |
ctx.route_templatealways has a leading slash. Dashboards and alerts filtering on the old shape (orders/{id}) must add the slash.CircuitBreakerRegistry.breakersisstd.StringHashMap(*CircuitBreaker); pointers stay valid across rehashes and access is locked.count()is new andgenerateReport()returns real JSON.RateLimiterRegistrygainsmax_keys(viainitWithCapacity), LRU eviction,remove(name),retain(max_idle_seconds)andgenerateReport().max_keys = 0keeps the old behaviour, but registries keyed by IP should set a bound.ShardRouter.ShardedQueryis removed (it only returnederror.NotImplemented).PluginManager.loadPluginlogs a warning and reportsdynamicLoadingSupported() == falseinstead of doing nothing silently.http.AccessLog/http.HttpMetricsshareSpinLock.
v0.15.45
- RFC 7807 error bodies (opt-in). Framework errors used to come in three shapes, only one of which could be hooked. Call once at startup:
zmodu.http.useRfc7807Errors();— chain plus pre-router,application/problem+json. Partial wiring:setDefaultReject(http.problemReject),setTransportErrorRenderer(http.problemTransportBody),setDefaultReject(null)to reset. Per gate:.reject = problemRejector.reject = envelopeReject(.thinkphp);ModuleGateConfiggainedrejectand.unknown = .denystill works. Per response:ctx.sendErrorEnvelope(status, code, msg). With this enabled, chain-tail middleware that rewrites the 404 body can be deleted.ProblemDetails.toJsonescapes strings, andstatusTitlecovers 402/411/413/414/415/416/418/428/431/451/501/505/507. - Handler-side permission matching. When a route meta uses OR (
portal:user|portal:shop) and the handler needs to know which side it is on, reuse the same expression instead of narrowing one branch:ctx.permissionMatches("portal:user|portal:shop"). For exactly a gate’s semantics (.rbacprefers AuthInfo when present) usezmodu.http.permissionMatchesWith(ctx, "portal:shop", .{ .mode = .rbac }).ctx.permissionsCsv()is new alongsidectx.rolesCsv(). - Redis client: framed RESP reads, a lock on the shared no-pool stream, working
read_timeout_ms/write_timeout_ms, and a connection that fails to parse is closed and removed from the pool. RateLimiterRegistry.limitersisstd.StringHashMap(*RateLimiter); read throughget/getOrCreaterather than the field.RateLimiter.acquireis deprecated in favour oftryAcquire(it never waited); removal is no earlier than 1.0.ProblemDetails.statusTitle(418)is"I'm a Teapot"rather than"Unknown Error".
v0.15.36 – v0.15.41
Authgains.optionalin v0.15.41: verify the token when present, ignore failures, never return 401. Use it for public endpoints that personalise when a user is known instead of hand-writing a best-effort check in a.publichandler.- Use
ctx.route_templatefor metric labels, notctx.path.pathcontains ids and blows up Prometheus cardinality. - Connection-level backpressure and slow-connection protection are on by default;
max_connections,over_limit_responseandheader_timeout_msare described in BEST_PRACTICES.md under “resilience”.
Release template
The cycle section is headed ## v<previous release> (unreleased batch) and points at CHANGELOG’s [Unreleased] entry. At release the heading becomes ## v<new version> and in-body [Unreleased] references are repointed.
Each entry records Breaking? (and what stops compiling or changes behaviour), scope (which call sites and version range), and the one-line fix. A rename or deprecation entry must also complete the three steps in Adding a deprecation. The release before a removal announces it here and in that version’s entry.