v3.12.0
release: v3.12.0 - emit impl:warning / impl:error diagnostic events (#182)
Slothlet v3.12.0 Changelog
Release Date: July 2026
Release Type: Minor
Branch: release/3.12.0
Overview
Version 3.12.0 is a security-and-observability release. It closes several ways the permission system could be bypassed, makes the previously console-only implementation diagnostics observable as lifecycle events, and fixes nested lifecycle hooks being silently dropped.
The security work (#183) hardens the permission system along five axes: enforcement now fails closed when a call or read arrives with no resolvable caller identity (previously it failed open); the constructTrap for proxied classes now enforces the same capability check as ordinary calls, so an inter-module new self.x.Foo() can no longer bypass gating; a class instance returned by a module now carries that module's identity, so its methods are checked as the module that created it; the engine-internal ./handlers/* and ./factories/* subpaths are removed from the package exports (in Node they were the only external import path to a live slothlet instance; the permission system is an enforced boundary in Node — in the browser it is cooperative, see below and PERMISSIONS.md); and the permission control surface gains an opt-in one-way seal. Separately, per-request context keys can now be owner-locked or write-protected via scope({ protect, owners }). Every behavior change that could affect a host relying on the old behavior ships with an explicit opt-out or is confined to undocumented internals — see Upgrade notes.
The observability work (#148) turns slothlet's non-throwing implementation diagnostics — a user API shadowing the reserved slothlet property, multiple root-level default exports, a hot-reload merge that cannot combine values — into impl:warning / impl:error lifecycle events. These fire additively, independent of the silent config (which now gates only slothlet's own console.warn, consistently across every warning site), so a host can observe every diagnostic while keeping the console quiet. A construction-time lifecycle config option registers subscribers before the api builds, so even init-time diagnostics emitted during cold-start are observable.
Finally, nested shutdown / destroy leaves are no longer silently dropped (#176), and the analyze audit reads each source file once across its detector passes instead of re-reading per detector (#164).
Compatibility. No breaking changes. The permission fail-closed change and the class-instance/constructTrap enforcement correct never-intended gaps in a security feature; each has an opt-out (permissions.failOpenOnAbsentCaller) or applies only where enforcement was always intended. The removed ./handlers/* / ./factories/* exports were undocumented engine internals, never a public entrypoint. impl:warning / impl:error are new additive events, and lifecycle / collectLifecycleHooks are new opt-in config options. See Upgrade notes for the two behaviors a host might need to opt out of.
🔒 Security & Permission Hardening (#183)
Permission enforcement fails closed on absent caller identity
Enforcement previously exempted any call or read made with no resolvable active caller — an enforcement gap that was never intended for a system whose purpose is to gate access. A call inside an active context whose caller identity was missing or forged slipped through. It now fails closed.
A new internal-only module #handlers/trusted-root provides a non-enumerable TRUSTED_ROOT store marker, a genuineWrappers WeakSet of every real UnifiedWrapper, and a reserved PROTECT_SENTINEL. A shared resolveEnforcedCaller() helper — used by applyTrap, constructTrap, and the read gate — permits a call/read only when the resolved context carries the trusted-root marker (genuinely host-initiated; the marker rides the base store and any host-level run() / scope() descended from it, but never a module's execution store). A context-present-but-no-caller, or a forged-wrapper caller, is denied. The marker is non-enumerable so an async execution-store shallow copy never inherits it.
Opt-out: new config option permissions.failOpenOnAbsentCaller (boolean, default false) restores the previous fail-open behavior for any host that depends on it.
constructTrap enforces the same capability check as applyTrap
constructTrap had no permission-manager code, so an inter-module new self.x.Foo() bypassed the capability check that guards ordinary calls. The enforcement block is extracted into a shared enforcePermission() helper and called from both traps. External (host) construction stays exempt, mirroring call enforcement.
Class-instance methods are enforced as their creating module
A method invoked on a class instance returned by a module ran via runInContext with no caller identity, so under the fail-closed change it was denied even under defaultPolicy: "allow" — and was previously exempt from enforcement entirely. The creating module's wrapper is now captured when the returned instance is wrapped and passed as the caller identity for every method call, so a class method's self.* calls are permission-checked as the module that created the instance — identical to that module's plain functions.
handlers/ and factories/ are internal-only (raw-instance leak closed)
./handlers/* and ./factories/* are removed from the package.json exports and moved to an imports field as #handlers/* / #factories/*, reachable only from inside the package. In a Node process this closes a raw-instance leak: ./handlers/* was the only external import path to context-async's getContext(), through which a dependency could reach a live slothlet instance and step around the permission surface. Node enforces exports / imports privacy, so external code can no longer resolve them — importing @cldmv/slothlet/handlers/* or /factories/* now throws ERR_PACKAGE_PATH_NOT_EXPORTED, and #-prefixed specifiers resolve only from slothlet's own modules. These subpaths were undocumented engine internals, never a public entrypoint.
This is a Node boundary. In the browser there is no module-privacy equivalent — any served .mjs is importable by URL, page script has full DOM/global authority, and the runtime uses live bindings — so browser-mode permissions are a cooperative / intra-app least-privilege boundary, not a defense against adversarial page code. See PERMISSIONS.md → Browser mode.
Opt-in control-surface seal
A one-way seal for the permission control surface. api.slothlet.permissions.control.seal() (idempotent, no unseal) plus a sealed getter; once sealed, the policy-mutating methods enable, disable, addRule, removeRule, and setReadGating throw PERMISSION_SEALED. shutdown() is never guarded so teardown always works, and enforcement keeps evaluating normally. Module-land calls to the control surface remain blocked by the pre-existing slothlet.permissions.control.** deny rule, so only the host (trusted root) can seal. The seal is preserved across reload().
✨ Features
Owner-locked / write-protected context keys (#183)
Opt-in ownership for per-request context keys, declared via scope({ protect, owners }) / run() options (never via run()'s positional args, which forward to the callback):
protect: string[]locks keys write-once/unowned: after the initial value is seeded fromcontext, any later write via the runtimecontextproxy throwsCONTEXT_KEY_PROTECTED.owners: { key: ownerApiPath }binds a key to a named owner. Only a writer whose executing-module apiPath equals the owner (or is a leaf under it) may write; others throwCONTEXT_KEY_PROTECTED.
Each scope builds a __contextOwners map (null-prototype, hasOwnProperty-checked so a __proto__ / constructor key can't be misread) inheriting the parent's owners; a nested scope cannot re-claim a key another owner already holds (CONTEXT_KEY_OWNED). Enforcement lives in a shared enforceContextKeyWrite() helper wired into both the async and live runtime context set-traps. New error codes CONTEXT_KEY_PROTECTED / CONTEXT_KEY_OWNED / SCOPE_INVALID_PROTECT / SCOPE_INVALID_OWNERS added across all 12 locales.
impl:warning / impl:error diagnostic lifecycle events (#148)
Slothlet's non-throwing implementation diagnostics are now observable as lifecycle events instead of being console-only:
impl:warning— a condition slothlet handled and continued past, at runtime (e.g. a syntheticapi.slothlet.api.add()whose default export can't be placed at the root) and during cold-start init (multiple root-level default exports; a user API that shadows the reservedslothletproperty; an empty scanned directory). Payload:{ code, message, apiPath? }.impl:error— a failure slothlet caught and continued past without throwing (e.g. a hot-reload merge that can't combine a primitive with an incoming module, so it keeps the existing value and rejects the mutation). Same payload plus anerrorfield carrying the originatingError/SlothletError.
Both are additive: they fire regardless of silent. silent now gates only slothlet's own console.warn output, consistently across every warning site (runtime and init-time alike) — so under silent: true the console stays quiet while subscribers still observe every diagnostic. Genuinely invalid configuration still throws; only non-throwing diagnostics emit these events. Emission goes through a shared ComponentBase.emitImplDiagnostic(level, data) helper.
Construction-time lifecycle subscription option (#148)
Subscribing via api.slothlet.lifecycle.on(...) only works after the api is built, so init-time events have already fired by the time a handler can attach. The new lifecycle config option registers handlers on the lifecycle emitter before the api builds:
await slothlet({
dir: "./api",
lifecycle: {
"impl:warning": (data) => console.warn(`[init] ${data.code}: ${data.message}`),
"impl:error": [onError, auditError],
"impl:created": (data) => registry.add(data.apiPath)
}
});It maps an event name to a handler function(data, token) or an array of them; any event name is accepted. Because the handlers are ordinary subscribers, they keep receiving runtime events afterward too — equivalent to calling api.slothlet.lifecycle.on(event, fn) for each, but early enough to catch initialization diagnostics.
Opt-in collectLifecycleHooks for nested teardown (#176)
A new collectLifecycleHooks config option (default false). When enabled, api.shutdown() and api.destroy() additionally discover and invoke nested shutdown / destroy functions found anywhere in the API tree (deepest-first) before the root-level hook and internal teardown. The tree is walked lazily at call time, so lazy-mode unmaterialized subtrees contribute nothing and runtime api.slothlet.api.add() / remove() / reload() need no extra invalidation. Off by default; nested hooks remain directly callable regardless of the option.
🐛 Bug Fixes
Nested shutdown / destroy leaves silently dropped (#176)
___adoptImplChildren's root-only builtinKeys skip — meant to protect the true root's shutdown / destroy builtins — only ever ran on nested wrappers (the root is always a plain object, never a UnifiedWrapper), so it deleted legitimately-nested shutdown / destroy leaves while never protecting the root it targeted. This also produced an ownKeys/get inconsistency where Object.keys(api.state) listed shutdown but typeof api.state.shutdown was undefined. The skip is removed entirely; nested shutdown / destroy leaves now resolve as real callables in both eager and lazy modes.
impl config validation rejects non-plain-object lifecycle (#148)
The lifecycle option is normalized and validated up front: undefined / null is a no-op, and a non-plain-object (array, class instance, primitive) throws rather than being silently accepted, so a mistyped lifecycle fails loudly at construction.
Owner-value validation and within-scope collision (#183)
scope()'s owners option only checked the value was a non-null object; the owner names were never validated, so a non-string or empty-string owner could slip through and cause incorrect matching later. Owner values are now rejected up front unless non-empty strings. buildContextOwners also compared each claim against the parent snapshot only, so a key named by both protect and owners in the same scope() call was claimed twice with protect silently losing; claims are now checked against the in-progress map so a within-call double-claim is rejected with CONTEXT_KEY_OWNED.
Clearer scope validation errors (#183)
SCOPE_INVALID_PROTECT / SCOPE_INVALID_OWNERS now report a descriptive shape (e.g. "array" or "array with non-string entry (…)") instead of a bare typeof, so the two most common invalid inputs are actionable rather than both reading "object".
🧪 Tests
- #183 — permission matrix tests for fail-closed enforcement,
constructTrapparity (denied / allowed / host-exempt construction), class-instance method enforcement, the control-surface seal (sealed-method rejection, teardown-still-works, seal-survives-reload), and owner-locked / protected context keys (protect write-once, owner-bound writes, cross-scope re-own rejection, null-prototype key safety). Fixtures added for a proxiedWidgetclass and an owner-managed context leaf. - #148 — impl-diagnostic-event coverage:
impl:warningfor a dropped root default and a reserved-property shadow,impl:errorfor a rejected hot-reload merge, additive delivery undersilent: true, construction-timelifecyclesubscribers catching init-time events, and invalid-lifecycle-config rejection (including a class instance in place of a plain object). - #176 — new
api_test_lifecycle_hooksfixtures;describe.each(["eager","lazy"])asserting nested leaves resolve as callables regardless of the option, option-on nested-then-root ordering with no double-invoke, option-off root-only auto-fire, and idempotent double-destroy(). - Full coverage gate green across node and browser arms.
📚 Documentation
- NEW: docs/changelog/v3/v3.12.0.md — this changelog.
- docs/LIFECYCLE.md — the
impl:warning/impl:errorevents (payload, additive-under-silentsemantics) and the construction-timelifecycleconfig option. - docs/CONFIGURATION.md —
lifecycle,collectLifecycleHooks, and thepermissions.failOpenOnAbsentCalleropt-out. - docs/PERMISSIONS.md — fail-closed enforcement, inter-module construction / class-instance enforcement, and the
permissions.control.seal()API. - docs/CONTEXT-PROPAGATION.md — owner-locked / write-protected context keys (
scope({ protect, owners })). - README — refreshed What's New.
🔧 Tooling
analyzecaches source file contents across detector passes (#164).tools/dev/analyze-errors.mjsread every source file once per detector; it now reads each file a single time and shares the cached contents across all detector passes, cutting redundant I/O on the audit with no change to what it reports.
Dependency updates
@types/node25.9.3 → 26.1.0 (#189)eslint10.4.1 → 10.6.0 (#178)prettier3.8.4 → 3.9.4 (#177)- the
@vitestfamily 4.1.9 → 4.1.10 (#185) @eslint/markdown8.0.2 → 8.0.3 (#188)@eslint/json2.0.0 → 2.0.1 (#187)
Dependabot bumps had been updating only the lockfile within existing caret ranges, so package.json had drifted behind the resolved versions. The declared devDependencies floors are re-synced to what the lockfile actually resolves and tests (#198) — manifest hygiene, no resolved-version change (npm ci installs the identical versions).
Upgrade notes
- Permissions now fail closed on absent caller identity. If you run the permission system and depend on the old fail-open behavior for calls/reads with no resolvable caller, set
permissions.failOpenOnAbsentCaller: trueto restore it. This is a security fix — the previous behavior let a call with a missing or forged caller identity slip past enforcement — so prefer leaving it fail-closed and only opt out if a concrete flow breaks. - Inter-module construction and class-instance methods are now permission-checked.
new self.x.Foo()and methods on a class instance a module returns are enforced as the calling / creating module, where before they were exempt. UnderdefaultPolicy: "allow"nothing changes; under a deny-based policy, add the rules these paths need. Host-initiated construction stays exempt. @cldmv/slothlet/handlers/*and/factories/*are no longer importable. These were undocumented engine internals; importing them now throwsERR_PACKAGE_PATH_NOT_EXPORTED. There is no supported public replacement — use the composedapisurface. If you reached a live instance through./handlers/*, that path was the leak this release closes.impl:warning/impl:errorfire regardless ofsilent.silentnow suppresses only slothlet's ownconsole.warn; it never gated event delivery, and these new additive events make that explicit. If you subscribe (vialifecycleorlifecycle.on), expect to receive diagnostics even undersilent: true.