v3.6.0
release: v3.6.0 (#90)
Slothlet v3.6.0 Changelog
Release Date: May 2026
Release Type: Minor
Branch: release/3.6.0
Overview
Version 3.6.0 makes caller identity on callbacks correct across the board —
for callbacks slothlet cannot intercept, for its own hooks, and for run/scope
callbacks:
-
self.slothlet.lockCaller(fn)— pins the registering module's caller
identity onto a callback, so aself.*call inside it is always attributed to
the module that registered it, regardless of which module's async context
happens to be ambient when the callback fires. -
self.slothlet.bind(fn)— a convenience re-export of Node's
AsyncResource.bind, which freezes the entire async context (every
AsyncLocalStorage) captured at registration time. -
Hooks auto-pin caller identity —
api.slothlet.hook.on()now pins the
registering module's identity onto hook handlers by default, so a hook's
self.*calls and permission checks are attributed to the module that
registered it rather than to whichever caller triggered the intercepted call.
Opt out per-registration with{ lockCaller: false }. -
run/scopecallbacks keep caller identity —api.slothlet.run()and
.scope()(andcontext.run/context.scope) previously dropped the caller
identity inside the callback; it is now carried through.
Compatibility. All four changes are backward compatible — v3.6.0 has no
breaking API changes. The hook auto-pin default (#3) is a correctness fix:
previously a hook handler registered from inside a module ran under whichever
caller triggered the intercepted call — an unrelated module that has nothing to do
with the hook — so self.* calls and permission checks inside the handler were
misattributed (and a before-hook handler ran with no slothlet context at all, so
a self.* call inside one threw outright). The handler now runs under the
registering module's identity, which is what hook authors already expected.
Handlers that do not call self.* or otherwise depend on caller identity are
unaffected. The rare handler that intentionally relied on the old, misattributed
triggering-caller identity can restore it with { lockCaller: false }. Hooks
registered outside a module are unaffected.
🚀 New Features
self.slothlet.lockCaller() — freeze caller identity on a callback
The problem. Slothlet wraps EventEmitter listeners with AsyncResource so a
listener registered by one module always runs with that module's async context.
Callbacks stored in plain arrays, however, never pass through that patch —
Fastify's server.addHook() handlers, third-party event registries, and similar
mechanisms keep their callbacks in ordinary arrays. When such a callback fires it
inherits whatever async context is ambient at that moment, which may belong to a
different module.
The concrete failure: module B registers an onRequest hook during its setup. A
later request restores module A's async context first (A registered an upgrade
listener that slothlet's EventEmitter patch pinned to A), Fastify then runs the
onRequest chain, and B's hook executes with currentWrapper = module A. Any
self.* call inside B's hook with a permission rule keyed to module B is now
denied, because the permission system sees module A as the caller.
This is not a defect in the EventEmitter patch — addHook is a Fastify
mechanism slothlet cannot intercept generically. The fix is an opt-in utility that
lets a module pin its identity onto a callback.
The fix. self.slothlet.lockCaller(fn) captures the registering module's
currentWrapper at call time. Every invocation of the returned wrapper takes the
live async context store and overrides only the caller identity with the
frozen value before calling fn:
import { self } from "@cldmv/slothlet/runtime";
// Inside module B's setup:
server.addHook("onRequest", self.slothlet.lockCaller(async (req, reply) => {
// Runs as module B no matter which module's context is ambient.
const ctx = self.slothlet.context.get(); // permission rules keyed to B match
// ...
}));- The full request-scoped context — set later in the request lifecycle — stays
live and visible inside the locked callback;selfstays the live proxy.
Only the caller identity is frozen. - This is intentionally not
AsyncResource.bind— that would freeze the whole
context snapshot. UselockCallerwhen you want the caller pinned but the
request context to remain live. thisis forwarded into the locked callback, so Fastify-stylethis(the
request/reply or the Fastify instance) is preserved.- Called with no active context (no module wrapper to capture),
lockCaller
is a no-op passthrough — it is meaningful only when called from inside a module
(during setup or a request) so there is a wrapper to capture. - Errors thrown by
fnpropagate unchanged — the original error type,code,
andstatusare preserved, never re-typed asCONTEXT_EXECUTION_FAILED.
Implementation
lockCaller is a thin wrapper over the context manager's
runInContext(instanceID, fn, thisArg, args, currentWrapper, rawErrors). Both
context managers — async (context-async.mjs)
and live (context-live.mjs) —
already copy the live store and override only currentWrapper/callerWrapper
when a currentWrapper argument is passed. A new rawErrors parameter tells
runInContext to let a non-SlothletError thrown by fn propagate unchanged
instead of wrapping it as CONTEXT_EXECUTION_FAILED. lockCaller captures the
wrapper once at registration; instanceID and contextManager are resolved
live off the long-lived slothlet object on every invocation, so a callback
held across a reload() (which swaps both) still targets the current instance:
lockCaller(fn) {
// ... INVALID_ARGUMENT guard ...
const capturedWrapper = slothlet.contextManager?.tryGetContext?.()?.currentWrapper ?? null;
const locked = function slothlet_lockedCaller(...args) {
// rawErrors: a locked framework callback surfaces its own errors unchanged.
return slothlet.contextManager.runInContext(slothlet.instanceID, fn, this, args, capturedWrapper, true);
};
locked._slothletOriginal = fn; // parity with the EventEmitter patch metadata
return locked;
}self.slothlet.bind() — freeze the whole async context
self.slothlet.bind(fn) is a convenience re-export of Node's
AsyncResource.bind. Unlike lockCaller, which overrides only the caller
identity and leaves the request context live, bind freezes the entire async
context captured at registration time — every AsyncLocalStorage, including
slothlet's caller and request context:
server.addHook("onRequest", self.slothlet.bind(handler));AsyncResource.bind only meaningfully captures slothlet's caller/context in
async runtime mode; in live mode the slothlet store is kept off the ALS, so
bind degrades to binding whatever other async context exists. Reach for
lockCaller when you want the caller pinned but request-scoped context live, and
bind when you want the whole context snapshot frozen. bind is not a
workaround for the live-mode lockCaller caveat — it has the same limitation;
async runtime mode is the only fix.
Hooks auto-pin caller identity
lockCaller exists for callbacks slothlet cannot intercept. Slothlet's own
hooks are a different story: api.slothlet.hook.on() is a controlled entry point,
so it now pins caller identity automatically.
A hook handler fires during the API call it intercepts, which belongs to whichever
caller triggered that call — not to the module that registered the hook. Before
this release a hook handler therefore misattributed itself to the triggering
caller's identity (and a before-hook handler ran with no slothlet context at
all, so a self.* call inside it threw). hook.on() now pins the registering
module's caller identity onto the handler by default — the same mechanism as
lockCaller:
// Registered inside module B's init() — the handler runs as module B,
// no matter which caller's API call triggers `math.*`.
self.slothlet.hook.on("before:math.*", ({ args }) => {
self.audit.record(args); // permission rules keyed to B match
return args;
});Opt out per-registration with { lockCaller: false } — the handler then runs
without a pinned identity (and, like any un-pinned callback, may have no slothlet
context to resolve self against). Opt out only for handlers that do not touch
self:
self.slothlet.hook.on("before:math.*", logArgs, { lockCaller: false });Auto-pinning applies only when the hook is registered from inside a module and the
handler is not already lockCaller-wrapped; the latter keeps re-registration after
a full reload() idempotent. Handler errors still reach the error-hook pipeline
with their original type and code — the pinning routes through runInContext
with rawErrors, so it never re-types a thrown error.
run/scope callbacks keep caller identity
api.slothlet.run() and .scope() (and the context.run/context.scope
aliases) execute a callback in an isolated child context. That child store
isolates context data — but it previously also dropped the caller identity,
so a self.* call inside a run/scope callback resolved against no caller
(permission rules keyed to the invoking module silently did not match). The child
store now carries currentWrapper/callerWrapper from the parent, so a callback
invoked from inside a module stays attributed to that module.
🔒 Permissions Interaction
The slothlet namespace is wrapped by createInternalRouteProxy, so
slothlet.lockCaller and slothlet.bind are permission-gated routes like every
other slothlet.* member. A module that calls self.slothlet.lockCaller needs
slothlet.lockCaller permitted — default-allow policies already cover it;
deny-by-default configurations must allow it explicitly. The callback returned by
lockCaller runs with currentWrapper = the registering module, so permission
rules keyed to that module match instead of failing against whatever module's
async context happened to be ambient.
📚 Documentation Updates
docs/HOOKS.md— new "Caller Identity in Callbacks" section
explaining whyaddHook/array-stored callbacks lose caller identity and when to
reach forlockCallervsbind; a "Hooks auto-pin caller identity" section and
theoptions.lockCallerparameter on thehook.on()reference.docs/PERMISSIONS.md— notes thatslothlet.lockCaller
andslothlet.bindare permission-gated routes.README.md— promoted v3.6.0 to "Latest".
🧪 Test Coverage
api_tests/api_test_lock_caller/— fixture with two modules: aproducerthat
invokes a callback while it is the active caller (both via a plain
EventEmitterlistener and via a direct nested call), and aconsumerthat
buildslockCaller-wrapped and plain callbacks,bind-wrapped probes, hook
registrations, andrun/scopeidentity probes.tests/vitests/suites/runtime/lock-caller.test.vitest.mjs— the repro (a locked
callback keeps the registering module's caller identity while a plain callback
inherits the wrong one), a permission rule keyed to the registering module
matching inside a locked callback, live request context staying visible,
thisforwarding, the no-active-context no-op, non-function rejection for both
utilities, errors propagating unchanged through a locked callback,bind
restoring the captured async context and freezing slothlet caller identity,
hooks auto-pinning the registering module's identity (with thelockCaller: false
opt-out and the already-locked passthrough), andrun/scopecallbacks keeping
caller identity — across the eager/lazy × async/live × hook matrix.
Coverage remains at 100% across statements, branches, functions, and lines.