A ten-lens production-readiness review of v0.13.0, run against the code rather than against the tracker, and re-verified before filing.
121 findings carried into verification → 7 folded into existing issues → 114 filed (29 blocker, 38 high, 39 medium, 8 low). 67 were reproduced by running code ; 39 were confirmed by reading the cited lines where a live cluster or a real broker would have been required. A further 172 pre-existing issues were labelled production-goal rather than re-filed.
The question the review asked was narrow: ignoring the README disclaimer, what is the actual technical state, and what stands between this codebase and running it for real?
Why this batch is verified rather than reported
The review's most consequential finding is not any single defect — it is a pattern that makes ordinary code review unreliable here. Comments and JSDoc in this codebase systematically assert invariants the code does not implement. Not as sloppiness; as a consistent shape:
The comment says
The code does
Cluster.ts:804 — "FD-driven downing is the advisory fallback when no DowningProvider is configured"
deletes the member unconditionally; there is no if (!this.downing) (#929 )
KeepOldest.ts:52-57 — "the conservative default is still: keep the side with the oldest"
both branches return the identical set, so downIfAlone is dead (#932 )
ActorCell.ts:191 — "Unbounded … is still available via withMailbox(() => new Mailbox())"
Mailbox is exported from no entry point and there is no subpath (#661 )
PersistentActor.ts:261 — "Persist several events atomically"
D1 has no transaction at all (#959 )
ReplicatedEventSourcedActor.ts:486 — "read highestSeq + append in one mailbox tick"
sits directly above a void this._appendOne(...) (#958 )
LWWRegister.ts:60 — "break by replica id so every node converges to the same winner regardless of arrival order"
returns this when the replica also ties, so merge is order-dependent (#950 )
MailboxVariants.test.ts:249 — "assert behaviorally: fill past capacity, observe drop-head"
expect(droppedCount).toBe(0) on a mailbox that received nothing (#1020 )
BasicAuth.ts:48 — "a miss still burns the same comparisons"
&& short-circuits, so the password compare runs only for a known user (#968 )
ShardedDaemonProcess — "each daemon … its own shard via a 1-to-1 allocation"
ids are hashed; 100 daemons land on 58 shards (#951 )
Because of that, every finding was re-checked against the source before it became an issue, preferentially by execution rather than by reading. Each child issue carries a Verification status section stating which of the two it got. That discipline changed the outcome in both directions — see Reviewed and not pursued below.
Ground rules applied to every child
Verified before filed. CONFIRMED means reproduced by running it; CONFIRMED-BY-READ means the mechanism is unambiguous at the cited lines but not runtime-observable here. Both are stated explicitly; neither is implied.
Cite the line, not the comment. Every evidence block opens with path:from-to and quotes the current tree. Where a comment or a doc page contradicts the code, both are quoted.
No duplicates. Findings that already had an issue were not re-filed — the existing issue was labelled and, where this review added evidence it did not already carry, commented on.
production-goal is a gate, not a batch marker. It sits on 287 of the 668 open issues, including ones filed months before this review. Filtering on it answers "what is still between us and production", not "what did this review find".
Children
Core runtime, typed API and patterns (15)
[Bug] A throwing actor constructor leaves a permanently suspended zombie — never stopped, never removed from its parent, and its watchers are never notified #914 — A throwing actor constructor leaves a permanently suspended zombie — never stopped, never removed from its parent, and its watchers are never notified (blocker)
[Bug] A terminating actor with a queued user message busy-spins the dispatcher until its last child stops, burning a core through every shutdown under load #915 — A terminating actor with a queued user message busy-spins the dispatcher until its last child stops, burning a core through every shutdown under load (blocker)
[Bug] An actor that throws in onReceive is restarted with no log output at any level, so a crash-looping actor is invisible in production #916 — An actor that throws in onReceive is restarted with no log output at any level, so a crash-looping actor is invisible in production (blocker)
[Bug] The supervision restart budget is per-parent rather than per-child, so five siblings that each fail once exhaust one budget and two are stopped #917 — The supervision restart budget is per-parent rather than per-child, so five siblings that each fail once exhaust one budget and two are stopped (high)
[Bug] context.watch() is a silent no-op for a remote ref, so cross-node death watch never delivers Terminated despite the JSDoc promising it unconditionally #918 — context.watch() is a silent no-op for a remote ref, so cross-node death watch never delivers Terminated despite the JSDoc promising it unconditionally (high)
[Bug] tell() throws MailboxFullError into the sender under the reject policy, so an actor is failed and restarted because the actor it sent to was slow #919 — tell() throws MailboxFullError into the sender under the reject policy, so an actor is failed and restarted because the actor it sent to was slow (high)
[Bug] CoordinatedShutdown applies the 5 s default phase timeout to terminate-actor-system and has no overall deadline, so a slow drain is abandoned and then exited over #920 — CoordinatedShutdown applies the 5 s default phase timeout to terminate-actor-system and has no overall deadline, so a slow drain is abandoned and then exited over (high)
[Bug] startTimerWithFixedDelay is implemented with scheduleAtFixedRate, so a slow handler accumulates ticks — the exact behaviour the fixed-delay name rules out #921 — startTimerWithFixedDelay is implemented with scheduleAtFixedRate, so a slow handler accumulates ticks — the exact behaviour the fixed-delay name rules out (medium)
[Bug] ReceiveTimeout fires 1 ms after a long handler completes, reporting an actor as idle at the moment it finished working #922 — ReceiveTimeout fires 1 ms after a long handler completes, reporting an actor as idle at the moment it finished working (medium)
[Bug] An all-for-one restart resumes a sibling that is already terminating, leaving a running actor whose child set can never finalise #923 — An all-for-one restart resumes a sibling that is already terminating, leaving a running actor whose child set can never finalise (medium)
[Feature] The per-actor stash capacity is a hardcoded 1024 with no ActorOptions field and no config key #924 — The per-actor stash capacity is a hardcoded 1024 with no ActorOptions field and no config key (low)
[Bug] ask() rebuilds the message as an object literal, so Map, Set, Date, typed arrays and every class instance arrive stripped of their contents #925 — ask() rebuilds the message as an object literal, so Map, Set, Date, typed arrays and every class instance arrive stripped of their contents (blocker)
[Bug] BackoffSupervisor cannot survive its own restart — it respawns into the name of a child that is still alive, or orphans it #926 — BackoffSupervisor cannot survive its own restart — it respawns into the name of a child that is still alive, or orphans it (high)
[Bug] after(...).cancel() produces an unhandled rejection from the idiom its own JSDoc teaches #927 — after(...).cancel() produces an unhandled rejection from the idiom its own JSDoc teaches (medium)
[Bug] Typed activeSupervise and signalHandler are one-way latches, so a signal handler installed once keeps intercepting for every later behavior #928 — Typed activeSupervise and signalHandler are one-way latches, so a signal handler installed once keeps intercepting for every later behavior (medium)
Cluster membership, transport, downing and leases (19)
[Bug] The failure detector deletes a peer at downAfterMs regardless of the configured DowningProvider, so every split-brain resolver is bypassed under a comment claiming it is only a fallback #929 — The failure detector deletes a peer at downAfterMs regardless of the configured DowningProvider, so every split-brain resolver is bypassed under a comment claiming it is only a fallback (blocker)
[Bug] Gossip and heartbeats target only reachable members, so a symmetric partition never heals and both halves run as independent clusters until a process restart #930 — Gossip and heartbeats target only reachable members, so a symmetric partition never heals and both halves run as independent clusters until a process restart (blocker)
[Bug] TcpTransport.send discards the result of socket.write(), so on Bun a backpressured socket silently truncates the frame stream and on Node it buffers without bound #931 — TcpTransport.send discards the result of socket.write(), so on Bun a backpressured socket silently truncates the frame stream and on Node it buffers without bound (blocker)
[Bug] KeepOldest.downIfAlone is a dead branch — both arms return the same set — so the crash of the lowest-addressed node makes every survivor down itself #932 — KeepOldest.downIfAlone is a dead branch — both arms return the same set — so the crash of the lowest-addressed node makes every survivor down itself (blocker)
[Bug] StaticQuorum has no "nothing is unreachable" early return, so a cluster merely smaller than quorumSize downs itself at bootstrap #933 — StaticQuorum has no "nothing is unreachable" early return, so a cluster merely smaller than quorumSize downs itself at bootstrap (blocker)
[Bug] lastDownedView is never cleared, so a partition shape that recurs at the same addresses is silently never resolved a second time #934 — lastDownedView is never cleared, so a partition shape that recurs at the same addresses is silently never resolved a second time (high)
[Bug] mergeMember has no tie-break for equal versions, so two observers that assign the same version to different statuses diverge permanently and the member never converges #935 — mergeMember has no tie-break for equal versions, so two observers that assign the same version to different statuses diverge permanently and the member never converges (high)
[Security] decodeSingleRef builds a dialable NodeAddress from unvalidated wire fields, so a ref embedded in any message body makes the receiving node connect to an attacker-chosen host #936 — decodeSingleRef builds a dialable NodeAddress from unvalidated wire fields, so a ref embedded in any message body makes the receiving node connect to an attacker-chosen host (high)
[Security] Lease.checkAlive() returns a cached boolean instead of comparing against expiresAt, and has no callers, so two nodes can both believe they hold the lease after an event-loop stall #937 — Lease.checkAlive() returns a cached boolean instead of comparing against expiresAt, and has no callers, so two nodes can both believe they hold the lease after an event-loop stall (blocker)
[Bug] ClusterClient.connect() clears the hello timeout in onError without settling once the socket has opened, so a post-handshake reset hangs the promise forever and ensureConnected memoises it for every later send #938 — ClusterClient.connect() clears the hello timeout in onError without settling once the socket has opened, so a post-handshake reset hangs the promise forever and ensureConnected memoises it for every later send (high)
[Security] onGossip trusts the payload's from for liveness and inserts an unknown sender without maySpeakFor, so any peer can hold a crashed node's failure detector open and one frame naming a phantom low address makes isLeader() false #939 — onGossip trusts the payload's from for liveness and inserts an unknown sender without maySpeakFor, so any peer can hold a crashed node's failure detector open and one frame naming a phantom low address makes isLeader() false (high)
[Bug] A downed node cannot be told and NodeAddress carries no incarnation, so SelfRemoved is unreachable on a real Cluster and the FD's delete-not-tombstone lets the address walk back in with its old version and roles #940 — A downed node cannot be told and NodeAddress carries no incarnation, so SelfRemoved is unreachable on a real Cluster and the FD's delete-not-tombstone lets the address walk back in with its old version and roles (high)
[Bug] The documented TLS recipe parses the transport address into a systemName that never matches Cluster.selfAddress, so a cluster enabled the only supported way never converges #941 — The documented TLS recipe parses the transport address into a systemName that never matches Cluster.selfAddress, so a cluster enabled the only supported way never converges (high)
[Bug] ClusterRouter rebuilds only on MemberUp/MemberRemoved while upMembers() drops an unreachable member at once, so 1/N of routed traffic is blackholed for the whole downAfterMs window with no dead letter #942 — ClusterRouter rebuilds only on MemberUp/MemberRemoved while upMembers() drops an unreachable member at once, so 1/N of routed traffic is blackholed for the whole downAfterMs window with no dead letter (high)
[Bug] awaitSelfUp resolves through the same finish() on timeout and resolveSeeds swallows discovery failures into an empty list, so Cluster.bootstrap() reports success for a node that never joined and self-elects a one-node cluster #943 — awaitSelfUp resolves through the same finish() on timeout and resolveSeeds swallows discovery failures into an empty list, so Cluster.bootstrap() reports success for a node that never joined and self-elects a one-node cluster (high)
[Bug] bootstrapCluster defaults the advertised host to 0.0.0.0, so every node gossips an identical selfAddress and each treats the join announcements of the others as claims about itself #944 — bootstrapCluster defaults the advertised host to 0.0.0.0, so every node gossips an identical selfAddress and each treats the join announcements of the others as claims about itself (high)
[Security] MessageChannelTransport calls the wire handler with no validateWireFrame and no try/catch, so one malformed frame from a worker throws out of the MessagePort callback and terminates the host process #945 — MessageChannelTransport calls the wire handler with no validateWireFrame and no try/catch, so one malformed frame from a worker throws out of the MessagePort callback and terminates the host process (high)
[Bug] encodeRefs/decodeRefs never remove from their WeakSet, so it is visited-detection rather than cycle-detection and every repeated object in a cross-node message body arrives as null #946 — encodeRefs/decodeRefs never remove from their WeakSet, so it is visited-detection rather than cycle-detection and every repeated object in a cross-node message body arrives as null (medium)
[Bug] FailureDetector.samples gains an entry for every address that ever sends a frame while forget() is only reachable from member-removal paths, so a non-member address is never reclaimed #947 — FailureDetector.samples gains an entry for every address that ever sends a frame while forget() is only reachable from member-removal paths, so a non-member address is never reclaimed (medium)
Sharding, singleton, reliable delivery and CRDTs (10)
[Bug] ShardCoordinator.onRegister overwrites shardHome for every claimed shard with no conflict check, so a region re-registering with stale localShards takes ownership back from the live owner and both nodes run the same entities #948 — ShardCoordinator.onRegister overwrites shardHome for every claimed shard with no conflict check, so a region re-registering with stale localShards takes ownership back from the live owner and both nodes run the same entities (blocker)
[Bug] The cluster singleton has no handover protocol — the new host spawns on LeaderChanged while the previous host stops its child with a PoisonPill queued behind that child's whole mailbox, so a routine scale-up runs two instances #949 — The cluster singleton has no handover protocol — the new host spawns on LeaderChanged while the previous host stops its child with a PoisonPill queued behind that child's whole mailbox, so a routine scale-up runs two instances (blocker)
[Bug] LWWRegister.merge returns this when timestamp and replica id both tie, so two writes from one replica in the same millisecond make merge order-dependent and the coordinator-state snapshot diverges between nodes #950 — LWWRegister.merge returns this when timestamp and replica id both tie, so two writes from one replica in the same millisecond make merge order-dependent and the coordinator-state snapshot diverges between nodes (high)
[Bug] ShardedDaemonProcess sets numShards = numDaemons but the region hashes the entity id, so 100 daemons land on 58 shards and up to 4 permanently co-locate instead of the documented 1-to-1 allocation #951 — ShardedDaemonProcess sets numShards = numDaemons but the region hashes the entity id, so 100 daemons land on 58 shards and up to 4 permanently co-locate instead of the documented 1-to-1 allocation (high)
[Bug] onLeaderChanged fires void this.loadCoordinatorState() outside the mailbox, so a demotion during the load repopulates the just-cleared regions/shardHome from a stale snapshot #952 — onLeaderChanged fires void this.loadCoordinatorState() outside the mailbox, so a demotion during the load repopulates the just-cleared regions/shardHome from a stale snapshot (high)
[Bug] ShardRegion.onShardHome drops a lost shard from localShards without stopping its Shard actor or entities, so the old owner keeps running them alongside the new owner and sweepEmptyShards can never reclaim the orphan #953 — ShardRegion.onShardHome drops a lost shard from localShards without stopping its Shard actor or entities, so the old owner keeps running them alongside the new owner and sweepEmptyShards can never reclaim the orphan (medium)
[Bug] Shard.onEntityTerminated emits EntityStopped for a crash-driven termination too, so a remembered entity that exhausts the shard restart budget is deleted from the durable registry and never revived after a node failure #954 — Shard.onEntityTerminated emits EntityStopped for a crash-driven termination too, so a remembered entity that exhausts the shard restart budget is deleted from the durable registry and never revived after a node failure (medium)
[Bug] DistributedData never prunes departed replicas — onMemberRemoved is a literal no-op — so replica slots grow per address forever and a replica restarting on its old address loses increments to merge-max #955 — DistributedData never prunes departed replicas — onMemberRemoved is a literal no-op — so replica slots grow per address forever and a replica restarting on its old address loses increments to merge-max (medium)
[Bug] applyMerged serialises both the previous and the merged value for every key of every gossip frame purely to detect a no-op merge, so each received tick allocates two full serialisations per key although every CRDT could implement equals #956 — applyMerged serialises both the previous and the merged value for every key of every gossip frame purely to detect a no-op merge, so each received tick allocates two full serialisations per key although every CRDT could implement equals (medium)
[Bug] ensureCoordinator runs before numShardsByType is populated, so every ShardCoordinator is built with 64 shards whatever you configure and entities above that id never get a home #1026 — ensureCoordinator runs before numShardsByType is populated, so every ShardCoordinator is built with 64 shards whatever you configure and entities above that id never get a home (blocker)
Persistence (7)
[Bug] Journal appends stamp the event timestamp before the commit while the by-tag Offset is ordered by it, so the events of a slow-committing writer land behind the projection cursor and are dropped permanently #957 — Journal appends stamp the event timestamp before the commit while the by-tag Offset is ordered by it, so the events of a slow-committing writer land behind the projection cursor and are dropped permanently (blocker)
[Bug] ReplicatedEventSourcedActor._absorb starts _appendOne without awaiting it, so back-to-back remote events race one highestSeq and every loser is lost from the local journal behind a log.warn #958 — ReplicatedEventSourcedActor._absorb starts _appendOne without awaiting it, so back-to-back remote events race one highestSeq and every loser is lost from the local journal behind a log.warn (blocker)
[Bug] persistAll is documented atomic but D1, MongoDB and Cassandra have no multi-event transaction and no capability flag exposes it, so a mid-batch failure commits a prefix the caller was told had failed #959 — persistAll is documented atomic but D1, MongoDB and Cassandra have no multi-event transaction and no capability flag exposes it, so a mid-batch failure commits a prefix the caller was told had failed (high)
[Security] Ten of eleven snapshot stores and nine of ten durable-state stores bind PersistenceOptions and never read it, so an actor encryption() setting is a silent no-op and state is written to disk in plaintext #960 — Ten of eleven snapshot stores and nine of ten durable-state stores bind PersistenceOptions and never read it, so an actor encryption() setting is a silent no-op and state is written to disk in plaintext (high)
[Bug] CassandraJournal.append splits its unlogged batch on the events partition only, so the tag-keyed index rows ride along in a multi-partition batch and a partial apply hides an event from every tag projection #961 — CassandraJournal.append splits its unlogged batch on the events partition only, so the tag-keyed index rows ride along in a multi-partition batch and a partial apply hides an event from every tag projection (medium)
[Bug] ObjectStorageDurableStateStore.etagCache is an unbounded Map keyed by persistenceId evicted only on delete or close, so a sharded deployment leaks one entry per entity ever loaded #962 — ObjectStorageDurableStateStore.etagCache is an unbounded Map keyed by persistenceId evicted only on delete or close, so a sharded deployment leaks one entry per entity ever loaded (medium)
[Bug] DurableStateActor.persist never refreshes _record after a DurableStateConcurrencyError, so every later persist replays the same stale revision and the actor is wedged for its lifetime #963 — DurableStateActor.persist never refreshes _record after a DurableStateConcurrencyError, so every later persist replays the same stale revision and the actor is wedged for its lifetime (medium)
Security and serialization (8)
[Security] The cluster wire protocol carries no credential and dispatchEnvelope resolves any to path from the root cell, so any peer that completes hello can address /system framework actors directly #964 — The cluster wire protocol carries no credential and dispatchEnvelope resolves any to path from the root cell, so any peer that completes hello can address /system framework actors directly (blocker)
[Security] decodeBody never checks that the caller expected encryption and there is no requireEncryption option, so object-store write access is enough to replace an encrypted body with a plaintext one the actor recovers into #965 — decodeBody never checks that the caller expected encryption and there is no requireEncryption option, so object-store write access is enough to replace an encrypted body with a plaintext one the actor recovers into (blocker)
[Security] parseCidr accepts an empty prefix because Number coerces it to 0, so a malformed CIDR such as 10.0.0.0/ turns IpAllowlist into an allow-all gate in front of /cluster/down and /metrics #966 — parseCidr accepts an empty prefix because Number coerces it to 0, so a malformed CIDR such as 10.0.0.0/ turns IpAllowlist into an allow-all gate in front of /cluster/down and /metrics (high)
[Security] entity() lets a JSON body pick a value runtime class through the JsonTree tags, so a handler receives an attacker-authored RegExp, a bigint that passes x > 0 but breaks JSON.stringify, or an undefined no parse can produce #967 — entity() lets a JSON body pick a value runtime class through the JsonTree tags, so a handler receives an attacker-authored RegExp, a bigint that passes x > 0 but breaks JSON.stringify, or an undefined no parse can produce (medium)
[Security] BasicAuth short-circuits its credential check with &&, so the password comparison runs only for an existing username and the number of timingSafeEqual calls becomes a username-existence oracle #968 — BasicAuth short-circuits its credential check with &&, so the password comparison runs only for an existing username and the number of timingSafeEqual calls becomes a username-existence oracle (low)
[Security] Static-file Range requests call readFileBytes on the whole file and answer with a subarray view, so Range: bytes=0-0 buffers and retains up to the 50 MiB maxFileSize per in-flight request #969 — Static-file Range requests call readFileBytes on the whole file and answer with a subarray view, so Range: bytes=0-0 buffers and retains up to the 50 MiB maxFileSize per in-flight request (medium)
[Bug] CborEncoder accumulates output with one Array.push per byte, so a 10 MiB application/cbor response transiently costs a 10-million-element JS array plus the Uint8Array copy #970 — CborEncoder accumulates output with one Array.push per byte, so a 10 MiB application/cbor response transiently costs a 10-million-element JS array plus the Uint8Array copy (low)
[Security] managementRoutes mounts the three cluster-topology endpoints with no auth and no enable flag, so internal topology is readable by default while every mutating endpoint is opt-in #971 — managementRoutes mounts the three cluster-topology endpoints with no auth and no enable flag, so internal topology is readable by default while every mutating endpoint is opt-in (medium)
The I/O edge — HTTP, WebSocket, brokers (17)
[Bug] KafkaActor resolves its eachMessage promise before the handler runs while autoCommit is on, so the documented at-least-once default is at-most-once, and a partial withConsumer erases a HOCON manual commit mode #975 — KafkaActor resolves its eachMessage promise before the handler runs while autoCommit is on, so the documented at-least-once default is at-most-once, and a partial withConsumer erases a HOCON manual commit mode (blocker)
[Bug] AmqpActor acks each delivery before handing it to the target actor and swallows a failing ack, so the default configuration loses messages on restart and redelivers duplicates when the ack fails #976 — AmqpActor acks each delivery before handing it to the target actor and swallows a failing ack, so the default configuration loses messages on restart and redelivers duplicates when the ack fails (blocker)
[Bug] MQTT QoS 1 and 2 are structurally unreachable because MqttActor never enables manualAcks, so mqtt.js PUBACKs while the message is still in the mailbox and MqttMessage.qos reports a guarantee the actor cannot provide #977 — MQTT QoS 1 and 2 are structurally unreachable because MqttActor never enables manualAcks, so mqtt.js PUBACKs while the message is still in the mailbox and MqttMessage.qos reports a guarantee the actor cannot provide (blocker)
[Bug] RedisStreamsActor attaches no error listener to either ioredis client, so the first connection blip raises an unhandled error event and kills the process #978 — RedisStreamsActor attaches no error listener to either ioredis client, so the first connection blip raises an unhandled error event and kills the process (blocker)
[Bug] ExpressBackend pipes a ReadableStream body with no error handler, so a stream that fails mid-response exits the Node process and hangs the request forever on Bun #979 — ExpressBackend pipes a ReadableStream body with no error handler, so a stream that fails mid-response exits the Node process and hangs the request forever on Bun (blocker)
[Security] cached() stores and replays response headers verbatim including Set-Cookie and returns before the handler, so one caller session and body are served to every later caller for ttlMs #980 — cached() stores and replays response headers verbatim including Set-Cookie and returns before the handler, so one caller session and body are served to every later caller for ttlMs (blocker)
[Bug] AmqpActor awaits a drain event that a closing amqplib channel never emits, so one backpressure episode wedges the outbound path and every later publish is FIFO-evicted from the buffer #981 — AmqpActor awaits a drain event that a closing amqplib channel never emits, so one backpressure episode wedges the outbound path and every later publish is FIFO-evicted from the buffer (high)
[Bug] RedisStreamsActor guards its consume loop with a boolean re-tested only after the await, so a reconnect landing in the error backoff leaves two loops reading under one consumer name #982 — RedisStreamsActor guards its consume loop with a boolean re-tested only after the await, so a reconnect landing in the error backoff leaves two loops reading under one consumer name (high)
[Bug] rateLimit fails open on a bare catch with no log, metric or option, so a cache outage silently disables the limiter for every request with nothing to alert on #983 — rateLimit fails open on a bare catch with no log, metric or option, so a cache outage silently disables the limiter for every request with nothing to alert on (high)
[Bug] idempotent() claims the key with the response TTL, deletes the claim on a throw and caches every status, so a crash wedges the key for 24 h, a partially applied handler is re-executed and a 503 is pinned #984 — idempotent() claims the key with the response TTL, deletes the claim on a throw and caches every status, so a crash wedges the key for 24 h, a partially applied handler is re-executed and a 503 is pinned (high)
[Bug] WebsocketConnection.close() is a tell into the same drop-head mailbox as every outbound frame, so a hot broadcast evicts the close command and closeAll() silently does nothing #985 — WebsocketConnection.close() is a tell into the same drop-head mailbox as every outbound frame, so a hot broadcast evicts the close command and closeAll() silently does nothing (high)
[Bug] The WebSocket hub removes a client from _clients only in onWebsocketDisconnected, a drop-head mailbox tell, so a frame flood evicts the disconnect signal and leaks the entry and the hook forever #986 — The WebSocket hub removes a client from _clients only in onWebsocketDisconnected, a drop-head mailbox tell, so a frame flood evicts the disconnect signal and leaks the entry and the hook forever (high)
[Bug] BrokerActor.dispatchWhenConnected reads an empty buffer while _drainBuffer still awaits, so two dispatches run concurrently and the documented enqueue order is violated #987 — BrokerActor.dispatchWhenConnected reads an empty buffer while _drainBuffer still awaits, so two dispatches run concurrently and the documented enqueue order is violated (high)
[Bug] BrokerActor returns from _handleReconnect without _closeTransport() on both terminal paths, so a broker that gives up keeps a live driver client, its sockets and its internal reconnect timers #988 — BrokerActor returns from _handleReconnect without _closeTransport() on both terminal paths, so a broker that gives up keeps a live driver client, its sockets and its internal reconnect timers (medium)
[Bug] BrokerActor.postStop discards up to 1000 accepted outbound messages with no flush and no dead-lettering, so a graceful stop silently drops everything enqueueOutbound acknowledged #989 — BrokerActor.postStop discards up to 1000 accepted outbound messages with no flush and no dead-lettering, so a graceful stop silently drops everything enqueueOutbound acknowledged (medium)
[Feature] Only 6 of 35 plain-HTTP test files run across all three backends and there is no shared contract suite, so backend divergences ship until a user finds them #990 — Only 6 of 35 plain-HTTP test files run across all three backends and there is no shared contract suite, so backend divergences ship until a user finds them (medium)
[Feature] No broker actor caps redelivery or has a dead-letter path, so a poison message hot-loops on AMQP requeue, redelivers forever on JetStream and sits in the Redis PEL with nothing to reclaim it #991 — No broker actor caps redelivery or has a dead-letter path, so a poison message hot-loops on AMQP requeue, redelivers forever on JetStream and sits in the Redis PEL with nothing to reclaim it (medium)
Performance and benchmark validity (4)
[Bug] The 100,000-queued-messages row of the memory benchmark measures 9,999 because the default bounded mailbox drops the rest, so the per-message footprint figure reports the mailbox cap instead #972 — The 100,000-queued-messages row of the memory benchmark measures 9,999 because the default bounded mailbox drops the rest, so the per-message footprint figure reports the mailbox cap instead (medium)
[Security] The DevTools wallclock profiler never auto-stops when durationMs is omitted and its bucket Map is uncapped and keyed per actor path, so one panel click grows one bucket per entity per message type forever #973 — The DevTools wallclock profiler never auto-stops when durationMs is omitted and its bucket Map is uncapped and keyed per actor path, so one panel click grows one bucket per entity per message type forever (medium)
[Bug] BoundedMailbox.enqueue builds a ts-pattern matcher and three closures per dropped message, so the overflow path costs about 19x an inlined switch exactly when the system is already saturated #974 — BoundedMailbox.enqueue builds a ts-pattern matcher and three closures per dropped message, so the overflow path costs about 19x an inlined switch exactly when the system is already saturated (low)
[Bug] The tell-throughput benchmark reports 100,000 ops for an iteration that handled 9,999, so the published headline figure is about 10x too high and measures the drop path #1027 — The tell-throughput benchmark reports 100,000 ops for an iteration that handled 9,999, so the published headline figure is about 10x too high and measures the drop path (blocker)
Operability (9)
[Docs] All four PromQL queries in the operations runbook name metrics that no code in src/ emits, and seven of the eight real stock metrics are never mentioned there, so every documented diagnostic returns an empty result #992 — All four PromQL queries in the operations runbook name metrics that no code in src/ emits, and seven of the eight real stock metrics are never mentioned there, so every documented diagnostic returns an empty result (blocker)
[Docs] The docs say the cluster shutdown phases wire themselves up automatically while the only three addTask sites in src/ hit service-unbind and actor-system-terminate, so a rolling deploy neither leaves the cluster nor hands off shards #993 — The docs say the cluster shutdown phases wire themselves up automatically while the only three addTask sites in src/ hit service-unbind and actor-system-terminate, so a rolling deploy neither leaves the cluster nor hands off shards (blocker)
[Bug] Every cluster membership transition is logged at debug while the default level is info, so a partition produces no operator-visible record of which peer went unreachable, when, or why #994 — Every cluster membership transition is logged at debug while the default level is info, so a partition produces no operator-visible record of which peer went unreachable, when, or why (high)
[Feature] Log records carry neither a node identity nor the active traceId/spanId that Tracer.ts promises, so a trail cannot be followed across pods or joined to a trace without an external log shipper #995 — Log records carry neither a node identity nor the active traceId/spanId that Tracer.ts promises, so a trail cannot be followed across pods or joined to a trace without an external log shipper (medium)
[Feature] ActorSystem emits no startup record and Config.load neither reports the config file it used nor rejects unknown actor-ts.* keys, so a misconfigured node is byte-identical in its output to a correct one #996 — ActorSystem emits no startup record and Config.load neither reports the config file it used nor rejects unknown actor-ts.* keys, so a misconfigured node is byte-identical in its output to a correct one (medium)
[Bug] The default shutdown-grace-period is 0 ms and /ready never reports draining, so a rolling deploy severs in-flight requests instead of draining them, and the three HTTP backends each read the value differently #997 — The default shutdown-grace-period is 0 ms and /ready never reports draining, so a rolling deploy severs in-flight requests instead of draining them, and the three HTTP backends each read the value differently (medium)
[Bug] actor_message_handler_seconds uses the 5 ms to 10 s default buckets for handlers that run in microseconds, so every observation lands in the first bucket and histogram_quantile returns a constant near 5 ms #998 — actor_message_handler_seconds uses the 5 ms to 10 s default buckets for handlers that run in microseconds, so every observation lands in the first bucket and histogram_quantile returns a constant near 5 ms (medium)
[Bug] actor_restarted_total and actor_message_handler_seconds carry no labels at all, so a scrape cannot say which actor class is crash-looping or which one is slow — the two questions those metrics exist to answer #999 — actor_restarted_total and actor_message_handler_seconds carry no labels at all, so a scrape cannot say which actor class is crash-looping or which one is slow — the two questions those metrics exist to answer (medium)
[Feature] Dead letters are published to an event stream that nothing subscribes to by default, so an undeliverable message produces zero output while two docs pages claim the system logs it #1000 — Dead letters are published to an event stream that nothing subscribes to by default, so an undeliverable message produces zero output while two docs pages claim the system logs it (medium)
Packaging, public API and release engineering (9)
[Docs] 92 documented imports use the subpaths actor-ts/http, /coordination, /serialization, /discovery and /cluster/pubsub which are absent from the exports map, so every HTTP documentation page fails with ERR_PACKAGE_PATH_NOT_EXPORTED #1001 — 92 documented imports use the subpaths actor-ts/http, /coordination, /serialization, /discovery and /cluster/pubsub which are absent from the exports map, so every HTTP documentation page fails with ERR_PACKAGE_PATH_NOT_EXPORTED (blocker)
[Bug] Ten documented public symbols including migrateInMemoryJournal, wrapEventAsEnvelope and Mailbox are re-exported by no entry point, so the wrap-legacy migration page and the mailbox sample cannot compile against the tarball #1002 — Ten documented public symbols including migrateInMemoryJournal, wrapEventAsEnvelope and Mailbox are re-exported by no entry point, so the wrap-legacy migration page and the mailbox sample cannot compile against the tarball (blocker)
[Feature] No file in the repository imports actor-ts by its published name and doc fences are never type-checked, so the exports map has no end-to-end test and publint plus attw stay green while five subpaths are unresolvable #1003 — No file in the repository imports actor-ts by its published name and doc fences are never type-checked, so the exports map has no end-to-end test and publint plus attw stay green while five subpaths are unresolvable (blocker)
[Docs] Two of the three patch releases shipped a change marked BREAKING while the version policy promises patches carry no breaking changes, so a consumer on a caret range auto-installs an API break #1004 — Two of the three patch releases shipped a change marked BREAKING while the version policy promises patches carry no breaking changes, so a consumer on a caret range auto-installs an API break (high)
[Bug] The root barrel star-exports http/index.ts which statically imports fastify, so importing ActorSystem, the testkit or devtools eagerly loads Fastify and 20 transitive packages on an unbundled runtime #1005 — The root barrel star-exports http/index.ts which statically imports fastify, so importing ActorSystem, the testkit or devtools eagerly loads Fastify and 20 transitive packages on an unbundled runtime (medium)
[Bug] The shipped declarations reference NodeJS.Signals, Buffer and node:http while @types/node is only a devDependency, so a consumer with skipLibCheck false gets 35 errors from inside node_modules #1006 — The shipped declarations reference NodeJS.Signals, Buffer and node:http while @types/node is only a devDependency, so a consumer with skipLibCheck false gets 35 errors from inside node_modules (medium)
[Bug] declarationMap and sourceMap are on while files ships only dist, so all 1100 map files in the tarball point at a ../src that is not published and go-to-definition dead-ends for every consumer #1007 — declarationMap and sourceMap are on while files ships only dist, so all 1100 map files in the tarball point at a ../src that is not published and go-to-definition dead-ends for every consumer (low)
[Feature] The build tsconfig resolves modules as Bundler rather than NodeNext, so nothing validates the emitted specifiers under the resolver the package actually publishes to #1008 — The build tsconfig resolves modules as Bundler rather than NodeNext, so nothing validates the emitted specifiers under the resolver the package actually publishes to (low)
[Feature] No CODEOWNERS or CONTRIBUTING.md and GitHub private vulnerability reporting is disabled, so the security template points at a SECURITY.md that never existed and a finder has no private channel #1009 — No CODEOWNERS or CONTRIBUTING.md and GitHub private vulnerability reporting is disabled, so the security template points at a SECURITY.md that never existed and a finder has no private channel (medium)
Verification rigor (10)
[Test] Bun reports the All-files coverage row as an unweighted mean over files and bunfig.toml never sets coverageSkipTestFiles, so 319 test files sit in the badge at 100 percent and the real src figure is about 5 points lower #1016 — Bun reports the All-files coverage row as an unweighted mean over files and bunfig.toml never sets coverageSkipTestFiles, so 319 test files sit in the badge at 100 percent and the real src figure is about 5 points lower (high)
[Test] The persistence replay-mutation fuzzer imports nothing from src and proves theorems about Array.prototype.reduce, so nine badge-counted cases execute zero lines of the 19,814-line persistence layer #1017 — The persistence replay-mutation fuzzer imports nothing from src and proves theorems about Array.prototype.reduce, so nine badge-counted cases execute zero lines of the 19,814-line persistence layer (high)
[Test] main requires only the test context with strict false and develop has no branch protection at all, so integration, build, multi-runtime, package-health and examples gate nothing on the branch where work lands #1018 — main requires only the test context with strict false and develop has no branch protection at all, so integration, build, multi-runtime, package-health and examples gate nothing on the branch where work lands (high)
[Test] AllForOneStrategy is never spawned and the withinTimeRangeMs restart window is never crossed, so both branches can be neutralised in ActorCell with every supervision-related test file still green #1019 — AllForOneStrategy is never spawned and the withinTimeRangeMs restart window is never crossed, so both branches can be neutralised in ActorCell with every supervision-related test file still green (high)
[Test] The #310 default-mailbox guard checks droppedCount === 0 on a mailbox that received nothing, so changing the default to capacity 3 with drop-new leaves MailboxVariants.test.ts fully green #1020 — The Default mailbox: switch from unbounded to bounded (10k, drop-head) #310 default-mailbox guard checks droppedCount === 0 on a mailbox that received nothing, so changing the default to capacity 3 with drop-new leaves MailboxVariants.test.ts fully green (medium)
[Test] COV_COLOR is hardcoded and the test-badge denominator is pass plus fail, so the coverage badge can never go red and a quarantined run is indistinguishable from a full one #1021 — COV_COLOR is hardcoded and the test-badge denominator is pass plus fail, so the coverage badge can never go red and a quarantined run is indistinguishable from a full one (medium)
[Test] Five test cases assert expect(true).toBe(true) in place of the behaviour their title names and CircuitBreaker.test.ts discards the thrown error at eight sites, so a wrong error type or a silent no-op passes #1022 — Five test cases assert expect(true).toBe(true) in place of the behaviour their title names and CircuitBreaker.test.ts discards the thrown error at eight sites, so a wrong error type or a silent no-op passes (medium)
[Feature] MultiNodeTransport offers only a binary partition and delivers via queueMicrotask, so gossip convergence under packet loss, reordering, duplication or latency is structurally untestable #1023 — MultiNodeTransport offers only a binary partition and delivers via queueMicrotask, so gossip convergence under packet loss, reordering, duplication or latency is structurally untestable (high)
[Feature] No reusable failing-journal or failing-snapshot-store double exists, so a throwing append inside persistAll, a snapshot write that fails after a successful append, and recovery over a gapped stream are all untested #1024 — No reusable failing-journal or failing-snapshot-store double exists, so a throwing append inside persistAll, a snapshot write that fails after a successful append, and recovery over a gapped stream are all untested (high)
[Docs] Both ManualScheduler samples call scheduler.advance before the actor has processed the preceding tell, so the flagship determinism examples fail with a probe timeout and the microtask explanation names the wrong queue #1025 — Both ManualScheduler samples call scheduler.advance before the actor has processed the preceding tell, so the flagship determinism examples fail with a probe timeout and the microtask explanation names the wrong queue (medium)
Docs-vs-code drift (6)
[Bug] EventStream.publish evaluates event instanceof channel outside the try block and subscribe validates nothing, so one bad channel makes every spawn, every dead letter and every actor stop throw TypeError #1010 — EventStream.publish evaluates event instanceof channel outside the try block and subscribe validates nothing, so one bad channel makes every spawn, every dead letter and every actor stop throw TypeError (high)
[Bug] expectMessage routes through receiveOne into _next which discards the expectation, so the most common testkit failure reports only a bare timeout with no expected value or probe identity #1011 — expectMessage routes through receiveOne into _next which discards the expectation, so the most common testkit failure reports only a bare timeout with no expected value or probe identity (low)
[Docs] The Kubernetes recipe leaves 20 s of shutdown budget while twelve canonical phases run sequentially at 5000 ms each, so a full pipeline needs 60 s and the page asks only that grace exceed the longest phase #1012 — The Kubernetes recipe leaves 20 s of shutdown budget while twelve canonical phases run sequentially at 5000 ms each, so a full pipeline needs 60 s and the page asks only that grace exceed the longest phase (medium)
[Docs] KubernetesApiSeedProvider reads the endpoints resource while the manifest grants RBAC only on pods and never sets K8S_SERVICE_NAME, so the flagship deployment recipe 403s and the cluster never forms #1013 — KubernetesApiSeedProvider reads the endpoints resource while the manifest grants RBAC only on pods and never sets K8S_SERVICE_NAME, so the flagship deployment recipe 403s and the cluster never forms (medium)
[Bug] SchemaRegistration.upcastFromPrev is typed to take unknown, so the upcaster form printed in the JSDoc, the docs page and the bundled example fails to compile under strictFunctionTypes #1014 — SchemaRegistration.upcastFromPrev is typed to take unknown, so the upcaster form printed in the JSDoc, the docs page and the bundled example fails to compile under strictFunctionTypes (medium)
[Bug] examples/ carries eight type errors that are not missing-module noise, including an Option unwrapped with a non-null assertion, so the bundled samples do not compile and nothing in CI notices #1015 — examples/ carries eight type errors that are not missing-module noise, including an Option unwrapped with a non-null assertion, so the bundled samples do not compile and nothing in CI notices (low)
Reviewed and not pursued
Seven findings did not become issues. Each was folded into an existing one, with the new evidence added there as a comment:
CircuitBreaker half-open never re-arms → [Feature] CircuitBreaker: single-probe serialization in half-open #457 . The same defect this issue already describes, and the same fix (serialize half-open to a single probe). Added there: with no default callTimeoutMs, a hung upstream leaves the breaker permanently permissive rather than merely admitting a burst.
The cluster hello identity is self-declared → [Security] The cluster hello identity is not bound to the TLS peer certificate, so mTLS admits a node but never verifies which node it is #912 . Already stated there. Added: the plaintext (non-TLS) case, and that remoteAddress is exposed by all three TCP backends and read nowhere in src/cluster/.
Sharding wire messages are unvalidated and self-attributed → [Security] ShardCoordinator derives region identity and shard ownership from node/region/hostedShards in the payload rather than the authenticated envelope sender, letting one peer seize every shard of a type or evict another node's region #712 (coordinator half) and [Security] ShardRegion.onHandOff destroys a shard's entities on the word of any peer #584 (region half). The finding's citation of WireValidation.ts:113 was wrong and is corrected in the comment: sharding rides the envelope frame kind, which is validated; the unchecked surface is the envelope body.
replayState has no contiguity check → [Security] PersistentActor accepts out-of-order events from snapshot store #122 , whose body already carries the gap-detection branch verbatim. Added: the four framework-internal crash windows that assertion would convert from silent wrong state into a loud recovery failure.
Projections read the whole journal on 6 of 10 backends → [Feature] PersistenceQuery for the remaining backends (MsSQL, libSQL, D1, DynamoDB, object storage) + shared query base #532 . Added: what the current fallback costs while the gap is open — O(journal) in time and memory, once per pollIntervalMs, with no LIMIT and no startup warning.
journal.append has no timeout → [Feature] Persistence behavior keys — breakers, recovery limits, bounded stash #874 , which proposes exactly this key. Added: the failure mode is not "slow persistence" but a supervision restart 1024 stashed messages away from its cause.
Per-message instrumentation is not zero-cost when disabled → [Feature] Reduce per-message allocations on the ActorCell hot path #411 . Added: three specific allocations not previously enumerated, including an Object.keys() call made purely to test emptiness against a frozen sentinel.
Nine further findings were narrowed rather than dropped, and their issues say so rather than overstating the claim. The notable ones:
[Bug] The documented TLS recipe parses the transport address into a systemName that never matches Cluster.selfAddress, so a cluster enabled the only supported way never converges #941 — "TLS cannot be enabled through the supported API" is already [Security] remote.tls.enabled HOCON key is documented but dead — nothing in src/ reads it, so operators believe TLS is on when it is not #591 ; what is new and reproduced is that the only documented workaround produces a systemName that never matches selfAddress, so a cluster wired that way does not converge.
[Feature] Only 6 of 35 plain-HTTP test files run across all three backends and there is no shared contract suite, so backend divergences ship until a user finds them #990 — the premise "plain HTTP has three independent test files" was wrong: six files already run across all three backends via describe.each. The real gap is that 29 of 35 do not, and there is no named contract module.
[Test] AllForOneStrategy is never spawned and the withinTimeRangeMs restart window is never crossed, so both branches can be neutralised in ActorCell with every supervision-related test file still green #1019 — a partial refutation . The named mutation is killed, by PersistentActorRecoveryFailure.test.ts rather than by anything in Supervision.test.ts. Two neighbouring branches survive mutation across all nine supervision-related files, and that is what the issue argues.
[Bug] persistAll is documented atomic but D1, MongoDB and Cassandra have no multi-event transaction and no capability flag exposes it, so a mid-batch failure commits a prefix the caller was told had failed #959 — the original claim had the state inversion backwards. _state/_seq are updated after append returns, so a partial batch leaves the actor's memory behind its own journal, which is why the restart is the moment divergence becomes real.
[Feature] The build tsconfig resolves modules as Bundler rather than NodeNext, so nothing validates the emitted specifiers under the resolver the package actually publishes to #1008 — the config fact holds but the claimed consequence was refuted by experiment: tsc --moduleResolution nodenext compiles all 550 source files cleanly. Filed as hardening with no latent defect behind it.
Two claims came back stronger than reported: the terminating-actor busy-spin measured 123 683 dispatcher executions in 300 ms rather than "a hot loop" (#915 ), and Bun's All files coverage row was shown to be an unweighted mean over files, not a weighted line ratio (#1016 ).
Working order
Sequenced so the cheapest total-outage fixes land first.
Four verified one-line blockers. [Bug] ensureCoordinator runs before numShardsByType is populated, so every ShardCoordinator is built with 64 shards whatever you configure and entities above that id never get a home #1026 (ensureCoordinator reading options.numShards before the map is populated — every coordinator is built with 64 shards whatever you configure), [Bug] The failure detector deletes a peer at downAfterMs regardless of the configured DowningProvider, so every split-brain resolver is bypassed under a comment claiming it is only a fallback #929 (the failure-detector delete behind an if (!this.downing)), [Security] Lease.checkAlive() returns a cached boolean instead of comparing against expiresAt, and has no callers, so two nodes can both believe they hold the lease after an event-loop stall #937 (checkAlive() compared against expiresAt instead of returning a cached boolean), [Bug] An actor that throws in onReceive is restarted with no log output at any level, so a crash-looping actor is invisible in production #916 (failToParent logging the cause — an actor that crash-loops currently produces zero log output at any level). Four small changes, four total-outage classes.
[Bug] ask() rebuilds the message as an object literal, so Map, Set, Date, typed arrays and every class instance arrive stripped of their contents #925 — ask(). It rebuilds the message as an object literal, so Map, Set, Date, typed arrays and every class instance arrive stripped. Silent data loss on the most-used API in the documentation.
The wire-identity class as one fix. [Security] Forged heartbeat.from keeps a dead node "healthy" forever (blocks singleton/shard failover) and makes the node dial an attacker-chosen host:port #572 , [Security] Receptionist gossip trusts the payload's self-declared from instead of the connection-authenticated sender, letting any peer poison cluster-wide service discovery #574 , [Security] DistributedPubSubMediator.handleGossip trusts message.from instead of the socket peer, letting one peer wipe another node's subscriptions #582 , [Security] ShardCoordinator derives region identity and shard ownership from node/region/hostedShards in the payload rather than the authenticated envelope sender, letting one peer seize every shard of a type or evict another node's region #712 , [Security] DistributedData counts quorum acks and read-responses by the payload's from instead of the authenticated peer, letting one member forge a full quorum and inject arbitrary CRDT state #719 , [Security] onReadRequest/onWriteRequest reply to the payload's from, so any node can be made to dial an attacker-named host and buffer full CRDT snapshots in a Connection.pending queue that is never drained, never capped and never reclaimed #723 and [Security] decodeSingleRef builds a dialable NodeAddress from unvalidated wire fields, so a ref embedded in any message body makes the receiving node connect to an attacker-chosen host #936 are the same defect at seven call sites: a wire-supplied address trusted instead of the authenticated peer. One helper that resolves any such address against the connection retires the class; [Security] The cluster hello identity is not bound to the TLS peer certificate, so mTLS admits a node but never verifies which node it is #912 is the foundation it rests on.
The three inverted delivery guarantees. [Bug] KafkaActor resolves its eachMessage promise before the handler runs while autoCommit is on, so the documented at-least-once default is at-most-once, and a partial withConsumer erases a HOCON manual commit mode #975 (Kafka), [Bug] AmqpActor acks each delivery before handing it to the target actor and swallows a failing ack, so the default configuration loses messages on restart and redelivers duplicates when the ack fails #976 (AMQP), [Bug] MQTT QoS 1 and 2 are structurally unreachable because MqttActor never enables manualAcks, so mqtt.js PUBACKs while the message is still in the mailbox and MqttMessage.qos reports a guarantee the actor cannot provide #977 (MQTT) all acknowledge before the handler runs, so the documented at-least-once default is at-most-once on every broker that claims it.
Two CI gates that prevent recurrence. [Feature] No file in the repository imports actor-ts by its published name and doc fences are never type-checked, so the exports map has no end-to-end test and publint plus attw stay green while five subpaths are unresolvable #1003 (a consumer smoke gate — npm pack, install, resolve every documented import) would have caught [Docs] 92 documented imports use the subpaths actor-ts/http, /coordination, /serialization, /discovery and /cluster/pubsub which are absent from the exports map, so every HTTP documentation page fails with ERR_PACKAGE_PATH_NOT_EXPORTED #1001 's 92 broken doc imports on the day they were written; [Test] Bun reports the All-files coverage row as an unweighted mean over files and bunfig.toml never sets coverageSkipTestFiles, so 319 test files sit in the badge at 100 percent and the real src figure is about 5 points lower #1016 (coverageSkipTestFiles) is why the coverage number is not what it appears to be.
[Bug] Gossip and heartbeats target only reachable members, so a symmetric partition never heals and both halves run as independent clusters until a process restart #930 — partitions never heal. Gossip and heartbeats target only reachable members, so a symmetric partition forks permanently. Every split-brain resolver above is only meaningful once the losing side can rejoin.
Then the remaining waves by severity. The production-goal label is the working set; severity: high plus priority: high within it is the head.
A ten-lens production-readiness review of
v0.13.0, run against the code rather than against the tracker, and re-verified before filing.121 findings carried into verification → 7 folded into existing issues → 114 filed (29 blocker, 38 high, 39 medium, 8 low). 67 were reproduced by running code; 39 were confirmed by reading the cited lines where a live cluster or a real broker would have been required. A further 172 pre-existing issues were labelled
production-goalrather than re-filed.The question the review asked was narrow: ignoring the README disclaimer, what is the actual technical state, and what stands between this codebase and running it for real?
Why this batch is verified rather than reported
The review's most consequential finding is not any single defect — it is a pattern that makes ordinary code review unreliable here. Comments and JSDoc in this codebase systematically assert invariants the code does not implement. Not as sloppiness; as a consistent shape:
Cluster.ts:804— "FD-driven downing is the advisory fallback when noDowningProvideris configured"if (!this.downing)(#929)KeepOldest.ts:52-57— "the conservative default is still: keep the side with the oldest"downIfAloneis dead (#932)ActorCell.ts:191— "Unbounded … is still available viawithMailbox(() => new Mailbox())"Mailboxis exported from no entry point and there is no subpath (#661)PersistentActor.ts:261— "Persist several events atomically"ReplicatedEventSourcedActor.ts:486— "read highestSeq + append in one mailbox tick"void this._appendOne(...)(#958)LWWRegister.ts:60— "break by replica id so every node converges to the same winner regardless of arrival order"thiswhen the replica also ties, so merge is order-dependent (#950)MailboxVariants.test.ts:249— "assert behaviorally: fill past capacity, observe drop-head"expect(droppedCount).toBe(0)on a mailbox that received nothing (#1020)BasicAuth.ts:48— "a miss still burns the same comparisons"&&short-circuits, so the password compare runs only for a known user (#968)ShardedDaemonProcess— "each daemon … its own shard via a 1-to-1 allocation"Because of that, every finding was re-checked against the source before it became an issue, preferentially by execution rather than by reading. Each child issue carries a
Verification statussection stating which of the two it got. That discipline changed the outcome in both directions — see Reviewed and not pursued below.Ground rules applied to every child
CONFIRMEDmeans reproduced by running it;CONFIRMED-BY-READmeans the mechanism is unambiguous at the cited lines but not runtime-observable here. Both are stated explicitly; neither is implied.path:from-toand quotes the current tree. Where a comment or a doc page contradicts the code, both are quoted.production-goalis a gate, not a batch marker. It sits on 287 of the 668 open issues, including ones filed months before this review. Filtering on it answers "what is still between us and production", not "what did this review find".Children
Core runtime, typed API and patterns (15)
onReceiveis restarted with no log output at any level, so a crash-looping actor is invisible in production #916 — An actor that throws inonReceiveis restarted with no log output at any level, so a crash-looping actor is invisible in production (blocker)context.watch()is a silent no-op for a remote ref, so cross-node death watch never deliversTerminateddespite the JSDoc promising it unconditionally #918 —context.watch()is a silent no-op for a remote ref, so cross-node death watch never deliversTerminateddespite the JSDoc promising it unconditionally (high)tell()throwsMailboxFullErrorinto the sender under therejectpolicy, so an actor is failed and restarted because the actor it sent to was slow #919 —tell()throwsMailboxFullErrorinto the sender under therejectpolicy, so an actor is failed and restarted because the actor it sent to was slow (high)terminate-actor-systemand has no overall deadline, so a slow drain is abandoned and then exited over #920 — CoordinatedShutdown applies the 5 s default phase timeout toterminate-actor-systemand has no overall deadline, so a slow drain is abandoned and then exited over (high)startTimerWithFixedDelayis implemented withscheduleAtFixedRate, so a slow handler accumulates ticks — the exact behaviour the fixed-delay name rules out #921 —startTimerWithFixedDelayis implemented withscheduleAtFixedRate, so a slow handler accumulates ticks — the exact behaviour the fixed-delay name rules out (medium)ReceiveTimeoutfires 1 ms after a long handler completes, reporting an actor as idle at the moment it finished working #922 —ReceiveTimeoutfires 1 ms after a long handler completes, reporting an actor as idle at the moment it finished working (medium)all-for-onerestart resumes a sibling that is already terminating, leaving a running actor whose child set can never finalise #923 — Anall-for-onerestart resumes a sibling that is already terminating, leaving a running actor whose child set can never finalise (medium)ActorOptionsfield and no config key #924 — The per-actor stash capacity is a hardcoded 1024 with noActorOptionsfield and no config key (low)ask()rebuilds the message as an object literal, soMap,Set,Date, typed arrays and every class instance arrive stripped of their contents #925 —ask()rebuilds the message as an object literal, soMap,Set,Date, typed arrays and every class instance arrive stripped of their contents (blocker)BackoffSupervisorcannot survive its own restart — it respawns into the name of a child that is still alive, or orphans it #926 —BackoffSupervisorcannot survive its own restart — it respawns into the name of a child that is still alive, or orphans it (high)after(...).cancel()produces an unhandled rejection from the idiom its own JSDoc teaches #927 —after(...).cancel()produces an unhandled rejection from the idiom its own JSDoc teaches (medium)activeSuperviseandsignalHandlerare one-way latches, so a signal handler installed once keeps intercepting for every later behavior #928 — TypedactiveSuperviseandsignalHandlerare one-way latches, so a signal handler installed once keeps intercepting for every later behavior (medium)Cluster membership, transport, downing and leases (19)
downAfterMsregardless of the configuredDowningProvider, so every split-brain resolver is bypassed under a comment claiming it is only a fallback #929 — The failure detector deletes a peer atdownAfterMsregardless of the configuredDowningProvider, so every split-brain resolver is bypassed under a comment claiming it is only a fallback (blocker)TcpTransport.senddiscards the result ofsocket.write(), so on Bun a backpressured socket silently truncates the frame stream and on Node it buffers without bound #931 —TcpTransport.senddiscards the result ofsocket.write(), so on Bun a backpressured socket silently truncates the frame stream and on Node it buffers without bound (blocker)KeepOldest.downIfAloneis a dead branch — both arms return the same set — so the crash of the lowest-addressed node makes every survivor down itself #932 —KeepOldest.downIfAloneis a dead branch — both arms return the same set — so the crash of the lowest-addressed node makes every survivor down itself (blocker)StaticQuorumhas no "nothing is unreachable" early return, so a cluster merely smaller thanquorumSizedowns itself at bootstrap #933 —StaticQuorumhas no "nothing is unreachable" early return, so a cluster merely smaller thanquorumSizedowns itself at bootstrap (blocker)lastDownedViewis never cleared, so a partition shape that recurs at the same addresses is silently never resolved a second time #934 —lastDownedViewis never cleared, so a partition shape that recurs at the same addresses is silently never resolved a second time (high)mergeMemberhas no tie-break for equal versions, so two observers that assign the same version to different statuses diverge permanently and the member never converges #935 —mergeMemberhas no tie-break for equal versions, so two observers that assign the same version to different statuses diverge permanently and the member never converges (high)decodeSingleRefbuilds a dialableNodeAddressfrom unvalidated wire fields, so a ref embedded in any message body makes the receiving node connect to an attacker-chosen host #936 —decodeSingleRefbuilds a dialableNodeAddressfrom unvalidated wire fields, so a ref embedded in any message body makes the receiving node connect to an attacker-chosen host (high)Lease.checkAlive()returns a cached boolean instead of comparing againstexpiresAt, and has no callers, so two nodes can both believe they hold the lease after an event-loop stall #937 —Lease.checkAlive()returns a cached boolean instead of comparing againstexpiresAt, and has no callers, so two nodes can both believe they hold the lease after an event-loop stall (blocker)ClusterClient.connect()clears the hello timeout inonErrorwithout settling once the socket has opened, so a post-handshake reset hangs the promise forever andensureConnectedmemoises it for every later send #938 —ClusterClient.connect()clears the hello timeout inonErrorwithout settling once the socket has opened, so a post-handshake reset hangs the promise forever andensureConnectedmemoises it for every later send (high)onGossiptrusts the payload'sfromfor liveness and inserts an unknown sender withoutmaySpeakFor, so any peer can hold a crashed node's failure detector open and one frame naming a phantom low address makesisLeader()false #939 —onGossiptrusts the payload'sfromfor liveness and inserts an unknown sender withoutmaySpeakFor, so any peer can hold a crashed node's failure detector open and one frame naming a phantom low address makesisLeader()false (high)NodeAddresscarries no incarnation, soSelfRemovedis unreachable on a realClusterand the FD's delete-not-tombstone lets the address walk back in with its old version and roles #940 — A downed node cannot be told andNodeAddresscarries no incarnation, soSelfRemovedis unreachable on a realClusterand the FD's delete-not-tombstone lets the address walk back in with its old version and roles (high)systemNamethat never matchesCluster.selfAddress, so a cluster enabled the only supported way never converges #941 — The documented TLS recipe parses the transport address into asystemNamethat never matchesCluster.selfAddress, so a cluster enabled the only supported way never converges (high)ClusterRouterrebuilds only onMemberUp/MemberRemovedwhileupMembers()drops an unreachable member at once, so 1/N of routed traffic is blackholed for the wholedownAfterMswindow with no dead letter #942 —ClusterRouterrebuilds only onMemberUp/MemberRemovedwhileupMembers()drops an unreachable member at once, so 1/N of routed traffic is blackholed for the wholedownAfterMswindow with no dead letter (high)awaitSelfUpresolves through the samefinish()on timeout andresolveSeedsswallows discovery failures into an empty list, soCluster.bootstrap()reports success for a node that never joined and self-elects a one-node cluster #943 —awaitSelfUpresolves through the samefinish()on timeout andresolveSeedsswallows discovery failures into an empty list, soCluster.bootstrap()reports success for a node that never joined and self-elects a one-node cluster (high)bootstrapClusterdefaults the advertised host to0.0.0.0, so every node gossips an identicalselfAddressand each treats the join announcements of the others as claims about itself #944 —bootstrapClusterdefaults the advertised host to0.0.0.0, so every node gossips an identicalselfAddressand each treats the join announcements of the others as claims about itself (high)MessageChannelTransportcalls the wire handler with novalidateWireFrameand no try/catch, so one malformed frame from a worker throws out of theMessagePortcallback and terminates the host process #945 —MessageChannelTransportcalls the wire handler with novalidateWireFrameand no try/catch, so one malformed frame from a worker throws out of theMessagePortcallback and terminates the host process (high)encodeRefs/decodeRefsnever remove from theirWeakSet, so it is visited-detection rather than cycle-detection and every repeated object in a cross-node message body arrives asnull#946 —encodeRefs/decodeRefsnever remove from theirWeakSet, so it is visited-detection rather than cycle-detection and every repeated object in a cross-node message body arrives asnull(medium)FailureDetector.samplesgains an entry for every address that ever sends a frame whileforget()is only reachable from member-removal paths, so a non-member address is never reclaimed #947 —FailureDetector.samplesgains an entry for every address that ever sends a frame whileforget()is only reachable from member-removal paths, so a non-member address is never reclaimed (medium)Sharding, singleton, reliable delivery and CRDTs (10)
ShardCoordinator.onRegisteroverwritesshardHomefor every claimed shard with no conflict check, so a region re-registering with stalelocalShardstakes ownership back from the live owner and both nodes run the same entities #948 —ShardCoordinator.onRegisteroverwritesshardHomefor every claimed shard with no conflict check, so a region re-registering with stalelocalShardstakes ownership back from the live owner and both nodes run the same entities (blocker)LeaderChangedwhile the previous host stops its child with aPoisonPillqueued behind that child's whole mailbox, so a routine scale-up runs two instances #949 — The cluster singleton has no handover protocol — the new host spawns onLeaderChangedwhile the previous host stops its child with aPoisonPillqueued behind that child's whole mailbox, so a routine scale-up runs two instances (blocker)LWWRegister.mergereturnsthiswhen timestamp and replica id both tie, so two writes from one replica in the same millisecond make merge order-dependent and the coordinator-state snapshot diverges between nodes #950 —LWWRegister.mergereturnsthiswhen timestamp and replica id both tie, so two writes from one replica in the same millisecond make merge order-dependent and the coordinator-state snapshot diverges between nodes (high)ShardedDaemonProcesssetsnumShards = numDaemonsbut the region hashes the entity id, so 100 daemons land on 58 shards and up to 4 permanently co-locate instead of the documented 1-to-1 allocation #951 —ShardedDaemonProcesssetsnumShards = numDaemonsbut the region hashes the entity id, so 100 daemons land on 58 shards and up to 4 permanently co-locate instead of the documented 1-to-1 allocation (high)onLeaderChangedfiresvoid this.loadCoordinatorState()outside the mailbox, so a demotion during the load repopulates the just-clearedregions/shardHomefrom a stale snapshot #952 —onLeaderChangedfiresvoid this.loadCoordinatorState()outside the mailbox, so a demotion during the load repopulates the just-clearedregions/shardHomefrom a stale snapshot (high)ShardRegion.onShardHomedrops a lost shard fromlocalShardswithout stopping itsShardactor or entities, so the old owner keeps running them alongside the new owner andsweepEmptyShardscan never reclaim the orphan #953 —ShardRegion.onShardHomedrops a lost shard fromlocalShardswithout stopping itsShardactor or entities, so the old owner keeps running them alongside the new owner andsweepEmptyShardscan never reclaim the orphan (medium)Shard.onEntityTerminatedemitsEntityStoppedfor a crash-driven termination too, so a remembered entity that exhausts the shard restart budget is deleted from the durable registry and never revived after a node failure #954 —Shard.onEntityTerminatedemitsEntityStoppedfor a crash-driven termination too, so a remembered entity that exhausts the shard restart budget is deleted from the durable registry and never revived after a node failure (medium)DistributedDatanever prunes departed replicas —onMemberRemovedis a literal no-op — so replica slots grow per address forever and a replica restarting on its old address loses increments to merge-max #955 —DistributedDatanever prunes departed replicas —onMemberRemovedis a literal no-op — so replica slots grow per address forever and a replica restarting on its old address loses increments to merge-max (medium)applyMergedserialises both the previous and the merged value for every key of every gossip frame purely to detect a no-op merge, so each received tick allocates two full serialisations per key although every CRDT could implementequals#956 —applyMergedserialises both the previous and the merged value for every key of every gossip frame purely to detect a no-op merge, so each received tick allocates two full serialisations per key although every CRDT could implementequals(medium)ensureCoordinatorruns beforenumShardsByTypeis populated, so every ShardCoordinator is built with 64 shards whatever you configure and entities above that id never get a home #1026 —ensureCoordinatorruns beforenumShardsByTypeis populated, so every ShardCoordinator is built with 64 shards whatever you configure and entities above that id never get a home (blocker)Persistence (7)
Offsetis ordered by it, so the events of a slow-committing writer land behind the projection cursor and are dropped permanently #957 — Journal appends stamp the event timestamp before the commit while the by-tagOffsetis ordered by it, so the events of a slow-committing writer land behind the projection cursor and are dropped permanently (blocker)ReplicatedEventSourcedActor._absorbstarts_appendOnewithout awaiting it, so back-to-back remote events race onehighestSeqand every loser is lost from the local journal behind alog.warn#958 —ReplicatedEventSourcedActor._absorbstarts_appendOnewithout awaiting it, so back-to-back remote events race onehighestSeqand every loser is lost from the local journal behind alog.warn(blocker)persistAllis documented atomic but D1, MongoDB and Cassandra have no multi-event transaction and no capability flag exposes it, so a mid-batch failure commits a prefix the caller was told had failed #959 —persistAllis documented atomic but D1, MongoDB and Cassandra have no multi-event transaction and no capability flag exposes it, so a mid-batch failure commits a prefix the caller was told had failed (high)PersistenceOptionsand never read it, so an actorencryption()setting is a silent no-op and state is written to disk in plaintext #960 — Ten of eleven snapshot stores and nine of ten durable-state stores bindPersistenceOptionsand never read it, so an actorencryption()setting is a silent no-op and state is written to disk in plaintext (high)CassandraJournal.appendsplits its unlogged batch on the events partition only, so the tag-keyed index rows ride along in a multi-partition batch and a partial apply hides an event from every tag projection #961 —CassandraJournal.appendsplits its unlogged batch on the events partition only, so the tag-keyed index rows ride along in a multi-partition batch and a partial apply hides an event from every tag projection (medium)ObjectStorageDurableStateStore.etagCacheis an unboundedMapkeyed bypersistenceIdevicted only on delete or close, so a sharded deployment leaks one entry per entity ever loaded #962 —ObjectStorageDurableStateStore.etagCacheis an unboundedMapkeyed bypersistenceIdevicted only on delete or close, so a sharded deployment leaks one entry per entity ever loaded (medium)DurableStateActor.persistnever refreshes_recordafter aDurableStateConcurrencyError, so every later persist replays the same stale revision and the actor is wedged for its lifetime #963 —DurableStateActor.persistnever refreshes_recordafter aDurableStateConcurrencyError, so every later persist replays the same stale revision and the actor is wedged for its lifetime (medium)Security and serialization (8)
dispatchEnveloperesolves anytopath from the root cell, so any peer that completeshellocan address/systemframework actors directly #964 — The cluster wire protocol carries no credential anddispatchEnveloperesolves anytopath from the root cell, so any peer that completeshellocan address/systemframework actors directly (blocker)decodeBodynever checks that the caller expected encryption and there is norequireEncryptionoption, so object-store write access is enough to replace an encrypted body with a plaintext one the actor recovers into #965 —decodeBodynever checks that the caller expected encryption and there is norequireEncryptionoption, so object-store write access is enough to replace an encrypted body with a plaintext one the actor recovers into (blocker)parseCidraccepts an empty prefix becauseNumbercoerces it to 0, so a malformed CIDR such as10.0.0.0/turnsIpAllowlistinto an allow-all gate in front of/cluster/downand/metrics#966 —parseCidraccepts an empty prefix becauseNumbercoerces it to 0, so a malformed CIDR such as10.0.0.0/turnsIpAllowlistinto an allow-all gate in front of/cluster/downand/metrics(high)entity()lets a JSON body pick a value runtime class through theJsonTreetags, so a handler receives an attacker-authoredRegExp, abigintthat passesx > 0but breaksJSON.stringify, or anundefinedno parse can produce #967 —entity()lets a JSON body pick a value runtime class through theJsonTreetags, so a handler receives an attacker-authoredRegExp, abigintthat passesx > 0but breaksJSON.stringify, or anundefinedno parse can produce (medium)BasicAuthshort-circuits its credential check with&&, so the password comparison runs only for an existing username and the number oftimingSafeEqualcalls becomes a username-existence oracle #968 —BasicAuthshort-circuits its credential check with&&, so the password comparison runs only for an existing username and the number oftimingSafeEqualcalls becomes a username-existence oracle (low)readFileByteson the whole file and answer with asubarrayview, soRange: bytes=0-0buffers and retains up to the 50 MiBmaxFileSizeper in-flight request #969 — Static-file Range requests callreadFileByteson the whole file and answer with asubarrayview, soRange: bytes=0-0buffers and retains up to the 50 MiBmaxFileSizeper in-flight request (medium)CborEncoderaccumulates output with oneArray.pushper byte, so a 10 MiBapplication/cborresponse transiently costs a 10-million-element JS array plus theUint8Arraycopy #970 —CborEncoderaccumulates output with oneArray.pushper byte, so a 10 MiBapplication/cborresponse transiently costs a 10-million-element JS array plus theUint8Arraycopy (low)managementRoutesmounts the three cluster-topology endpoints with no auth and no enable flag, so internal topology is readable by default while every mutating endpoint is opt-in #971 —managementRoutesmounts the three cluster-topology endpoints with no auth and no enable flag, so internal topology is readable by default while every mutating endpoint is opt-in (medium)The I/O edge — HTTP, WebSocket, brokers (17)
KafkaActorresolves itseachMessagepromise before the handler runs whileautoCommitis on, so the documented at-least-once default is at-most-once, and a partialwithConsumererases a HOCON manual commit mode #975 —KafkaActorresolves itseachMessagepromise before the handler runs whileautoCommitis on, so the documented at-least-once default is at-most-once, and a partialwithConsumererases a HOCON manual commit mode (blocker)AmqpActoracks each delivery before handing it to the target actor and swallows a failing ack, so the default configuration loses messages on restart and redelivers duplicates when the ack fails #976 —AmqpActoracks each delivery before handing it to the target actor and swallows a failing ack, so the default configuration loses messages on restart and redelivers duplicates when the ack fails (blocker)MqttActornever enablesmanualAcks, so mqtt.js PUBACKs while the message is still in the mailbox andMqttMessage.qosreports a guarantee the actor cannot provide #977 — MQTT QoS 1 and 2 are structurally unreachable becauseMqttActornever enablesmanualAcks, so mqtt.js PUBACKs while the message is still in the mailbox andMqttMessage.qosreports a guarantee the actor cannot provide (blocker)RedisStreamsActorattaches noerrorlistener to either ioredis client, so the first connection blip raises an unhandlederrorevent and kills the process #978 —RedisStreamsActorattaches noerrorlistener to either ioredis client, so the first connection blip raises an unhandlederrorevent and kills the process (blocker)ExpressBackendpipes aReadableStreambody with no error handler, so a stream that fails mid-response exits the Node process and hangs the request forever on Bun #979 —ExpressBackendpipes aReadableStreambody with no error handler, so a stream that fails mid-response exits the Node process and hangs the request forever on Bun (blocker)cached()stores and replays response headers verbatim includingSet-Cookieand returns before the handler, so one caller session and body are served to every later caller forttlMs#980 —cached()stores and replays response headers verbatim includingSet-Cookieand returns before the handler, so one caller session and body are served to every later caller forttlMs(blocker)AmqpActorawaits adrainevent that a closing amqplib channel never emits, so one backpressure episode wedges the outbound path and every later publish is FIFO-evicted from the buffer #981 —AmqpActorawaits adrainevent that a closing amqplib channel never emits, so one backpressure episode wedges the outbound path and every later publish is FIFO-evicted from the buffer (high)RedisStreamsActorguards its consume loop with a boolean re-tested only after the await, so a reconnect landing in the error backoff leaves two loops reading under one consumer name #982 —RedisStreamsActorguards its consume loop with a boolean re-tested only after the await, so a reconnect landing in the error backoff leaves two loops reading under one consumer name (high)rateLimitfails open on a barecatchwith no log, metric or option, so a cache outage silently disables the limiter for every request with nothing to alert on #983 —rateLimitfails open on a barecatchwith no log, metric or option, so a cache outage silently disables the limiter for every request with nothing to alert on (high)idempotent()claims the key with the response TTL, deletes the claim on a throw and caches every status, so a crash wedges the key for 24 h, a partially applied handler is re-executed and a 503 is pinned #984 —idempotent()claims the key with the response TTL, deletes the claim on a throw and caches every status, so a crash wedges the key for 24 h, a partially applied handler is re-executed and a 503 is pinned (high)WebsocketConnection.close()is a tell into the same drop-head mailbox as every outbound frame, so a hot broadcast evicts the close command andcloseAll()silently does nothing #985 —WebsocketConnection.close()is a tell into the same drop-head mailbox as every outbound frame, so a hot broadcast evicts the close command andcloseAll()silently does nothing (high)_clientsonly inonWebsocketDisconnected, a drop-head mailbox tell, so a frame flood evicts the disconnect signal and leaks the entry and the hook forever #986 — The WebSocket hub removes a client from_clientsonly inonWebsocketDisconnected, a drop-head mailbox tell, so a frame flood evicts the disconnect signal and leaks the entry and the hook forever (high)BrokerActor.dispatchWhenConnectedreads an empty buffer while_drainBufferstill awaits, so two dispatches run concurrently and the documented enqueue order is violated #987 —BrokerActor.dispatchWhenConnectedreads an empty buffer while_drainBufferstill awaits, so two dispatches run concurrently and the documented enqueue order is violated (high)BrokerActorreturns from_handleReconnectwithout_closeTransport()on both terminal paths, so a broker that gives up keeps a live driver client, its sockets and its internal reconnect timers #988 —BrokerActorreturns from_handleReconnectwithout_closeTransport()on both terminal paths, so a broker that gives up keeps a live driver client, its sockets and its internal reconnect timers (medium)BrokerActor.postStopdiscards up to 1000 accepted outbound messages with no flush and no dead-lettering, so a graceful stop silently drops everythingenqueueOutboundacknowledged #989 —BrokerActor.postStopdiscards up to 1000 accepted outbound messages with no flush and no dead-lettering, so a graceful stop silently drops everythingenqueueOutboundacknowledged (medium)Performance and benchmark validity (4)
durationMsis omitted and its bucketMapis uncapped and keyed per actor path, so one panel click grows one bucket per entity per message type forever #973 — The DevTools wallclock profiler never auto-stops whendurationMsis omitted and its bucketMapis uncapped and keyed per actor path, so one panel click grows one bucket per entity per message type forever (medium)BoundedMailbox.enqueuebuilds a ts-pattern matcher and three closures per dropped message, so the overflow path costs about 19x an inlined switch exactly when the system is already saturated #974 —BoundedMailbox.enqueuebuilds a ts-pattern matcher and three closures per dropped message, so the overflow path costs about 19x an inlined switch exactly when the system is already saturated (low)Operability (9)
src/emits, and seven of the eight real stock metrics are never mentioned there, so every documented diagnostic returns an empty result #992 — All four PromQL queries in the operations runbook name metrics that no code insrc/emits, and seven of the eight real stock metrics are never mentioned there, so every documented diagnostic returns an empty result (blocker)addTasksites insrc/hitservice-unbindandactor-system-terminate, so a rolling deploy neither leaves the cluster nor hands off shards #993 — The docs say the cluster shutdown phases wire themselves up automatically while the only threeaddTasksites insrc/hitservice-unbindandactor-system-terminate, so a rolling deploy neither leaves the cluster nor hands off shards (blocker)debugwhile the default level isinfo, so a partition produces no operator-visible record of which peer went unreachable, when, or why #994 — Every cluster membership transition is logged atdebugwhile the default level isinfo, so a partition produces no operator-visible record of which peer went unreachable, when, or why (high)traceId/spanIdthatTracer.tspromises, so a trail cannot be followed across pods or joined to a trace without an external log shipper #995 — Log records carry neither a node identity nor the activetraceId/spanIdthatTracer.tspromises, so a trail cannot be followed across pods or joined to a trace without an external log shipper (medium)ActorSystememits no startup record andConfig.loadneither reports the config file it used nor rejects unknownactor-ts.*keys, so a misconfigured node is byte-identical in its output to a correct one #996 —ActorSystememits no startup record andConfig.loadneither reports the config file it used nor rejects unknownactor-ts.*keys, so a misconfigured node is byte-identical in its output to a correct one (medium)shutdown-grace-periodis 0 ms and/readynever reports draining, so a rolling deploy severs in-flight requests instead of draining them, and the three HTTP backends each read the value differently #997 — The defaultshutdown-grace-periodis 0 ms and/readynever reports draining, so a rolling deploy severs in-flight requests instead of draining them, and the three HTTP backends each read the value differently (medium)actor_message_handler_secondsuses the 5 ms to 10 s default buckets for handlers that run in microseconds, so every observation lands in the first bucket andhistogram_quantilereturns a constant near 5 ms #998 —actor_message_handler_secondsuses the 5 ms to 10 s default buckets for handlers that run in microseconds, so every observation lands in the first bucket andhistogram_quantilereturns a constant near 5 ms (medium)actor_restarted_totalandactor_message_handler_secondscarry no labels at all, so a scrape cannot say which actor class is crash-looping or which one is slow — the two questions those metrics exist to answer #999 —actor_restarted_totalandactor_message_handler_secondscarry no labels at all, so a scrape cannot say which actor class is crash-looping or which one is slow — the two questions those metrics exist to answer (medium)Packaging, public API and release engineering (9)
migrateInMemoryJournal,wrapEventAsEnvelopeandMailboxare re-exported by no entry point, so the wrap-legacy migration page and the mailbox sample cannot compile against the tarball #1002 — Ten documented public symbols includingmigrateInMemoryJournal,wrapEventAsEnvelopeandMailboxare re-exported by no entry point, so the wrap-legacy migration page and the mailbox sample cannot compile against the tarball (blocker)actor-tsby its published name and doc fences are never type-checked, so the exports map has no end-to-end test and publint plus attw stay green while five subpaths are unresolvable #1003 — No file in the repository importsactor-tsby its published name and doc fences are never type-checked, so the exports map has no end-to-end test and publint plus attw stay green while five subpaths are unresolvable (blocker)http/index.tswhich statically imports fastify, so importingActorSystem, the testkit or devtools eagerly loads Fastify and 20 transitive packages on an unbundled runtime #1005 — The root barrel star-exportshttp/index.tswhich statically imports fastify, so importingActorSystem, the testkit or devtools eagerly loads Fastify and 20 transitive packages on an unbundled runtime (medium)NodeJS.Signals,Bufferandnode:httpwhile@types/nodeis only a devDependency, so a consumer withskipLibCheckfalse gets 35 errors from inside node_modules #1006 — The shipped declarations referenceNodeJS.Signals,Bufferandnode:httpwhile@types/nodeis only a devDependency, so a consumer withskipLibCheckfalse gets 35 errors from inside node_modules (medium)declarationMapandsourceMapare on whilefilesships only dist, so all 1100 map files in the tarball point at a../srcthat is not published and go-to-definition dead-ends for every consumer #1007 —declarationMapandsourceMapare on whilefilesships only dist, so all 1100 map files in the tarball point at a../srcthat is not published and go-to-definition dead-ends for every consumer (low)Verification rigor (10)
bunfig.tomlnever setscoverageSkipTestFiles, so 319 test files sit in the badge at 100 percent and the real src figure is about 5 points lower #1016 — Bun reports the All-files coverage row as an unweighted mean over files andbunfig.tomlnever setscoverageSkipTestFiles, so 319 test files sit in the badge at 100 percent and the real src figure is about 5 points lower (high)Array.prototype.reduce, so nine badge-counted cases execute zero lines of the 19,814-line persistence layer #1017 — The persistence replay-mutation fuzzer imports nothing from src and proves theorems aboutArray.prototype.reduce, so nine badge-counted cases execute zero lines of the 19,814-line persistence layer (high)mainrequires only thetestcontext with strict false anddevelophas no branch protection at all, so integration, build, multi-runtime, package-health and examples gate nothing on the branch where work lands #1018 —mainrequires only thetestcontext with strict false anddevelophas no branch protection at all, so integration, build, multi-runtime, package-health and examples gate nothing on the branch where work lands (high)AllForOneStrategyis never spawned and thewithinTimeRangeMsrestart window is never crossed, so both branches can be neutralised inActorCellwith every supervision-related test file still green #1019 —AllForOneStrategyis never spawned and thewithinTimeRangeMsrestart window is never crossed, so both branches can be neutralised inActorCellwith every supervision-related test file still green (high)droppedCount === 0on a mailbox that received nothing, so changing the default to capacity 3 with drop-new leavesMailboxVariants.test.tsfully green #1020 — The Default mailbox: switch from unbounded to bounded (10k, drop-head) #310 default-mailbox guard checksdroppedCount === 0on a mailbox that received nothing, so changing the default to capacity 3 with drop-new leavesMailboxVariants.test.tsfully green (medium)COV_COLORis hardcoded and the test-badge denominator is pass plus fail, so the coverage badge can never go red and a quarantined run is indistinguishable from a full one #1021 —COV_COLORis hardcoded and the test-badge denominator is pass plus fail, so the coverage badge can never go red and a quarantined run is indistinguishable from a full one (medium)expect(true).toBe(true)in place of the behaviour their title names andCircuitBreaker.test.tsdiscards the thrown error at eight sites, so a wrong error type or a silent no-op passes #1022 — Five test cases assertexpect(true).toBe(true)in place of the behaviour their title names andCircuitBreaker.test.tsdiscards the thrown error at eight sites, so a wrong error type or a silent no-op passes (medium)MultiNodeTransportoffers only a binary partition and delivers viaqueueMicrotask, so gossip convergence under packet loss, reordering, duplication or latency is structurally untestable #1023 —MultiNodeTransportoffers only a binary partition and delivers viaqueueMicrotask, so gossip convergence under packet loss, reordering, duplication or latency is structurally untestable (high)persistAll, a snapshot write that fails after a successful append, and recovery over a gapped stream are all untested #1024 — No reusable failing-journal or failing-snapshot-store double exists, so a throwing append insidepersistAll, a snapshot write that fails after a successful append, and recovery over a gapped stream are all untested (high)ManualSchedulersamples callscheduler.advancebefore the actor has processed the preceding tell, so the flagship determinism examples fail with a probe timeout and the microtask explanation names the wrong queue #1025 — BothManualSchedulersamples callscheduler.advancebefore the actor has processed the preceding tell, so the flagship determinism examples fail with a probe timeout and the microtask explanation names the wrong queue (medium)Docs-vs-code drift (6)
EventStream.publishevaluatesevent instanceof channeloutside the try block andsubscribevalidates nothing, so one bad channel makes every spawn, every dead letter and every actor stop throwTypeError#1010 —EventStream.publishevaluatesevent instanceof channeloutside the try block andsubscribevalidates nothing, so one bad channel makes every spawn, every dead letter and every actor stop throwTypeError(high)expectMessageroutes throughreceiveOneinto_nextwhich discards the expectation, so the most common testkit failure reports only a bare timeout with no expected value or probe identity #1011 —expectMessageroutes throughreceiveOneinto_nextwhich discards the expectation, so the most common testkit failure reports only a bare timeout with no expected value or probe identity (low)KubernetesApiSeedProviderreads the endpoints resource while the manifest grants RBAC only on pods and never setsK8S_SERVICE_NAME, so the flagship deployment recipe 403s and the cluster never forms #1013 —KubernetesApiSeedProviderreads the endpoints resource while the manifest grants RBAC only on pods and never setsK8S_SERVICE_NAME, so the flagship deployment recipe 403s and the cluster never forms (medium)SchemaRegistration.upcastFromPrevis typed to takeunknown, so the upcaster form printed in the JSDoc, the docs page and the bundled example fails to compile understrictFunctionTypes#1014 —SchemaRegistration.upcastFromPrevis typed to takeunknown, so the upcaster form printed in the JSDoc, the docs page and the bundled example fails to compile understrictFunctionTypes(medium)Optionunwrapped with a non-null assertion, so the bundled samples do not compile and nothing in CI notices #1015 — examples/ carries eight type errors that are not missing-module noise, including anOptionunwrapped with a non-null assertion, so the bundled samples do not compile and nothing in CI notices (low)Reviewed and not pursued
Seven findings did not become issues. Each was folded into an existing one, with the new evidence added there as a comment:
callTimeoutMs, a hung upstream leaves the breaker permanently permissive rather than merely admitting a burst.helloidentity is self-declared → [Security] The clusterhelloidentity is not bound to the TLS peer certificate, so mTLS admits a node but never verifies which node it is #912. Already stated there. Added: the plaintext (non-TLS) case, and thatremoteAddressis exposed by all three TCP backends and read nowhere insrc/cluster/.node/region/hostedShardsin the payload rather than the authenticated envelope sender, letting one peer seize every shard of a type or evict another node's region #712 (coordinator half) and [Security] ShardRegion.onHandOff destroys a shard's entities on the word of any peer #584 (region half). The finding's citation ofWireValidation.ts:113was wrong and is corrected in the comment: sharding rides theenvelopeframe kind, which is validated; the unchecked surface is the envelope body.replayStatehas no contiguity check → [Security] PersistentActor accepts out-of-order events from snapshot store #122, whose body already carries the gap-detection branch verbatim. Added: the four framework-internal crash windows that assertion would convert from silent wrong state into a loud recovery failure.pollIntervalMs, with noLIMITand no startup warning.journal.appendhas no timeout → [Feature] Persistence behavior keys — breakers, recovery limits, bounded stash #874, which proposes exactly this key. Added: the failure mode is not "slow persistence" but a supervision restart 1024 stashed messages away from its cause.Object.keys()call made purely to test emptiness against a frozen sentinel.Nine further findings were narrowed rather than dropped, and their issues say so rather than overstating the claim. The notable ones:
systemNamethat never matchesCluster.selfAddress, so a cluster enabled the only supported way never converges #941 — "TLS cannot be enabled through the supported API" is already [Security]remote.tls.enabledHOCON key is documented but dead — nothing in src/ reads it, so operators believe TLS is on when it is not #591; what is new and reproduced is that the only documented workaround produces asystemNamethat never matchesselfAddress, so a cluster wired that way does not converge.describe.each. The real gap is that 29 of 35 do not, and there is no named contract module.AllForOneStrategyis never spawned and thewithinTimeRangeMsrestart window is never crossed, so both branches can be neutralised inActorCellwith every supervision-related test file still green #1019 — a partial refutation. The named mutation is killed, byPersistentActorRecoveryFailure.test.tsrather than by anything inSupervision.test.ts. Two neighbouring branches survive mutation across all nine supervision-related files, and that is what the issue argues.persistAllis documented atomic but D1, MongoDB and Cassandra have no multi-event transaction and no capability flag exposes it, so a mid-batch failure commits a prefix the caller was told had failed #959 — the original claim had the state inversion backwards._state/_seqare updated afterappendreturns, so a partial batch leaves the actor's memory behind its own journal, which is why the restart is the moment divergence becomes real.tsc --moduleResolution nodenextcompiles all 550 source files cleanly. Filed as hardening with no latent defect behind it.Two claims came back stronger than reported: the terminating-actor busy-spin measured 123 683 dispatcher executions in 300 ms rather than "a hot loop" (#915), and Bun's
All filescoverage row was shown to be an unweighted mean over files, not a weighted line ratio (#1016).Working order
Sequenced so the cheapest total-outage fixes land first.
ensureCoordinatorruns beforenumShardsByTypeis populated, so every ShardCoordinator is built with 64 shards whatever you configure and entities above that id never get a home #1026 (ensureCoordinatorreadingoptions.numShardsbefore the map is populated — every coordinator is built with 64 shards whatever you configure), [Bug] The failure detector deletes a peer atdownAfterMsregardless of the configuredDowningProvider, so every split-brain resolver is bypassed under a comment claiming it is only a fallback #929 (the failure-detector delete behind anif (!this.downing)), [Security]Lease.checkAlive()returns a cached boolean instead of comparing againstexpiresAt, and has no callers, so two nodes can both believe they hold the lease after an event-loop stall #937 (checkAlive()compared againstexpiresAtinstead of returning a cached boolean), [Bug] An actor that throws inonReceiveis restarted with no log output at any level, so a crash-looping actor is invisible in production #916 (failToParentlogging the cause — an actor that crash-loops currently produces zero log output at any level). Four small changes, four total-outage classes.ask()rebuilds the message as an object literal, soMap,Set,Date, typed arrays and every class instance arrive stripped of their contents #925 —ask(). It rebuilds the message as an object literal, soMap,Set,Date, typed arrays and every class instance arrive stripped. Silent data loss on the most-used API in the documentation.heartbeat.fromkeeps a dead node "healthy" forever (blocks singleton/shard failover) and makes the node dial an attacker-chosen host:port #572, [Security] Receptionist gossip trusts the payload's self-declaredfrominstead of the connection-authenticated sender, letting any peer poison cluster-wide service discovery #574, [Security] DistributedPubSubMediator.handleGossip trusts message.from instead of the socket peer, letting one peer wipe another node's subscriptions #582, [Security] ShardCoordinator derives region identity and shard ownership fromnode/region/hostedShardsin the payload rather than the authenticated envelope sender, letting one peer seize every shard of a type or evict another node's region #712, [Security] DistributedData counts quorum acks and read-responses by the payload'sfrominstead of the authenticated peer, letting one member forge a full quorum and inject arbitrary CRDT state #719, [Security] onReadRequest/onWriteRequest reply to the payload'sfrom, so any node can be made to dial an attacker-named host and buffer full CRDT snapshots in aConnection.pendingqueue that is never drained, never capped and never reclaimed #723 and [Security]decodeSingleRefbuilds a dialableNodeAddressfrom unvalidated wire fields, so a ref embedded in any message body makes the receiving node connect to an attacker-chosen host #936 are the same defect at seven call sites: a wire-supplied address trusted instead of the authenticated peer. One helper that resolves any such address against the connection retires the class; [Security] The clusterhelloidentity is not bound to the TLS peer certificate, so mTLS admits a node but never verifies which node it is #912 is the foundation it rests on.KafkaActorresolves itseachMessagepromise before the handler runs whileautoCommitis on, so the documented at-least-once default is at-most-once, and a partialwithConsumererases a HOCON manual commit mode #975 (Kafka), [Bug]AmqpActoracks each delivery before handing it to the target actor and swallows a failing ack, so the default configuration loses messages on restart and redelivers duplicates when the ack fails #976 (AMQP), [Bug] MQTT QoS 1 and 2 are structurally unreachable becauseMqttActornever enablesmanualAcks, so mqtt.js PUBACKs while the message is still in the mailbox andMqttMessage.qosreports a guarantee the actor cannot provide #977 (MQTT) all acknowledge before the handler runs, so the documented at-least-once default is at-most-once on every broker that claims it.actor-tsby its published name and doc fences are never type-checked, so the exports map has no end-to-end test and publint plus attw stay green while five subpaths are unresolvable #1003 (a consumer smoke gate —npm pack, install, resolve every documented import) would have caught [Docs] 92 documented imports use the subpaths actor-ts/http, /coordination, /serialization, /discovery and /cluster/pubsub which are absent from the exports map, so every HTTP documentation page fails with ERR_PACKAGE_PATH_NOT_EXPORTED #1001's 92 broken doc imports on the day they were written; [Test] Bun reports the All-files coverage row as an unweighted mean over files andbunfig.tomlnever setscoverageSkipTestFiles, so 319 test files sit in the badge at 100 percent and the real src figure is about 5 points lower #1016 (coverageSkipTestFiles) is why the coverage number is not what it appears to be.Then the remaining waves by severity. The
production-goallabel is the working set;severity: highpluspriority: highwithin it is the head.