Releases: pathosDev/actor-ts
Release list
v0.17.0
The dead letters, DevTools and the wire release — 828 commits since v0.16.0 and 291 changelog entries, by a wide margin the largest window this project has had (v0.16.0, the previous record, was 188). Three threads run through it. The wire — cluster frames stop being a bare JSON.stringify and become the same tagged JSON tree the persistence layer already wrote, so the framework stops contradicting itself across its own boundaries. Dead letters — an undeliverable message stops vanishing into an event stream nobody subscribed to and gets a bounded, inspectable, replayable queue. DevTools — the UI becomes something you would actually leave open, with four new panels and the ability to stop time.
Fifty-three entries carry a BREAKING marker, each with a migration note. One of them decides how you deploy this, and it is first below.
⚠️ Breaking changes (pre-1.0)
-
A rolling upgrade across this release is not safe in either direction — and this is the second consecutive release for which that is true (#450). Cluster frames are now the tagged JSON tree, applied to the whole frame rather than an envelope's body. The old format was a bare
JSON.stringify, and the cost was that the framework disagreed with itself depending on which boundary a value crossed: aMapan actor could persist and recover verbatim arrived at a peer as{}, aDatearrived as a string whose.getTime()throws, aUint8Arrayarrived as an index-keyed object,NaNand-0arrived asnulland0, and abigintthrew straight out ofTcpTransport.send— which is to say, out of your ownref.tell. One walker now serves HTTP bodies, journal rows and the wire alike, so there is no per-transport list of what a message may contain to keep in sync.Migration. Most legacy traffic decodes unchanged, because
decodeJsonTreereads a tag only when it is an object's sole own key. The exception is a legacy body that already had that shape:__map__,__set__,__regexp__,__bigint__,__url__,__number__and__error__throw at any depth, and a decoder throw costs the whole connection along with every frame batched into the same chunk;__bytes__and__date__fail silently instead, decoding to aUint8Arrayand an Invalid Date. The other direction is plainly lossy — an older node reads the tag wrapper as ordinary data.Upgrade the cluster in one step: stop every node, then start every node on the new version. v0.16.0 already broke gossip compatibility the same way (#112), so a hop from v0.15.x lands on two non-rollable legs and needs the same treatment once rather than twice. The upgrade documentation now carries a per-release compatibility table (#1304), and #823 — a protocol version handshake — is what will make a mixed-version window a supported state rather than a hazard.
-
Cluster.bootstraprejects when readiness is missed (#943, #1086). A resolvedbootstrap()now means a formed cluster.awaitReadywidens toboolean | number | ClusterReadinessOptions, and its default budget covers the self-election grace on every stable-observation node — the old default under-covered N−1 of N nodes on a genuine cold start. On timeout the bootstrap runs the coordinated-shutdown pipeline and rejects withClusterReadyTimeoutErrorinstead of resolving for a node stilljoiningand letting it serve traffic. Migration:awaitReady: falsepluscluster.awaitReady().catch(…)restores the old fire-and-forget shape. -
ActorCellhandles a batch of user messages per dispatcher turn (#409) — worth 2.1×–3.6× ontellthroughput. Configurable per actor throughActorOptions.withThroughput(). -
Three cluster-correctness defects that all had the same shape: two authorities where there should be one. A routine singleton scale-up no longer runs two instances (#949);
KeepMajoritynow downs both sides of an exact 50/50 split rather than returning the empty set and leaving both halves live (#1170); and two nodes that disagree aboutnumShardsare no longer allowed to double-home entities silently, including via a persisted coordinator snapshot that used to route around the refusal (#633). -
system.terminate()drains the actors under/userbefore stopping them (#663).ref.tell('x'); await system.terminate()now deliversx. -
A bounded mailbox's
capacitybounds the messages it may discard, not the messages it holds (#729) — and messages a bounded or priority mailbox discards can now become dead letters (#773) instead of vanishing. -
BrokerActor.onReceiveis sealed (#709). Subclasses implement the new abstractonCommand(command)instead. -
Dead letters name the actor the message failed to reach, on every path (#433).
-
Projections gained a handler-failure recovery strategy and no longer retry a poison event forever (#650).
Forty more are in CHANGELOG.md, each with its own migration note.
🚀 New features
-
A bounded, optionally durable dead-letter queue with inspection and replay (#1000, #433). Undeliverable messages were published to an event stream that nothing subscribed to by default — which meant they produced no output at all, while two documentation pages claimed the system logged them. There is now a real queue, configured through
ActorSystemOptions.withDeadLetters(…), with ametricsstore option, anddeadLetterQueue.replay(id, alternateRecipientPath)to replay a message to a recipient other than the one it was addressed to. -
DevTools grew four panels and a pause button (#482, #553, #1349). The UI is Angular throughout, and gains a dead-letter panel, a live event-stream tail, a resolved-configuration panel showing every HOCON key and where its value came from, and a send-message action that is off by default. Time can be paused and resumed from the header. It also gained roughly 3,000 lines of tests, having previously had none (#487).
-
Cluster operability: wait until the cluster is actually formed (#943);
ClusterOptions.advertisedHostfor nodes behind NAT or a service mesh (#944); warm hand-over for singletons, so a scale-up transfers state instead of rebuilding it; andClusterSharding.shardMap(typeName). -
Persistence:
PersistentActorcan be fenced with a lease (#1166), so a stale instance cannot keep writing;PersistentActorandDurableStateActorgained anintegrity()hook;InMemorySnapshotStoreaccepts akeepNretention bound; Postgres and MariaDB projections read the tags index instead of scanning. -
The framework comparison is complete (#27, #1327). Nine arms across three runtimes — actor-ts, nact and XState on Bun; Akka and Pekko through both their Java and Scala APIs on the JVM; Akka.NET and Orleans on .NET — measured as a hundred interleaved rounds on one Linux machine, every row verified against work the system actually completed rather than work it was asked for. See
RESULTS.md. -
Observability:
actor_mailbox_depth,actor_mailbox_wait_secondsandactor_dispatcher_queue_delay_seconds;/healthand/readyaggregate framework-owned health checks;ActorSystem.runUntilTerminated()owns the whole of a service's shutdown. -
An email bridge actor and HTML email templates (#1133).
🔒 Security
Seventy-one entries — the largest security section this project has shipped. Among them: a ProducerController now stamps a crypto-random per-incarnation token on every Delivery and the ConsumerController refuses one that does not echo it; a replicated event's author is bound to the node that sent it, so a member can no longer permanently suppress another replica's events (#706); object-storage bodies are bound to the storage key they live at, so a durable-state revision cannot silently go backwards; DistributedData gossip is bounded by a per-frame byte budget; a WebSocket route's transport frame cap is the cap you configured, in both directions (#373, #586); a decoded CRDT counter slot is bounded; and the object-storage backends and the master-key rotation sweep now share one key policy (#747).
🐛 Fixed
Ninety-one entries. The tooling half is worth calling out on its own, because it is what makes the rest checkable: typecheck:dev went from 320 errors to zero and became a gated CI job (#540) — it is the only gate that compiles the library from a caller's position, and it immediately turned up five exported declarations no caller could use. Alongside it: the coverage gate stopped being two implementations of one parse, with per-module floors a rollup of per-file percentages cannot express (#541, #1016); the bundled examples now run in CI (#559); the three quarantined multi-node suites run nightly with the skip flag off; and a repeat-run flake harness (bun run test:stress) landed with a Diagnosing flakes page to go with it.
Install: bun add actor-ts · npm install actor-ts
Published with npm provenance via Trusted Publishing (OIDC). Full detail in CHANGELOG.md; what is planned next is in ROADMAP.md.
v0.16.0
The entry points, logging and old defects release, and the largest window the project has had: 181 commits since v0.15.0. Three threads run through it. Entry points — the root export stops being a barrel over the entire framework and becomes core-only, with eighteen subsystem subpaths beside it. Logging — the logger stops writing to exactly one place and grows a sink architecture, with rotating files and ten platform integrations, none of which pull in an SDK. Old defects — the 50 oldest open bug and security issues were worked as one unit, 42 of them resolved, including most of the 2026-08-01 security catalogue.
Twenty-six entries carry a BREAKING marker, each with a migration note. Two of them decide how you should deploy this, and they are first below.
⚠️ Breaking changes (pre-1.0)
-
A rolling upgrade across this release does not converge — upgrade the cluster in one step (#112). A gossip frame is a snapshot of the member map, and a member's version only moves when its status does, so a frame captured off the wire stayed valid indefinitely. Against a converged receiver that was harmless. Against an entry the receiver had deleted it was not: the failure detector's down path deletes outright so a healed partition can re-discover the peer, an expired tombstone is pruned for the same reason, and the branch that files a first sighting had no lower version bound at all. Replaying a downed member's own pre-down record therefore brought it back at its old version,
up, carrying its roles — and roles are what shard placement, singleton hosting and downing quorums are computed from.Every frame now carries a sequence its author stamps, seeded from that node's wall clock at startup so a restart out-numbers its own previous incarnation, and a receiver drops any frame that does not out-number the highest it has accepted from that connection peer. There is no new knob: the comparison is between a peer and itself, so it needs no clock-skew budget.
Migration.
GossipMessagegains a requiredsequence, and a frame without it is refused at the decode boundary. Nothing outside the framework composes gossip frames, so application code is unaffected — but an upgraded peer refuses an old node's frames and an old node ignores the new one. Upgrade every node in one step, or accept that membership does not converge while both versions are running. -
The root
'actor-ts'export is core-only; subsystems moved to subpath exports (#414). The root barrel re-exported every subsystem, which dragged the whole framework through one entry point — the testkit shipped in the production entry (#685), andimport { ActorSystem }paid for whatever any subsystem pulled in eagerly (#1005). Core — actors, supervision, scheduler/dispatcher, EventStream, system messages, config, mailboxes, patterns/Router, typed behaviors, the util value types and the base loggers — stays at'actor-ts'; everything else lives at its own entry.Migration. Import moved symbols from their subsystem entry:
import { PersistentActor } from 'actor-ts/persistence'; import { Cluster } from 'actor-ts/cluster'; import { FileSink } from 'actor-ts/logging';
Aliased root names keep working as spelled aliases:
import { Subscribe as ReceptionistSubscribe } from 'actor-ts/discovery',import { Transition as FsmTransition } from 'actor-ts/fsm'. -
The rest, in one line each — every one carries its own migration note in
CHANGELOG.md:HttpRequest.pathis the bare pathname on every backend (#605); every HTTP backend caps a request body at the same 1 MiB (#613);Lease.release()reports a failure instead of swallowing it (#598); the ClusterClient envelope no longer carries a sender field;serializeCookieis now safe by omission and validatesPathandDomain(#626); the CSRF cookie defaults to the__Host-prefix (#626); CSRF origin checks compare whole origins (#604);DEFAULT_MIME_TYPEShas a null prototype (#608); Express WebSocket handshakes now run the app's middleware (#623); an HMAC integrity tag can no longer be stripped to skip verification; a unary gRPC call is bounded by the configured deadline; everyHttpClientcall carries a deadline and a response-size cap (#602, #625);HttpClienthas a redirect policy of its own, with the hop budget down from 20 to 5;getFromDirectoryenforcessymlinks: 'within-root';DevTools.mount()demands an acknowledgement;@hono/node-wsmust be 1.2.0 or newer forwebsocket()routes (#586); andMemcachedClientLikeis typed inUint8Arrayrather thanBuffer(#1006).
🚀 New features
-
Logging grew a sink architecture (#1150). The logger wrote to exactly one place; it now fans one record out to as many destinations as you configure, each with its own minimum level, bounded delivery and a flush on shutdown.
const consoleSink = new ConsoleSink({ minLevel: LogLevel.Info }); const auditSink = new ConsoleSink({ minLevel: LogLevel.Error, format: 'json' }); const systemOptions = ActorSystemOptions.create().withLogSinks([consoleSink, auditSink]); const system = ActorSystem.create('my-app', systemOptions);
Nothing about the existing surface changed:
Logger,ConsoleLogger,JsonLoggerandNoopLoggerare untouched,this.logbehaves as before, and a system whose config nobody edited logs exactly what it logged yesterday. The pieces:MultiSinkLoggerand theLogSinkcontract (#1151),BatchingSinkwith a bounded queue, batching, retry with jittered backoff and drop accounting (#1152),FileSinkwith rotation and retention (#1153), and ten platform sinks (#1154–#1161) — starting withOtlpHttpSink, which reaches Grafana Loki 3+, Parseable, SigNoz, Datadog, Axiom, Honeycomb, New Relic and every OpenTelemetry Collector through one endpoint format. Every integration is dependency-free; the two that could have pulled an SDK, OpenTelemetry and Sentry, take the opposite route and say why on their own pages. -
Per-subsystem subpath exports (#414, #1001). The exports map grew one entry per subsystem barrel, so the subpaths the documentation already used —
actor-ts/http,/coordination,/serialization,/discovery— resolve now, and the smoke suite loads every declared entry on Bun, Node and Deno (#1003). -
The default Fastify backend loads lazily (#1005).
import { ActorSystem } from 'actor-ts'no longer parses Fastify and its ~20 transitive packages; the default backend resolves on the first bind, exactly like the express and hono arms always did. -
HttpClientcalls are bounded by time and by bytes (#602, #625), and carry a redirect policy of their own rather than the platform's. -
The cluster warns at startup when
remote.tls.enabledis not honoured (#591), rather than running plaintext in silence.
🔒 Security
Most of the 2026-08-01 security catalogue (#575–#626) closed in this window. The larger ones: cluster frame decoding is now linear in the bytes received rather than quadratic in the chunk count (#588) — a 16 MiB frame delivered in ~1400-byte writes was roughly 100 GB of memory copying on a path that runs before the hello gate; an idle inbound cluster socket is bounded in both directions (#588); the pod's mounted ServiceAccount token is never paired with an untrusted API server; the Kubernetes API seed provider percent-encodes the path; and the Idempotency-Key header is validated before it reaches a cache key (#607 narrowed, the eviction policy in #1080 is the remainder).
📦 Packaging
The packaging surface was brought in line with what actually ships.
-
No more dangling source maps (#1007).
declarationMapandsourceMapwere on whilefilespublishes onlydist/, so all 1262 maps pointed at a../src/*.tsthe tarball never carried, and none heldsourcesContent. A dangling map is worse than an absent one, since a missing map degrades cleanly to the.d.tswhile a dangling one sends the editor after a file that never arrives. Published, the package goes from 8.07 MB over 2360 files at v0.15.0 to 6.07 MB over 1268 files — and that is with the whole logging-sink subsystem added in the same window. -
NodeNextmodule resolution (#1008). The build saidmoduleResolution: "Bundler", but no bundler runs — the consumer is Node ≥ 24 or Deno on the real ESM resolver, andBundlerrelaxes exactly the rules that resolver enforces. The mandatory.jssuffix already satisfiedNodeNext, so the switch changes no emitted byte; what it buys is that the next forgotten suffix is a compile error here instead of anERR_MODULE_NOT_FOUNDin your process, and that.d.tsresolution becomesexports-map-aware now that the package has eighteen subpaths. -
@types/nodeis no longer a silent requirement (#1006). The declarations used Node-only types in public signatures while@types/nodewas a devDependency, so type-checking withskipLibCheck: falseproduced errors out ofnode_modules/actor-ts/that were not yours to fix.NodeJS.Signalsis replaced by the newProcessSignal(a member-for-member mirror, so every call site is unchanged),MemcachedClientLikespeaksUint8Array, and@types/nodeis declared as an optional peer dependency for the one remaining surface —ExpressBackend, which constructs aServerResponseat run time.
🐛 Fixed
42 of the 50 oldest open defects. A sample: inbound TCP bytes are buffered in a doubling slab rather than a fresh array per chunk (#610); an accepted socket is put on the handshake clock (#588); one rejected discovery rung no longer takes the whole ladder down; LeaseMajority tracks the acquire an abandoned reset leaves behind (#600); content-types resolve against own keys only (#608); Origin merges into a handler's Vary whatever its case (#603); and a rejected WebSocket upgrade reaches the client on Bun (#623).
Full detail, with the reasoning and every migration note, is ...
v0.15.0
The mailboxes and channels release, and the shortest window the project has had: 58 commits and twelve issues in the day since v0.14.0. It is a minor rather than the patch it was planned as, because pre-1.0 puts breaking changes in a minor and this window carries one — the default mailbox. Two threads run through it. Mailboxes — the default is unbounded again, bounding became a deliberate act that names its own loss, and the telemetry that watches what replaced it exists for the first time. Channels — EventStream finally accepts the kind-discriminated message style the rest of the framework argues for, and stops letting one bad subscription take the bus down with it. Underneath, constants got a placement rule, and the rule found a dead export, five duplicated values and a second path-traversal denylist.
⚠️ Breaking changes (pre-1.0)
-
The default mailbox is unbounded again (#1148). Every actor spawned without an explicit
mailboxCapacitynow gets the plain, unboundedMailbox. Since #310 it got aBoundedMailboxwithcapacity = 10_000andoverflow = 'drop-head', which silently discarded the oldest queued message on overflow — no dead letter, no exception, only a counter.#310's trade was worst-case message loss for a guaranteed memory ceiling. The ceiling turned out not to exist: the system-message queue was never bounded (#794), so the framework was paying the loss without collecting the guarantee. And the loss was never confined to the telemetry-shaped workloads
drop-headsuits, because a mailbox cannot tell a stale sample from a control message — the tracker has one entry per victim: a death-watchTerminatedevicted and the watcher blinded (#729), ReliableDelivery sends discarded with theirconfirmnever settling (#732), DistributedDataupdateAsyncpromises stranded unsettled (#1078), and three WebSocket-hub defects where the evicted envelope was a spawn command, aclose()or a disconnect signal (#717, #985, #986). None of those are reachable under the new default.Migration. Nothing to do if you want the unbounded shape — it is the default. To keep a bound, say so at the spawn site:
// before — the bound was implicit, and so was the drop policy system.spawn(Worker, 'worker'); // after — bounding is the deliberate act, and it names its own loss const workerOptions = ActorOptions.create<WorkerMessage>() .withMailboxCapacity(10_000) .withMailboxOverflow('drop-head'); system.spawn(Worker, 'worker', workerOptions);
Unbounded does not mean unobserved.
ActorCellwarns when a mailbox crosses 10 000 queued messages and again at every doubling, and metrics gained theactor_mailbox_sizegauge that the tuning docs had been documenting for a gauge that did not exist.actor_mailbox_dropped_totalstill exists and now only counts drops someone asked for.
🚀 New features
-
EventStreamchannels can bekind-discriminated types, not just classes (#1143).subscribe/unsubscribetook a class constructor and matched withinstanceof, which locked the one API the project offers for loosely coupled fan-out to exactly the message style the project argues against — and sincepublish(event: object)has always accepted a plain object, you could publish something nobody was able to subscribe to. A channel is now named three ways: by a class, exactly as before; by anEventKey, a new export mirroringServiceKey/ShardKey; or by the bare kind string, which costs the type unless you supply the type argument.export type UserLoggedInEvent = { readonly kind: 'user-logged-in'; readonly userId: string }; export const UserLoggedInEvent = EventKey.of<UserLoggedInEvent>('user-logged-in'); eventStream.subscribe(self, UserLoggedInEvent, (event) => event.userId !== 'system'); eventStream.publish({ kind: 'user-logged-in', userId: 'user-42' });
A key and its string are the same channel — subscribing both ways dedups, and either form unsubscribes the other. A class and a kind are two channels even when the class's instances carry that
kind. Prefix and wildcard families ('billing.*') are deliberately not included; they are purely additive later. -
A collision predicate on every random-id helper (#1141).
randomString,randomHex,randomIdandrandomUuidtake an optionalexistscallback and draw again while it answerstrue, so the loop every caller wrote by hand collapses intorandomUuid((id) => state.users.has(id)). The polarity is the design: the callback is thatwhilecondition, which keeps the!offMap- andSet-backed call sites. Bounded at 1 000 draws, then throws anErrornaming the helper and the count.ExistsPredicateis exported from the root barrel. -
Every mailbox reports its drops, not just the one the framework built (#1149).
actor_mailbox_dropped_totalwas fed by anonDropthe cell passed into theBoundedMailboxit constructed, so a mailbox supplied throughwithMailboxwas invisible to it. The cell now registers its observer after choosing the mailbox, on anything implementing the newDropReportingMailboxcontract — a structural probe rather than aninstanceofcheck. Registration is additive: aBoundedMailboxOptions.onDropof your own keeps firing alongside the stock counter. New exports:DropReportingMailboxandMailboxDropReason. -
MailboxandEnvelopeare exported from the package root (#661) — the escape hatch the docs described was previously impossible to import.mailboxOverflow/withMailboxOverflowis a realActorOptionsfield too (defaultdrop-head; setting it without a capacity is rejected rather than silently ignored). -
Three of the framework's own identifier draws go through the
existspredicate (#1146). The framework mints twelve identifiers; only three have a registry in scope and a failure worth preventing.ActorCell's anonymous child names now draw againstthis._children,ORSet.addagainst the element's live tags and its tombstones, andClusterClient.ask's id against the pending map. The other nine are recorded on the issue with the reason rather than left to be re-derived.
🐛 Fixed
-
One faulty
EventStreamsubscription no longer breaks the bus for everyone else (#1010).publishguarded the subscription's predicate but not its channel: theinstanceoftest sat one line above thetryandsubscriber.tellran unguarded below it, so of the three things that can throw per subscription exactly one was covered. A single bad entry raised aTypeErrorinto whoever calledpublish, and because the throw escaped the loop, every subscription registered after it silently stopped receiving anything — in an order decided by subscription order, which no caller controls. Sincepublishruns on every actor start, every actor stop and every dead-letteredtell, that turnedref.tell(…)— an API that does not throw by contract — into one that did.subscribenow rejects a channel that is not a usableinstanceofright-hand side, throwing on the line that wrote the subscription.publishruns the match test, the predicate andsubscriber.tellunder a guard and carries on to the next subscriber. Behaviour change: asubscriber.tellthat throws is now logged and swallowed rather than propagated out ofpublish. -
A dead constant and its live duplicate (#1142).
DEFAULT_SNAPSHOT_CACHE_TTL_MShad zero importers while the consumer its own docblock named declared the same five minutes locally asDEFAULT_TTL_MS. -
reEncryptionSweeprebuilt the ATS1 magic prefix (#1142) instead of importing theATS1_MAGICthatBodyCodecalready exports — a second copy of a format definition. -
The heartbeat interval existed twice (#1142).
defaultFailureDetectorOptionsanddefaultPhiAccrualOptionseach carriedheartbeatIntervalMs: 500, so swapping detectors could silently change how often a node talks to its peers — and only the first was pinned toreference.confby a test. -
Two independently-introduced redraw caps (#1142) and unnamed literals mirroring
reference.conf(#1142) — the dispatcher throughput was a bare16in three places, andShardCoordinatorresolved its rebalance interval and hand-off timeout against?? 2_000and?? 10_000. All now named and verified against the HOCON leaves they mirror.
🔒 Security
- One path-traversal denylist instead of two (#1142).
ActorPathandPersistenceIdValidatoreach declarednew Set(['.', '..'])under a different name. Both guard against the same attack — a persistence id becomes a filesystem or object-storage key where..climbs out of the configured prefix (#133), a path segment reaches actor-selection resolution — and neither imported the other, so extending one would have left the other accepting what it now rejects. Shared asPATH_TRAVERSAL_SEGMENTS, typedReadonlySet. No behaviour change: both sites reject exactly what they did before.
🧹 Internal
- Constants have a placement rule, and follow it (#1142).
src/held ~300 module-levelSCREAMING_SNAKEconstants across 130 files with no documented rule for where any of them belonged. A constant now has exactly four possible homes, checked in order, written down inAGENTS.md. EightConstants.tsmodules hold 42 values; ~20 misplaced options defaults moved next to the option they back. Every public name is unchanged — the barrels re-export from the new location, so no import breaks.
v0.14.0
The caps, codecs and lifecycle release, and by some distance the largest window the project has had: 266 commits, 106 issues, eighteen breaking changes. Three threads run through it. Caps — the cluster-wide registries, the member map, the metric label space and the quorum queue are all bounded now, and a gossiped record is held to those bounds rather than believed. Codecs — CBOR reaches rich-type parity with the JSON tree, which took four encoding changes to get right, and Avro and Protobuf ship beside it. Lifecycle — a restart stops the actor's children, a resumed actor brings its subtree back with it, and both of those had been quietly impossible before. Underneath: every severity: high finding from both audit passes is closed, including a cluster hello that trusted whatever identity a peer claimed. Pre-1.0 — this minor carries breaking changes, and one of them rules out a rolling upgrade; see below.
🚀 New features
- Scatter/gather router (#153) —
Router.scatterGatherFirstCompleted(size, routee, options?)asks every routee at once and answers with the first reply: Akka'sScatterGatherFirstCompletedPool, the hedged-request pattern for tail latency. The fan-out is not awaited in the handler, so concurrent scatters overlap instead of serialising the router's mailbox; every failure rejects with anAggregateErrorcarrying one error per routee, and the message distinguishes "nobody replied in time" from "everyone failed". Stopping or restarting the router fails its open scatters immediately rather than running out the clock.withTimeoutMsdefaults to 4 500 ms — deliberately underask's 5 000, because the router can only name the failing routees after its own deadline has passed (#1088). - TCP listener actor (#158) —
TcpServerActorbinds a port and serves every connection it accepts, closing the one half of the raw-TCP API that was genuinely missing. Framing is applied per connection with its own re-assembly buffer, TLS and mTLS go through the same cross-runtime layer the cluster transport uses, andmaxConnectionsrefuses at the door. Connections are addressed by an opaqueconnectionIdrather than getting an actor each, because an actor's restart semantics cannot resurrect a peer's TCP connection. CborSerializercarries the same rich types as the JSON tree (#1036) —Map,Set,BidirectionalMap,RegExp,URL,Error, the typed arrays,DataViewandArrayBufferall round-trip, most through their registered IANA tags. Until now a store configured with a CBOR serializer silently degraded them to{}.- Avro and Protobuf serializers (#73) — plus
serializerCodec(serializer), which adapts any byte-native serializer to the codec seam, so one implementation serves both. - Live and cursor-paginated persistence-id queries (#156) —
allPersistenceIds()streams entities as they appear;currentPersistenceIdsPaginated(cursor, limit)walks them in pages. Paging is pushed into the backend wherever a sorted key over ids exists (ORDER BY … LIMITon SQLite and the SQL dialects, a clustering range on Cassandra); MongoDB and DynamoDB have no such index and fall back to an in-process page. BidirectionalMap<K, V>andBidirectionalMultiMap<L, R>(#1035, #1037) — aMapthat answers in both directions, and its many-to-many sibling. Both round-trip through every store as tagged JSON-tree values, so they can be held in an actor's state directly: no snapshot adapter, no serializer registration, nothing at the boundary. The receptionist, the pub-sub mediator and the broker base now index their subscribers through the multi-map instead of each maintaining the same relation by hand.- DistributedPubSub anycast (#155) —
Publishtakes a third argument,delivery, and'one-subscriber'hands the message to exactly one subscriber cluster-wide: the work-queue shape a broadcast bus cannot do without the workers coordinating among themselves. Selection rotates a per-topic cursor over local subscribers and the remote nodes claiming the topic, so ten tasks over three workers land 4/3/3 rather than "probably roughly even". - JetStream Key-Value and Object-Store actors (#74), gRPC client-streaming as its own call mode (#5), and gRPC health checking (#4) —
GrpcServerActorcan now hostgrpc.health.v1.Healthfrom theHealthCheckRegistry. - Cluster subscriptions get a
CurrentClusterStatesnapshot replay and aReachabilityChangedevent (#161), and stable-observation bootstrap (#148) decides whether, and when, a node may elect itself rather than racing its peers into two rival clusters. - Death watch with a custom termination message (#159) —
watchWith(ref, message)delivers a message you choose instead ofTerminated, so a watcher that already speaks one protocol does not need a second arm for the framework's. - Typed
Behaviors.intercept/monitor/logMessages(#152),Router.smallestMailbox(#154) andsmallest-mailboxfor the cluster router (#69). LogContext.runFresh(fn)andrunEach(entries, fn)(#129) — the two seams where deferred work either belongs to nobody or belongs to somebody different per item. Without them a batch drained in one turn logged every item under whichever tenant happened to start the turn.randomUuid(),randomString,randomHex,randomId,safeStringify,lazyImportModuleon the public surface (#1034, #1109) — all six already existed and the framework runs on them; nothing but the barrel kept them from consumers.acquireLock(cache, key, ttlMs)(#141) — mutual exclusion over anyCache, and thesetIfAbsentatomicity contract that makes it sound is now written down and proven across all three backends.
⚠️ Breaking changes (pre-1.0)
-
The cluster wire protocol's discriminator is
kind(#494). The framework had three spellings for one concept:ton the cluster wire and the internal coordinator/singleton events,$ton the sharding protocol,kindeverywhere else. A rolling upgrade is not possible — a v0.13.0 node and a v0.14.0 node cannot talk. Stop the cluster, upgrade every node, start it again. -
CborSerializerencodes several values differently (#1036). All of these previously produced something wrong rather than something different, so the migration is usually "delete the workaround":Map,Set,RegExp,Errorand the typed arrays no longer encode as{};undefinedis CBOR simple value 23 rather than being dropped;-0is written as a float instead of collapsing to0; wrapper objects are unwrapped, andPromise/WeakMap/WeakSetare refused rather than silently encoded as nothing. -
A restart stops the actor's children (#634). Children used to be inherited by the new incarnation, which made an ordinary pattern impossible:
postRestartre-runspreStart, so an actor that spawned a named child there hitChild name … is not uniqueon its first restart and never recovered. Migration: an actor whose children should outlive a restart overridesActor.stopChildrenOnRestart()to returnfalseand adopts the survivor inpreStart—this.child = this.context.child('name').toNullable() ?? this.context.spawn(Child, 'name'). An instance field cannot carry it across:preStartruns on a fresh instance, sothis.child ??= …is always unset and re-spawns into the name the surviving child still holds. -
A sharded entity's child name escapes its id injectively (#568).
entityName()folded every character outside[A-Za-z0-9_-]to_, which is many-to-one —a.b@x.comanda-b@x.comcollided, and the collision killed the whole Shard actor along with every unrelated entity in it, including other tenants'. Ordinary ids are unchanged; anything outside[A-Za-z0-9_-.@:+]is now escaped as~plus four hex digits. -
persistenceIdis validated before it becomes a storage key (#133). Empty ids, ids over 255 characters,/and\separators, whole-id./..and control characters are rejected inpreStart— the rules actor names have had all along, mirrored onto the one identifier in the persistence layer that had no validator. -
Recovery over a journal compacted without a covering snapshot now fails instead of inventing a state (#122). It used to fold the surviving tail onto
initialState()and hand that toonCommandas the current state. Migration: compact only past a snapshot —deleteHistory(seq)keeps the snapshot atseqfor exactly this reason — or take one before compacting. -
redirect()rejects off-origin targets (#125). Forwarding a?next=parameter into it was a textbook open redirect. Same-origin targets only; use the newredirectExternal(url, status?)where the off-origin hop is deliberate. -
The HKDF
infoparameter is required for client-side encryption (#108).infois HKDF's context binding, so the framework-wide constant it defaulted to meant any two deployments holding the same master key derived byte-for-byte the same subkey — a staging environment restored from a production dump could read production snapshots. -
The two cluster-wide subscriber registries are bounded and watched (#137, #139), and
DistributedPubSub'sSubscribetakes an optionalreplyTo(#139). A refusedSubscribeis now answered rather than discarded; existing calls compile unchanged and keep following the sender. -
ClusterOptions.firstSightMaxVersionSkewMsis nowmaxVersionSkewMs(#114) — same default, same unit, no HOCON key to change. The old name stopped being true once the cap applied to every merge rather than to a first sighting. -
Smaller ones, each with a note in the CHANGELOG:
Publish's third constructor slot isdelivery, not the no-opsendOneMessageToEachGroup— do not rewritetrueto'one-subscriber', the old flag broadcast and the new value does not;PersistenceQueryrequires the two new query meth...
v0.13.0
The names and lifecycle release. Two threads that turned out to be the same thread: what an actor is built from, and when it goes away. Props is gone — spawning takes the actor class, and per-actor configuration became ActorOptions, an ordinary options family like every other one in the framework. Sharded entities now passivate by default, empty shards stop with them, and the generated names the framework hands out are no longer guessable. Underneath, a cluster-transport bug that could partition two healthy nodes permanently, and the discovery that the documented mTLS recipe was never actually authenticating anyone. Pre-1.0 — this minor carries breaking changes; see below.
🚀 New features
ActorOptions(#547) —withSupervisorStrategy,withDispatcher,withMailboxCapacity,withMailbox,withInternal,withEntity,withDisplayName, plus anActorOptionsValidatorthat rejects a non-positivemailboxCapacityat thespawncall rather than from inside the mailbox constructor. Accepted as a builder or as a plain object, like every other options family.Actor.displayName()— a readable name for an actor in logs and DevTools (#891). A path is an address, not a name: under sharding the log source grows to ~120 characters of machine identifier, and the business identity it stands for had to be repeated by hand in every message the entity logged. OverridedisplayName()and the actor says it once — the name joins the line as its own segment (... - User(test-user-590) - recovery complete) and labels the row in the DevTools actor tree, which is worth the most forBehaviorsactors whose class column readsTypedActoron every row. Also settable at the spawn site withActorOptions.withDisplayName(...)and at runtime withcontext.setDisplayName(...). Defaults to the path, so existing log output is unchanged, and it stays a label: metrics, tracing, dead letters and every cluster-wire identifier keep using the path.- Empty shards passivate (#892). A shard actor used to outlive its entities indefinitely — since entity ids spread over the hash space, a long-running node accumulated one idle, empty shard actor per
numShards. A shard that has stood empty forshardPassivationIdleMsis now stopped too. The region keeps ownership, so the shard stays routable and the next message re-creates it with no coordinator round trip. shardPassivationIdleMs/withShardPassivationIdleMs()/actor-ts.sharding.shard-passivation-idle(#892). Unset, it followspassivationIdleMs;0keeps empty shards resident while entities still passivate.ShardInfo.resident(#901) —ClusterSharding.shards()now reports whether each shard actor was materialised when its region answered.entityCount: 0cannot say that on its own: a running-but-empty shard and one that passivated report the same count.
⚠️ Breaking changes (pre-1.0)
-
Propsis gone from the public API (#547). Spawning takes the actor class or a factory directly:// before system.spawn(Props.create(() => new Greeter()), 'greeter'); system.spawn(Props.create(() => new Worker(db)).withMailboxCapacity(500), 'w'); // after system.spawn(Greeter, 'greeter'); // zero-arg class const workerOptions = ActorOptions.create<WorkerMessage>().withMailboxCapacity(500); system.spawn(() => new Worker(db), 'w', workerOptions);
Propsbundled two unrelated things — what to construct and how to run it — and 75 % of its ~970 call sites used only the first. Migration: dropProps.create(and its closing); move each.withX(…)into a thirdActorOptionsargument;asInternal()becomeswithInternal(). Renamed carriers:entityProps→entityActor, singletonprops→actor,singletonProps→singletonActor,childProps→child,routeeProps→routee,behaviorFor→actorFor;BackoffSupervisor.props→.factory,ClusterRouter.props→.factory,typedProps→typedActor. Two behavioural notes:ActorOptionsmutates in place wherePropswas copy-on-write (settings are snapshotted at spawn), and a class whose constructor takes arguments is now rejected at the spawn call instead of being constructed withundefineddependencies. -
Idle entities passivate by default, after 5 minutes (#892).
passivation-idleshipped as0ms, so nothing ever passivated until an operator went looking for the key, and entity sets only grew. Migration: an entity that keeps state in memory and does not rebuild it inpreStartnow loses that state after five minutes idle — persistent entities recover, plain ones do not. Setpassivation-idle = 0msto restore the old behaviour.ShardedDaemonProcessopts out on its own. -
The cluster TLS listener requests a client certificate (#565). See Security — a cluster already passing
castarts demanding peer certificates, and mutual TLS on Deno is now refused rather than silently skipped. -
Anonymous actors are named
$anonymous-<n>-<random>, not$1/$2(#895). The old per-parent counter was both opaque and guessable —/user/$1is the first anonymous actor of every run, and a path is an address. Migration: code that hard-codes an anonymous path or parses$<n>out of a name must spawn with a name of its own. -
Unnamed reliable-delivery controllers are
consumer-<n>-<random>/producer-<n>-<random>(#897). The fallback came from a module-global counter, so/system/delivery/consumer-1was the first one of every run, and twoActorSystems in one process drew from the same sequence. -
Actor names starting with
$are reserved for the framework (#900).spawnandspawnTypednow reject them — until now anyone could claim the prefixspawnAnonymousgenerates. A$anywhere other than the first character is unaffected.
🔒 Security
- The cluster TLS listener never requested a client certificate (#565, severity: critical).
requestCerthard-defaulted tofalsein both listener adapters, andrequestClientCertwas never set totrueanywhere in the repo. On a serverrejectUnauthorizeddoes nothing unlessrequestCertis on — so the mTLS recipe the Cluster security page documents produced server-authenticated TLS only. Since thehellohandshake carries no credential of its own, that left the peer certificate — the cluster's only admission control — unrequested: anything that could reach the remoting port completed the handshake presenting nothing and then claimed whatever node identity it liked. It is also the mitigation several other findings lean on, so until now those notes promised more than the transport delivered.requestClientCertnow defaults toca !== undefined, and two incoherent configurations fail closed at bind time:requestClientCert: truewith noca, and mutual TLS on Deno, whereDeno.listenTlscannot request a client certificate and the dialer sends none. - Quorum correlation ids in
DistributedDataare no longer guessable (#896).nextPendingId()returnedp<Date.now()>-<counter>. That value travels on the wire and the peer echoes it back on its acknowledgment, so a guessable id is one whose acknowledgment can be forged — satisfying a quorum write or read no peer actually confirmed. Now sixteen random hex characters. - A
ClusterClient's own wire identity no longer comes fromMath.random()(#910). The synthetic port a client names itself by goes into theNodeAddressit announces and keys the cluster'sbyPeermap, so it is an address — andMath.random()is not a CSPRNG. The comment above it claimed hrtime-derived randomness, which the code never did. Now drawn withcrypto.getRandomValuesacross the whole ephemeral range; the old 15 000-slot window also made accidental collisions likely at a few dozen clients per process, which was a correctness problem on its own. - Filesystem object-storage temp paths no longer come from
Math.random()(#898). The atomic-write temp file was named with the clock andMath.random(), so a local process sharing the directory could predict the path and pre-create it or plant a symlink there.
🐛 Fixed
- Two nodes that dial each other at the same moment no longer stay split forever (#697).
openOutboundregisters a connection inbyPeerbefore the handshake, and the hello-hijack guard compared identity alone — so in a crossing dial each node held an un-acked outbound under the other's key and rejected the other's perfectly legitimatehello. Neither dial then received itshello-ack, andonClosereleased the slot only if the handshake had completed: no re-dial, and every frame for that peer accumulating silently in the handshake buffer. The pair was partitioned for the lifetime of the process. Cleanup is now keyed on the dialled address, a 5 s handshake deadline reclaims a dial that connects but never acks, the buffer is capped, and a crossing dial is settled by address order so exactly one survives. An established peer connection is still never displaced. rememberEntitiesno longer forgets every entity when a shard rebalances (#632). The departing region announced anEntityStoppedfor every entity of the shard, which emptied the coordinator's registry — so when the shard was reallocated there was nothing left to ship to the new owner. A rebalance is the ordinary path, so this wasrememberEntitiesfailing at the one thing it exists for; it survived because the only coverage was a cold restart, which reloads from the journal and never exercises a live handoff.- Filesystem object storage stopped recognising its own temp files (#909). The
Math.random()removal above changed the temp-file name without updating the patternlist()uses to skip them, so a crashed writer's partial body was reported as an ordinary object. The test had staged the old shape as a literal, which ...
v0.12.2
The payload fidelity release. Every journal, snapshot store and durable-state store wrote payloads with bare JSON.stringify — so a persisted Set or Map recovered as {}, a Date came back a string, a Uint8Array an index-keyed object, and a bigint threw outright. Because the write path folds the original object into state, none of it surfaced until the next recovery. Payloads now go through the tagged JSON tree format JsonSerializer already used, on every backend, and that format grew to cover essentially everything JavaScript can hold.
Numbered a patch, but read the breaking change below before upgrading: this window carries one.
🚀 New features
- Full type fidelity for stored payloads and
JsonSerializer(#889) —NaN/Infinity/-Infinity/-0,undefinedin value positions (array slots,Setmembers,Mapentries — object properties still drop, matchingJSON.stringify),RegExp(source + flags),URL,Error(name + message + cause, including subclass constructors andAggregateError.errors) and every typed array /DataView/ArrayBuffernow round-trip through every store and the JSON serializer. Error stacks are deliberately not stored — they would leak filesystem paths into long-lived rows.Number/String/Booleanwrapper objects unwrap likeJSON.stringifydoes;Promise,WeakMapandWeakSetthrow aSerializationErrorat persist time instead of being silently stored as{}. - Per-store
serializeroption (#888, the persistence half of #450) — every journal / snapshot store / durable-state store options builder, and everyRegister<X>Pluginsbundle, takeswithSerializer(serializer)to route a customSerializerinto stored rows through a self-describing__serialized__framing. Default-format rows and framed rows coexist in one stream, so you can switch a running system's serializer without a migration; reading a framed row without — or with a mismatching — serializer fails with an actionableSerializationErrorrather than garbage. Registry auto-binding and the cluster wire remain tracked in #450.
⚠️ Breaking changes (pre-1.0)
-
Persistence stores no longer silently corrupt rich payload types (#888) — the fix changes what gets written. Payloads are stored as the tagged JSON tree (
__date__,__bytes__,__map__,__set__,__bigint__, plus a new__literal__escape so user data shaped like a tag round-trips as data) on every backend.Migration: none for readers. Rows written by older versions decode unchanged, so an existing journal keeps replaying. What changes is the write side: rows written from this version on carry tag objects wherever plain JSON would have corrupted the value, so older framework versions — and non-actor-ts consumers reading your tables as plain JSON — see the tag shape instead of a bare value. Plan a rolling downgrade accordingly, and check any external reader (a BI job, a dashboard query) that parses payload columns directly.
JsonSerializeralso now honourstoJSON(), reports circular references as aSerializationErrornaming the key path instead of overflowing the stack, and only interprets a tag when it is an object's sole own key.
🔧 Changed
- The in-memory journal, snapshot store and durable-state store round-trip payloads through the same codec as the real backends (#888) — dev/prod parity. An event that cannot be stored now fails in your test suite instead of on the first production recovery, and mutating an object after
persistno longer aliases into the store. Like the real stores, they still return and publish the caller's original objects, so nothing observable changes for payloads that were always storable.
📚 Documentation
- "What events and state may contain" answers the payload question with a table instead of a caveat — a new section on the Persistent actor page lists every payload category against its actual round-trip behavior: stored as plain JSON, round-tripped as a real instance via a tag, dropped like
JSON.stringify, or thrown at persist time. It holds for every store on every backend, including the in-memory ones, and it states the reader guarantee — rows written by earlier versions keep decoding unchanged, tags appear only in newly written rows where plain JSON would have corrupted the value. The JSON serializer page gained the matching fidelity list (the tag set,toJSON(), the__literal__escape, circular references reported by key path), and the envelope-format migration page now describes_eas rich-type-capable rather than JSON-safe-only. - Per-store serializers are documented end to end — a new "Using a custom serializer for persistence" section covers the
__serialized__framing, why old and framed rows coexist in one stream, theSerializationErroryou get when the serializer'sidno longer matches, howRegister<X>Pluginsfans one serializer out to a backend's stores (a leaf's own wins), and the deliberate exception: the in-memory stores ignore the option and always use the stricter default codec, so a test cannot pass on something production would reject. - Both, as always, EN + DE 1:1 — 8 pages per language.
v0.12.1
The config honesty release. Every key reference.conf ships is now actually read by something — the sharding, cluster, remote, http, system, worker-cluster and coordinated-shutdown blocks were documented, shipped, and inert; explicit options > HOCON > built-in defaults now holds across all of them. A CI guard fails the build on the next key nothing reads, and a new docs page publishes the complete reference.conf verbatim, pinned to the source by a test. Alongside that, actors can reach two things they previously had to be handed: their own Cluster and their own entityId.
Numbered a patch, but read the breaking change below before upgrading: this window carries one, plus several HOCON key renames.
🚀 New features
- An actor can reach its own
Cluster(#833) —this.cluster(unwrapped, throwing when the system never joined one),this.context.clusterandsystem.cluster(bothOption<Cluster>). TheClusterwas the one runtime object that had to be threaded in by hand, and a framework-constructed actor — a sharded entity, a singleton — has no call site to thread it through at all.cluster.sharding/cluster.singletoncome along, so an actor can start a region or a singleton from the inside. All three read through to the system on every access, so an actor that outlived the join still sees the cluster, and a system that rejoined afterleave()resolves to the new instance rather than the dead one. Registration is a newClusterExtensionthatCluster.joinis the sole writer of; core keeps its runtime independence from the cluster layer. - A sharded entity can read its own
entityId(#832) — the routing id used to stop at theShardthat spawned it, recoverable only by slicing theentity-prefix off the actor path. That was boilerplate at every call site and lossy: actor names have a restricted alphabet, souser:42anduser/42both read back asuser_42(#568).Props.withEntity({ entityId, typeName, shardId })is the same doorClusterShardinguses, left public so an entity can be unit-tested without a cluster around it. actor-ts.sharding.max-entities— the per-node entity cap is configurable (#835) —maxEntitiesLRU-passivates the coldest entity at capacity, and it was the one passivation trigger with no HOCON form, leaving the time bound tunable per environment and the memory bound code-only. An entity count is exactly the value that differs between a laptop and a 64 GB production node. Reference value is0(no cap), so nothing changes for anyone who does not set it.
⚠️ Breaking changes (pre-1.0)
-
ReplicatedEventSourcedActorno longer takes aCluster, andreplicaIdhas a default (#833) — both existed only because the actor could not reach its own cluster.// before class Counter extends ReplicatedEventSourcedActor<Command, Event, State> { readonly persistenceId = 'counter-1'; readonly replicaId: string; constructor(cluster: Cluster) { super(cluster); this.replicaId = cluster.selfAddress.toString(); } } new Counter(cluster); // after class Counter extends ReplicatedEventSourcedActor<Command, Event, State> { readonly persistenceId = 'counter-1'; } new Counter();
Migration: drop the
clusterconstructor argument and thesuper(cluster)it fed — a subclass with no other dependencies can drop its constructor entirely.replicaIddefaults tothis.cluster.selfAddress.toString(), which is what every in-repo subclass set it to by hand. A customreplicaIdbecomes a getter, since as a field it now collides with the base-class accessor (TS2610):override get replicaId(): string { … }. -
HOCON keys renamed. All were inert before this release, so no working configuration changes meaning — but a file that named them was never doing anything:
actor-ts.remote.max-frame-size→remote.max-frame-bytes, and its published default moves1M→16M. Nothing read the key, so every cluster has always run at the 16 MiB code default; publishing16Mstates what the framework does. If you sized your deployment against the documented 1 MiB, setmax-frame-bytes = 1Mexplicitly — it now works.actor-ts.remote.tcp.hostname→remote.tcp.host, matchingClusterOptions.host.actor-ts.worker.*→actor-ts.worker-cluster.*, andcount→workers, in lockstep withWorkerClusterOptions.actor-ts.coordinated-shutdown.exit-jvm→exit-process— a JVM-ism in a TypeScript framework, and it now does something:process.exit(0)once the pipeline completes.
-
Two dead keys removed rather than wired:
cluster.leader-election(the leader is always the lowest-addressed up-member; there is no second strategy) andremote.transport(a custom transport is an object passed towithTransport(…), never a string). -
actor-ts.http.shutdown-grace-period's published default moves5s→0ms—unbind()has always been called with no grace period, so0is what every deployment has actually been running. Making the documented5slive would have cost real time: where a backend'sclose()cannot settle, the window is a deadline always reached, not an upper bound that resolves early. Raise it deliberately if you want in-flight requests to finish. -
Cluster.joinwithouthost/portno longer throws. Validation runs on the merged settings and the reference config supplies both, so it now binds0.0.0.0:2552. That is the point of the feature, but it turns a startup error into a running node — pin the address in config if you were relying on the throw.
🐛 Fixed
- The
actor-ts.sharding.*,cluster.*andremote.*blocks are actually read (part of #653; closes #754) — the keys shipped, the docs explained them, andCluster.jointook every value fromClusterOptionsalone. Env-var substitution (port = ${?ACTOR_TS_PORT}) is applied now too.failureDetectormerges per threshold, not per object, so setting onlydownAfterMsin code keepsheartbeat-intervalandunreachable-afterfrom the file. actor-ts.http.backendandhttp.shutdown-grace-periodare actually read (part of #653) —bind()hardcodednew FastifyBackend().useBackend(…)still wins; the config only decides whatbind()picks when the builder was given nothing. An unrecognised name now fails with aConfigErrornaming the key and the accepted values instead of silently falling back. The reference comment advertisedfastify | bun | express— abunbackend that has never existed, and no mention of the Hono backend that does; corrected tofastify | express | hono.actor-ts.system.name,worker-cluster.*andcoordinated-shutdown.*are actually read (part of #653) —ActorSystem.create()now takes an optional name, falling back toactor-ts.system.namethen"default";create('billing')still wins.coordinated-shutdown.default-phase-timeoutseeds the 12 canonical phases (was hardcoded to5_000), andterminate-actor-system = falsedrops the built-in terminator task while leaving the phase and any user tasks intact. An unknownworker-cluster.restart-policyis now rejected byWorkerClusterOptionsValidatorinstead of falling through the internalmatchand silently meaning "never restart".- A guard against the next dead config key (closes #653) —
tests/unit/config/NoDeadConfigKeys.test.tsasserts, for every leaf inREFERENCE_CONF, that it is reachable fromConfigKeysand referenced from somewhere undersrc/. Knowingly-unimplemented keys go inKNOWN_DEAD_KEYSwith the issue that will remove them — one entry today (remote.tls.enabled, #591) — and the guard checks each excused key still exists, so an exception cannot outlive its key. ShardedDaemonProcessno longer regex-parses its own actor name to find its daemon index, and the chat example's direct-messagepersistenceIdis built from the real|-separated pair id rather than the sanitized one.
📚 Documentation
- A new page publishes the complete
reference.conf— every setting the framework ships, verbatim, so "what can I configure?" has one exhaustive answer instead of a curated example. The Configuration page keeps explaining what each key does and links across. The copy is pinned to the source: a test compares the page's HOCON block toREFERENCE_CONFand fails on any drift, in both languages.
v0.12.0
The addressability release. Things that existed but could not be named now can be: a shard is a real actor with a real ActorRef, an entity has a location-transparent handle, singletons and sharded types carry typed keys declared on the actor class, and framework actors moved out of /user into grouped /system paths. Underneath that, the DevTools suite (an embeddable web UI for a running system), five more persistence backends on a new relational base layer, and a broad correctness pass over the 2026 audit. Pre-1.0 — this minor carries breaking changes; see below.
🚀 New features
Cluster addressing
- A shard is a real actor:
Region → Shard → Entity(#511) — entities are grandchildren of the region at/user/sharding-<type>/shard-<n>/entity-<id>. TheShardactor owns the entity lifecycle; routing, buffering and passivation policy stay in the region, which is what keepsmaxEntitiesmeaning "per node". Handoff is now simply "stop the shard", soHandOffCompletefinally means what it says. See Breaking changes for the cost. - Shard introspection (#151) —
ClusterSharding.shards(typeName)answers cluster-wide with aShardInfoper placed shard (id, hosting node, region path, live entity count, locality, and a usableref);shardRefFor(typeName, shardId)hands back one shard's ref and allocates it if it had no home. The coordinator fansGetShardRegionStatsout to the registered regions and joins the answers againstshardHome; a region that misses the deadline contributes0rather than failing the call. NewStartEntity/GetShardStatsshard commands. ClusterSharding.entityRefFor(typeName, entityId)(#512) — a location-transparent handle to a single entity. It wraps each message in an id-addressed envelope the region routes without consultingextractEntityId, so the message type no longer has to know how it is routed. Synchronous (the shard ishash(entityId) % numShards), and a proxy region is enough to hand one out.SingletonKeyandShardKey— typed, class-declared identities (#523) — declared as a static on the actor itself, tying thetypeNameand the message type together the wayServiceKeyalready did for the Receptionist.ShardKeycarries theextractEntityIdalongside the name;SingletonKeycarries an optional role. Identity is the name alone in both cases, and options still override.cluster.singleton(#523) — the facade mirroringcluster.sharding, plusClusterSingleton.ref(key)for a singleton ref on a node that never hosts it (the counterpart tostartProxy),stop(key),managerFor(key)andisStarted(key).
Persistence
- Relational base layer (#389) — a new SQL backend is a
SqlDialect+ aSqlPooladapter + three thin subclasses. - Five more backends (#438) — MongoDB, DynamoDB, Microsoft SQL Server, libSQL / Turso and Cloudflare D1, each with journal + snapshot + durable-state. CockroachDB and YugabyteDB certified on the Postgres stores (#401).
SqliteDurableStateStore, SQLite persistence on Deno,LazyStore,close?()onDurableStateStore,SqlDialect.rowLimit(count), and by-tag projections that accept a fullTagFilter.PersistenceExtension.configure({ journal?, snapshotStore? }), plusCassandraJournalOptions.withLightweightTransactions()/.withSerialConsistency().
DevTools
- An embeddable web UI for a running system (#445) — seven panels on one versioned tap protocol, behind a
./devtoolsexport. Absorbs the separately-tracked live cluster visualizer (#204).--devtools-hostmakes the examples' bind interface configurable.
Core & observability
- Actor lifecycle events on the
EventStream(which now accepts abstract classes as channels),ActorSystem.startedAtMs,CoordinatedShutdown.removeTask(phase, name),Props.asInternal(),MetricsExtension.disable(),replayState(),TeeTracer, a boundedRecordingTracerwith monotonic timings, andRetryOptions.sleep.
Tooling & CI
- Benchmarks are gated (#506) — a new
benchmarksworkflow runstypecheck:bench(a benchmarks-only compile) andbench:smoke(every suite, one unwarmed iteration, ~30 s). Nothing looked atbenchmarks/before, so asrc/change that orphaned one was invisible. - Package-health CI — publint, arethetypeswrong and knip; plus
"sideEffects": falseand a memoizedActorPath.toString(). - The committed DevTools UI bundle is CI-gated (#521) by a
source-hash, not a byte diff — bundle bytes vary with the OS and Bun release that produced them, so a byte diff is not a staleness signal.
⚠️ Breaking changes (pre-1.0)
- A shard is a real actor (#511) — anything that resolved an entity by path must insert the
shard-<n>segment (hashShardId(entityId, numShards));ActorPath.parentof an entity is now its shard. This costs throughput and the number is not small: every message to a local entity takes one extra node-local hop, measured at ~40k → ~29k ask/s on one node (−28 %, +9 µs) and ~42k → ~25k on two nodes (−41 %, +16 µs). If you route hot-path traffic through a region and relied on the old numbers, this is the change that moved them. - Framework actors moved from
/userto grouped/systempaths (#509) — the DevTools hub, shard regions and coordinators, the singleton manager, the pub-sub mediator, the receptionist, DistributedData, reliable-delivery controllers and projections all left/user, and dropped the name prefix that only existed to stop a dozen unrelated actors colliding as flat siblings:/user/devtools-hub→/system/devtools/hub, and so on. ClusterSingleton.start()returns anActorRef;SingletonHandleis gone (#523) —system.extension(ClusterSingletonId).start(cluster, options)→cluster.singleton.start(options);handle.proxy.tell(m)→singletonRef.tell(m);handle.stop()→cluster.singleton.stop(key);handle.manager→cluster.singleton.managerFor(key).withTypeName/withPropsare unchanged. One behaviour change:stop()on the returned ref is now a warning no-op — it is the proxy, andActorRef.stop()means "PoisonPill the target" everywhere else, which would have killed whatever the host was running.- Abbreviations spelled out across all identifiers — type, class, file, method, field, generic-parameter and local names use full words (
Command/Message/Acknowledgment/Request/Response/Function/Context/Connection, no moreCmd/Msg/Ack/Req/Res/Fn/Ctx/Conn). Public surface is affected: generic parameters (PersistentActor<Command, Event, State>),Scheduler.scheduleOnceFunction/scheduleAtFixedRateFunction, exported types (HealthCheckFunction), config fields (maxMessages/maxAcknowledgmentPending/autoAcknowledge). The tagged-union discriminant is now alwayskind, nevertype, and its string values are spelled out (kind: 'increment', not'inc'). Names mirroring external APIs (nats.js, prom-client, amqplib, DOM) and domain acronyms (PubSub,K8s,AMQP,MQTT,SQL) are unchanged. - Runtime floors raised: Node ≥ 24, Bun ≥ 1.3 — Node 20 reached end-of-life in April 2026; Node 24 is the oldest active LTS and the first floor on which the WebSocket client, zstd, WebCrypto and fetch are all native. The Bun floor moves from the two-year-old, never-CI-tested 1.1 claim to 1.3, which CI now actually exercises. Deno stays ≥ 2.0.
WebsocketClientActoralways uses the nativeWebSocket— the dynamicwsfallback for Node < 22 is gone, and with it theheadersclient option (only that path could send custom handshake headers; on native runtimes it was already silently ignored). Migration: pass credentials via query parameter or subprotocol, as browser clients must.wsremains an optional peer for server-side upgrades on the Express backend.typescriptpeerDependency is now^5.6.0 || ^6.0.0 || ^7.0.0— admits TypeScript 7 (the native compiler the repo itself builds with) and raises the floor from the never-verified 5.0 to 5.6.- Dead persistence options removed (#381) —
LiveQueryOptions.batchSize,LiveQueryOptions.clock, the object-storage plugin'sdurableStatePluginId(+ builder method), and the HOCON keyactor-ts.persistence.recovery.mode. All were declared but read by no code. Migration: remove any use — they were no-ops. (Cassandra'sconsistencyis not removed; it is now honoured.) - Example wire protocols discriminate on
kind—examples/cluster/counter-node.tsand the WebSocket frontends follow the project-wide convention.
🔒 Security
- Actor names are validated (#126, #134) — closing a path-forging and a log-injection hole: a name containing a separator produced a path indistinguishable from a different actor, including across the cluster wire where the remote side re-splits the string.
- HOCON config parsing can no longer reach the object prototype (#406).
askreply refs get unpredictable names (#119) — the one-shot reply ref was named from a module-global++askCounter: predictable enough to aim a forged reply at an in-flight ask, shared across everyActorSystemin the process, and prone to wrapping into collisions with names still in flight. Now 12 hex characters fromcrypto.randomUUID.- Numeric gossip and heartbeat fields are checked for plausibility (#113, #115) — a tombstone's
removedAtfailed open atInfinity/NaN, so one forged frame kept a node from ever rejoining. - A TCP socket's nested framing caps are validated (#372) — a non-numeric
maxLineLen/maxFrameLenfrom HOCON did not clamp anything; every comparison againstNaNisfalse, so it removed the DoS cap it exists to enforce. - The cluster-singleton proxy buffer is bounded (#526) —
withBufferSize, default 1000, dropping the newest to dead letters past the cap. Unbounded, a cluster that never elects a host w...
v0.11.0
The consistency + fail-fast release. A repo-wide naming sweep lands one vocabulary everywhere — no abbreviations, Options never Settings, Websocket casing — as hard cuts with migration notes. Underneath it, a new OptionsValidator / OptionsError layer makes every configurable thing fail fast on invalid values, on every input path (builder, plain object, HOCON). Plus scoped HTTP error handling, a security-middleware suite, static file serving, and a broad batch of WebSocket/HTTP security hardening from the audit backlog. Pre-1.0 — this minor carries breaking changes; see below.
🚀 New features
Options validation
OptionsValidator+OptionsError(#274) — a declarative-but-code validator layer for theXOptionspattern. Validation runs once at consume time on the merged settings, so builder, plain-object, and HOCON inputs are all checked and cross-field rules see the final values.- Validators shipped for ~40 options families — every broker (MQTT, Kafka, AMQP, Redis Streams, NATS, JetStream, SSE, TCP, UDP, gRPC client) and the WebSocket client; cluster core, sharding, singleton, and downing strategies; discovery + gossip intervals; leases; caches (
RedisCache,MemcachedCache,CachedSnapshotStore,InMemoryCache);CassandraJournaland the S3 / filesystem object-storage backends; the Express/Hono HTTP backends; HTTP middleware + directives; WebSocket routes + resolved policy;WorkerCluster,ProducerController,TestProbe,CircuitBreaker, andBoundedMailbox. RateLimitOptions/IdempotencyOptionsfluent builders —rateLimitandidempotentgained the real builders they were already documented to have; the plain-object call form is unchanged.withMaxDecompressedBytesstore option — the 512 MiB decompression-bomb guard (#3) is now tunable per object-storage store (Infinityopts out).- Per-route WebSocket connection cap — opt-in
maxConnectionsonwebsocket()routes (builder oractor-ts.http.websocket.maxConnections); upgrades beyond the cap close with 1013 before an actor is wired.
HTTP
- Scoped error handling + fallback routes (#352) —
handleErrors(handler, child)catches exceptions from a subtree,fallback(handler)answers unmatched requests,ServerBuilder.withErrorHandler(...)is the server-wide last resort. Uniform precedence across Fastify/Express/Hono. - Security-middleware suite (#353) —
cors(compiler-expanded per-pattern preflight routes),strictTransportSecurity/hsts,contentSecurityPolicy,csrfProtection+requireSameOrigin,securityHeaders,requestId,BasicAuth,requestTimeout— each with anXOptionsbuilder. Plus publicparseCookies/serializeCookie. - Static file serving (#354) —
getFromFile,getFromDirectory,getFromBrowseableDirectory: MIME detection, conditional requests (weak ETag +Last-Modified→ 304), singleRange(206/416), HEAD, and XSS-safe listings. Plus a MIME-type registry (contentTypeFor) and streaming response bodies (ReadableStream<Uint8Array>) on all three backends. - HTML response utilities (#352) —
escapeHtml, an auto-escapinghtmltagged template with aSafeHtmlbrand,rawHtml,completeHtml.
⚠️ Breaking changes (pre-1.0)
- WebSocket →
Websocket(single-cap), noWsabbreviation —WebSocketServerActor/WebSocketClientActor→WebsocketServerActor/WebsocketClientActor,Ws*supporting types →Websocket*,wsSend()→websocketSend(), module movedsrc/http/ws/→src/http/websocket/. Thewebsocket()directive, the globalWebSocket, and theSec-WebSocket-Protocolheader are unchanged. - Abbreviations spelled out in type/member names:
*Cmd→*Command,*Msg→*Message,*Ack→*Acknowledgment,ByPid*→ByPersistenceId*,*Impl→*Implementation,*Ctor→*Constructor. Testkit too:TestProbe.expectMsg()→expectMessage(),expectMsgType()→expectMessageType(). Wire/discriminator string literals are unchanged. - One config vocabulary:
Options, neverSettings— remaining*Settingstypes →*OptionsType;BrokerSettings.tsfolded intoBrokerOptions.ts(BrokerSettingsError→BrokerOptionsError); theBrokerActorglue renamed (readOptionsFromConfig/requiredOptions/builtInDefaultOptions/options). - Command vs Signal unified on
kind— MQTT and WebSocket internal mailbox signals arekind-tagged plain objects; the bad-payload hook isonInvalidMessageeverywhere (MQTT'sonDecodeErroris gone);WebSocketAcceptSignal→WebsocketAcceptCommand. - Invalid option values throw
OptionsError(#274) at construction / actor start instead of a bareError— and previously-unchecked builder/plain-object paths are now checked. Missing required broker settings still throwBrokerOptionsError; malformed HOCON still throwsConfigError. InMemoryCachejoins theXOptionsfamily —InMemoryCacheOptionsbuilder + validator + HOCON defaults underactor-ts.cache.in-memory; the internalInMemoryCacheSettingsinterface is removed (a plain{ maxEntries, cleanupMs }object still works).CircuitBreaker+BoundedMailboxvalidate their options —OptionsErrorinstead of bareError;maxFailures/resetTimeoutMsandcapacityare required at runtime (a builder without them previously produced a breaker that never opened / an unbounded "bounded" mailbox);callTimeoutMs: 0now throws (omit it to disable).- HTTP structural types — the
Route/CompiledEndpointunions gainfallbackandcorsvariants (exhaustivematches must handle them);ServerBuildergains a requiredwithErrorHandler;HttpErrorgains an optional 4thheadersparameter andBearerTokenAuth401s expose the challenge onerr.headers['www-authenticate'](#352, #353).
🔒 Security
- WS-1 (HIGH) — WebSocket upgrade crash hardened — a malformed percent-escape in the upgrade path was process-fatal (unhandled rejection) on the Express backend, reachable pre-auth. Now a non-match → 404, with a last-resort socket guard.
- WS-2 (HIGH) — Cross-Site WebSocket Hijacking (CSWSH) defence — new
allowedOriginsonwebsocket()routes: a listed-but-wrongOriginis rejected with 403 before the handshake on all three backends. - HTTP-1 (MEDIUM-HIGH) — Hono body-size cap enforced before buffering — oversized
Content-Lengthnow rejects with 413 before reading the body. - HTTP-2 (MEDIUM-HIGH) —
InMemoryCacheis bounded (LRU) — attacker-chosen keys (idempotency, rate-limit) can no longer grow the default cache without limit; defaultsmaxEntries: 10_000, background sweep every 60 s. - HTTP-4 (MEDIUM) — idempotency responses can be scoped per caller — opt-in
identity: (req) => stringfolds the authenticated principal into the cache key, preventing cross-user response disclosure. - #3 (MEDIUM) — decompression-bomb cap on stored bodies —
decodeBodycaps decompressed size at 512 MiB by default, now tunable per store. - WS-3 (MEDIUM) — transport-level WebSocket frame cap (Express + Fastify) — oversized frames are rejected at the protocol level (1 MiB) instead of being buffered in full first.
- WS-4 (MEDIUM) — WebSocket backpressure works on Hono — the adapter now surfaces
bufferedAmount, somaxBufferedBytes/onBackpressureare no longer no-ops there. - WS-5 (MEDIUM, partial) — per-route connection admission cap — see New features; the handshake/idle-timeout and hub-mailbox-bounding sub-parts remain tracked follow-ups.
- WS-6 (LOW) — CRLF stripped from raw upgrade-reject headers — no response splitting via app-supplied header values on the Express reject path.
- BRK-1 / BRK-2 (MEDIUM) — inbound buffer caps for TCP
lines+ SSE — un-delimited streams now drop the connection instead of buffering forever. - #6 (LOW) — consistent SQL/CQL identifier validation — SQLite and Cassandra now validate config-sourced identifiers like Postgres/MariaDB already did.
- #9 (hardening) — JSON deserialization ignores the
__proto__setter — hostile payloads can't change a decoded object's prototype. - CORS, CSRF, and security-header middleware (#353) — correct preflight handling, HMAC-signed double-submit CSRF token, HSTS/CSP/COOP/CORP/nosniff/frame-options, constant-time secret comparisons;
WWW-Authenticatechallenges now reach the wire. - Hardened path-traversal defence for static files (#354) — full pre-validation decode, segment rejection (
.., NUL, backslash, drive/ADS), symlink confinement, uniform 404s.
📚 Documentation
- Server-WebSocket page moved from IO into the HTTP section (#351); stale API-reference pages reconciled with the shipped code (#360).
- Middleware pages corrected to actual behavior: rate-limit headers + keying on
req.remoteAddress(HTTP-3), idempotency in-flight → 409, response-cache single-flight (EN + DE). - The reference config now documents the
actor-ts.http.websocketpolicy section;*Settingsprose repointed to the*OptionsTypevocabulary (#349).
Full changelog: CHANGELOG.md · 2665 tests green, ~94 % line coverage.
v0.10.0
The typed real-time + SQL-persistence release. Two new first-class actor families — a typed WebSocket stack (server routing + client) and a subclass-first MqttActor — land alongside PostgreSQL and MariaDB persistence backends. Configuration gets a framework-wide overhaul: every configurable thing now takes a fluent builder or a plain object under one XOptions name, with no more "Settings vs Options" split. Plus HTTP middleware + auth, structured logging, a real-network multi-node integration harness, and five security hardening fixes. Pre-1.0 — this minor carries breaking changes; see below.
🚀 New features
Messaging & IO
- Typed WebSocket routing (#1) —
websocket(path, actorRef)binds aWebSocketServerActor<TOut, TIn>: codec-decoded messages (JSON default,rawCodec()for binary),this.reply(...)/this.broadcast(...), andonClientConnected/onClientDisconnected/onInvalidMessagehooks. A session actor per connection solves the first-frame race by construction. Works on Fastify, Express, and Hono (Bun/Node/Deno);withMiddleware(...)gates the handshake. WebSocketClientActor<TOut, TIn>(#1) — typed client onBrokerActor: reconnect-with-backoff, outbound buffering across reconnects, circuit breaker, HOCON settings.- Subclass-first typed
MqttActor<T, TSelf>(#345) — extend it, declare subscriptions in the constructor (this.subscribe(topic, { qos })), handle inbound inonMessage, publish withthis.publish(...). Lifecycle events run on the actor thread; still externally controllable viaref.tell({ kind: 'publish' | 'subscribe' | 'unsubscribe', … }). - Typed MQTT payloads + codec (#345) — inbound
MqttMessage<T>carries a lazily-decodingMqttPayload<T>(.bytes/.text()/.entity<U>()); pluggableMqttCodec<T>(defaultmqttJsonCodec()).
Persistence
- PostgreSQL backend (#323) —
PostgresJournal,PostgresSnapshotStore,PostgresDurableStateStore(first SQL durable-state store) onpg, viaregisterPostgresPlugins. Optimistic concurrency, indexed tags join, auto-created schema.pgis an optional peer dep; in-process fake-pool suite + livepostgres:latestCI. - MariaDB backend (#324) —
MariaDbJournal/MariaDbSnapshotStore/MariaDbDurableStateStore+registerMariaDbPluginsvia themariadbconnector (MariaDB dialect). Optional peer dep; fake-pool + livemariadb:latestCI. - Configurable compression level (#322) —
CompressionConfig.level(gzip 0–9, zstd 1–22), encoder-only, no migration (old + new bodies mix freely).
HTTP
- Route middleware framework (#312) —
withMiddleware(mw, route)+ aMiddlewaretype, composing outside-in. BearerTokenAuth({ tokens })(#312) — constant-time bearer-token gate withWWW-Authenticate.IpAllowlist({ allow })(#312) — CIDR (IPv4 + IPv6, incl. IPv4-mapped) network isolation, fail-secure.HttpRequest.remoteAddress+ backend wiring (Fastify/Express/Hono) soIpAllowlistworks on real socket peers (#312).managementRoutesauth (#312) —auth,ipAllowlist,authProtectHealth; privileged subtree gated,/health+/readyanonymous by default.
Observability
JsonLogger(#311) — one JSON object per record to stdout, MDC-aware, never-throws sanitisation.otelLogger({ api })(#311) — OTLP-Logs bridge via@opentelemetry/api-logs, auto-links the active span.
Testing
- Real-network multi-node integration tests (#313) — a Docker-compose harness (5 node containers + controller) running 15 scenarios (partition/heal, sharding rebalance, singleton failover, CRDT convergence, external
ClusterClient, management auth,CoordinatedShutdown, bounded-mailbox drops, DNS discovery, …) withiptables+tc netemfault injection.bun run test:integration.
⚠️ Breaking changes (pre-1.0)
- Options: a fluent builder OR a plain object, under one
XOptionsname (#346, #348) — every configurable type exposes three names from oneXOptions.ts:XOptionsType(plain object),XOptionsBuilder(fluent builder), andXOptions(the union every consumer accepts, plus a value alias soXOptions.create()works).new MqttActor(MqttOptions.create().withClientId('x'))andnew MqttActor({ clientId: 'x' })are interchangeable. The oldXSettingsinterface is renamedXOptionsTypeand the builder classXOptionsis renamedXOptionsBuilder; everyday call sites (XOptions.create()…, plain objects) are unaffected — only code referencing the oldXSettingstype name or the builder class by name needs updating. HOCON precedence is unchanged. - Renamed settings fields + HOCON keys (#348) — MQTT
defaultQos→qos,keepAliveSec→keepAlive; JetStreamackTimeoutMs→ackTimeout; ClusterClientlog→logger; DistributedDatagossipIntervalMs→gossipInterval; ProducerControllerresendTimeoutMs→resendTimeout. MqttActoris now abstract (#345) — subclass and overrideonMessage.MqttMessage.payloadis aMqttPayload<T>wrapper (#345), not a rawUint8Array:msg.payload.bytes/.text()/.entity().- Removed
MqttOptionsType.subscriptions+MqttSubscription(#345) — move them into the subclass constructor asthis.subscribe(topic, { target }). subscribe/unsubscribetargetsemantics (#345) — a no-targetsubscribe delivers to the actor's ownonMessage; a no-targetunsubscribe removes only foreign targets.- Bounded mailbox is the default (#310) — capacity 10 000,
drop-head; drops counted viaactor_mailbox_dropped_total. Opt back into unbounded per-actor withProps.withMailbox(() => new Mailbox()). - Removed the legacy frame-level WebSocket API —
WebSocketActor,ServerWebSocketActor, and theserverWebSocketActorOf/bunWebSocketHandlersadapters are gone; useWebSocketClientActorandwebsocket(path, ref)+WebSocketServerActor. The client HOCON keyactor-ts.io.broker.websocketis unchanged.
🔒 Security
- WebSocket DoS hardening (#1) — inbound frames size-capped (
maxFrameBytes, 1 MiB default) before decode; oversize → close 1009, undecodable → close 1003; slow-consumer backpressure pastmaxBufferedBytes. - DurableState revision tampering (#116) — opt-in HMAC-SHA256 over
{ revision, etag }for unencrypted object-storage bodies (encrypted bodies already bindrevisionas AES-GCM AAD);requireIntegrityrefuses legacy un-tagged bodies. - ClusterClient ask-ID predictability (#120) —
nextAskId()nowcrypto.randomUUID()(wasDate.now() + counter). - Master-key rotation sweep race (#109) — durable resume tokens (
progress) + pre-sweepverifyKeyringCompleteness. - LeaseMajority split-brain (#142) — monotonic
acquireEpoch, release-on-abandon with fail-safe, and optional fencing tokens (Lease.acquireWithToken?()).
🐛 Fixed
- MQTT subscriptions re-applied after reconnect (#345) — runtime subscriptions no longer silently stop receiving after a drop.
- MQTT
subscribewhile disconnected reaches the broker on connect (#345). - MQTT terminated fan-out targets cleaned up (#345) — deathwatched refs pruned; UNSUBSCRIBE fires once a pattern has no consumers.
- zstd compress on runtimes without native zstd (#321) — compress is native-only with a clear "needs Bun / Node ≥ 22.15" error; decompress keeps the
fzstdfallback; the misconfig now surfaces at plugin-init. - Object-storage compression docs corrected (EN + DE) to the real
none/gzip/zstdset, ATS1-manifest decode, andleveloption.