Skip to content

v3.6.0

Choose a tag to compare

@cldmv-bot cldmv-bot released this 20 May 13:13
· 6 commits to master since this release
v3.6.0
4968255

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:

  1. self.slothlet.lockCaller(fn) — pins the registering module's caller
    identity onto a callback, so a self.* 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.

  2. 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.

  3. Hooks auto-pin caller identityapi.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 }.

  4. run/scope callbacks keep caller identityapi.slothlet.run() and
    .scope() (and context.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; self stays the live proxy.
    Only the caller identity is frozen.
  • This is intentionally not AsyncResource.bind — that would freeze the whole
    context snapshot. Use lockCaller when you want the caller pinned but the
    request context to remain live.
  • this is forwarded into the locked callback, so Fastify-style this (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 fn propagate unchanged — the original error type, code,
    and status are preserved, never re-typed as CONTEXT_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 why addHook/array-stored callbacks lose caller identity and when to
    reach for lockCaller vs bind; a "Hooks auto-pin caller identity" section and
    the options.lockCaller parameter on the hook.on() reference.
  • docs/PERMISSIONS.md — notes that slothlet.lockCaller
    and slothlet.bind are permission-gated routes.
  • README.md — promoted v3.6.0 to "Latest".

🧪 Test Coverage

  • api_tests/api_test_lock_caller/ — fixture with two modules: a producer that
    invokes a callback while it is the active caller (both via a plain
    EventEmitter listener and via a direct nested call), and a consumer that
    builds lockCaller-wrapped and plain callbacks, bind-wrapped probes, hook
    registrations, and run/scope identity 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,
    this forwarding, 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 the lockCaller: false
    opt-out and the already-locked passthrough), and run/scope callbacks keeping
    caller identity — across the eager/lazy × async/live × hook matrix.

Coverage remains at 100% across statements, branches, functions, and lines.