Security & Isolation
Isolation is a security property.
Typed messages, bounded mailboxes, supervised failure, and single-writer persistence eliminate entire classes of vulnerability at the language level — before your application ever starts.
Isolation by design.
Actors hold no shared mutable state. Every state change returns a new
value — Behavior::same() to keep the
current behavior, BehaviorWithState::next($state)
to advance to a new state. No two actors can write to the same memory
simultaneously, by construction. TOCTOU races, data races, and most
concurrency vulnerabilities are removed at the language level — not
by convention or discipline.
The Psalm plugin's MutableActorStateRule enforces
this statically: mutable properties on any class that implements
ActorHandler or StatefulActorHandler
are flagged as errors at analysis time. You cannot accidentally introduce shared
mutable state that Psalm misses.
This is not defensive programming applied after the fact — it is the structural consequence of the actor model. Isolation is load-bearing, not advisory.
Eliminated by the model
- Time-of-check/time-of-use (TOCTOU) races
- Data races on shared counters and caches
- Uncontrolled concurrent writes to entity state
- Global mutable singleton abuse
Enforced by Psalm
MutableActorStateRule— rejects mutable handler propertiesReadonlyMessageRule— requires readonly message classesMutableClosureCaptureRule— bans by-reference closure captures
Typed message contracts.
Messages are readonly classes — enforced by the
ReadonlyMessageRule Psalm hook. There are no
string-key dispatch tables, no eval()-like patterns,
no dynamic property bags. Every field on every message has a declared type and
is immutable from the moment it is constructed.
Handler dispatch uses a match(true) expression on
instanceof checks. Psalm Level 1 flags unhandled
message types at analysis time — not at runtime, and not in production logs.
Sending the wrong message type to an ActorRef<T>
is a Psalm error before the code ships. There is no way to craft an unexpected
message shape that bypasses the type system.
<?php
readonly class TransferFunds
{
public function __construct(
public string $fromAccount,
public string $toAccount,
public int $amountCents,
public ActorRef $replyTo,
) {}
}
// Psalm ReadonlyMessageRule rejects non-readonly messages at analysis time:
// $ref->tell(new TransferFunds(...)); // ← ERROR if class is not readonly
// $ref->tell(['amount' => 500]); // ← ERROR: not an object<?php
$props = Props::fromBehavior($behavior)->withSupervision(
SupervisionStrategy::oneForOne(
maxRetries: 3,
window: Duration::seconds(60),
decider: static fn (Throwable $t): Directive => match (true) {
$t instanceof CryptographicInvariantException => Directive::Escalate,
$t instanceof AuthenticationFailure => Directive::Stop,
$t instanceof TransientNetworkError => Directive::Restart,
default => Directive::Escalate,
},
),
);Supervision as security policy.
Failures are contained to the subtree they originate in. A supervision strategy maps exception types to explicit security actions: escalate a cryptographic invariant violation, stop permanently on authentication failure, restart with backoff on transient network errors. The decider is a typed closure — Psalm ensures the match is exhaustive.
This means your security policy is expressed in the same language as your business logic, in the same file as the actor that needs it, and verified by the same static analysis pipeline. You do not need a separate configuration format or a runtime policy engine to express "stop this actor if an authentication failure occurs".
The "let it crash" philosophy is a security principle as much as a reliability one: a compromised or corrupted actor is isolated and replaced, not patched in place while it continues to process messages.
Bounded mailboxes resist DoS.
A flood of messages cannot exhaust memory. Bounded mailboxes with
overflow strategies — DropNewest,
DropOldest,
Backpressure,
ThrowException — provide explicit, auditable
load-shedding. The capacity and strategy are set at spawn time on the
MailboxConfig; there is no global tuning knob
that silently changes under load.
Pair Backpressure with the HTTP layer's
ask() timeout to cap end-to-end request latency
under saturation. The actor signals its own overwhelm before your upstream
services time out. Pair DropNewest with a
circuit-breaker pattern at the actor boundary for fire-and-forget traffic
that should shed gracefully when the consumer falls behind.
<?php
MailboxConfig::bounded(
capacity: 10_000,
strategy: OverflowStrategy::Backpressure,
);
// OverflowStrategy options:
// • Backpressure — sender blocks until capacity frees
// • DropNewest — new messages are silently discarded
// • DropOldest — oldest queued messages make room
// • ThrowException — MailboxOverflowException for explicit handling<?php
use Monadial\Nexus\Persistence\Recovery\ReplayFilter;
$system = ActorSystem::create('orders', new SwooleRuntime());
$writerId = $system->writerId(); // unique ULID stamped on every write
EventSourcedBehavior::create($persistenceId, $emptyState, $cmdHandler, $evHandler)
->withEventStore($eventStore)
->withReplayFilter(ReplayFilter::fail()); // throw WriterConflictException on interleave
// WriterConflictException is raised if another ActorSystem wrote to the same
// persistence ID — split-brain corruption is surfaced immediately, not silently.Single-writer principle for persistence.
Each ActorSystem is assigned a unique ULID at
construction — the writer ID — stamped on every persisted envelope.
Persistence stores enforce single-writer semantics: a write from a different
writer ID raises WriterConflictException
immediately. Split-brain corruption when two workers race for the same
persistence ID is surfaced as an exception, not silently overwritten.
During event replay, ReplayFilterMode::Fail
throws on any cross-writer interleave. The alternative modes —
Warn, RepairByDiscardOld,
Off — are available but must be opted into
explicitly. The default is strict: if the event stream is inconsistent,
you know immediately.
Audit trail built in.
Event sourcing makes every state change an immutable, timestamped fact. Events are appended, never mutated. After a security incident you reconstruct exactly what happened from the event log — not from guesswork about current state, and not from application logs that might have been tampered with or rotated away. The authoritative record is the event store.
Snapshots accelerate recovery without sacrificing the audit trail. The snapshot stores the current state for fast startup; the event store retains the full history. Retention policy controls event pruning independently from snapshot frequency — so you can keep snapshots rolling while holding a forensic window of raw events for compliance or incident response.
Every event carries the writer ID, sequence number, and timestamp. Forensic reconstruction can verify sequence continuity and detect gaps without any application-level audit middleware.
No deserialization on the hot path.
Cross-worker messages pass Envelope objects
directly via Swoole\Thread\Queue. There is no
PHP unserialize() round-trip on local messaging.
PHP's unserialize() gadget-chain attack surface —
the class of vulnerability that has burned PHP applications for over a decade —
simply does not exist on the in-process actor messaging path.
Remote cluster transport, when it ships, will require explicit
#[MessageType] registration and an allowlist
serializer. Arbitrary object deserialization from untrusted sources is not
an architecture the system supports by default — it must be opted into at
the transport boundary with explicit type registration.
Secrets stay outside actor state.
Inject secrets through the PSR-11 container at actor construction time via
Props::fromContainer(ContainerInterface $container, string $class).
The container resolves the actor class with its dependencies — including
secret-bearing service objects — at spawn time. The secret lives in the
container binding, not in the actor's behavior closure.
Never store secrets on the actor's behavior closure or stateful field. Mailbox introspection, dead-letter logging, supervision restart context, and Psalm tooling can all incidentally observe handler state. A secret captured in a closure is one crash report or one debug dump away from exposure. Keep secrets in your secret manager; keep references to service objects — not raw secret strings — in your actors.
Deterministic security testing.
The StepRuntime combined with
VirtualClock
makes security properties testable as ordinary deterministic unit tests.
Call $runtime->step() to process exactly one
message; call $runtime->drain() to flush the
entire queue. Advance the clock by exactly the backoff window. Assert retry
counts, supervision decisions, and mailbox overflow behaviour without flaky
timing-dependent assertions.
Replay-filter behaviour, writer-conflict handling, and retention policy enforcement are all exercisable in the same way — no mocking of external stores needed for the core security property tests. Security invariants belong in CI just like functional correctness. The StepRuntime makes that practical.
Threat model & non-goals.
Nexus protects the in-process actor topology. It eliminates concurrency vulnerabilities, enforces message immutability, constrains resource exhaustion, and makes failure visible. These are meaningful guarantees, but they operate inside the process boundary.
- ✗ TLS termination — use Nginx, Caddy, or your cloud load balancer in front.
- ✗ Input validation & authentication — use PSR-15 middleware before requests become actor messages.
- ✗ Secret management — use Vault, AWS SSM, or your platform's secret store. Nexus provides the injection point (PSR-11 container) but not the store.
- ✗ Runtime sandboxing — Nexus does not constrain what PHP code can do at the OS level. Use containers, seccomp profiles, or read-only filesystems for process isolation.
Use Nexus in concert with PSR-15 middleware for HTTP request validation, a secrets manager for credential rotation, and your normal infra controls. Nexus handles the application-tier concurrency model; your infra stack handles the network perimeter.
Security across the stack.
The security model extends to HTTP request boundaries and database transaction isolation.
Build with isolation as a guarantee.
Actor semantics remove concurrency vulnerabilities by construction. Psalm enforces the contract before you ship.
composer require nexus-actors/nexus