v3.18.0
release: v3.18.0 - add host-only event.resolveLevel(subscriberPath, event)
Slothlet v3.18.0 Changelog
Release Date: September 2026
Release Type: Minor
Branch: release/3.18.0
Overview
Version 3.18.0 adds two small, additive, host/self primitives that together let a trusted layer forward an instance's events across a boundary while keeping slothlet itself boundary-agnostic:
api.slothlet.event.resolveLevel(subscriberPath, event)(#429) — a host-only query answering the delivery level (deny/notify/allow) a supplied subscriber identity would be granted for an event, without subscribing. The inverse ofon, which derives the subscriber from the live caller and never trusts a supplied one.api.slothlet.caller()(#431) — the dotted api path of the caller currently in context (the executing module's own identity), ornullfor the host. Ungated: a module only ever learns its own identity through it.
They are two halves of the same story. A forwarding layer that carries an instance's events to a subscriber living in another instance (a Web Worker, worker_threads, process, or WebSocket seam — the domain of @cldmv/slothlet-vine) must enforce that remote subscriber's level on the trusted (serving) side and strip the domain payload before it crosses, so a notify-level far subscriber never receives a payload the untrusted side could not be trusted to withhold. caller() gives the grow side the subscriber's real identity to forward; resolveLevel lets the serving side resolve that identity's level and decide what crosses. Because caller() reports the same identity on captures at subscribe, resolveLevel(caller(), event) agrees with the level an actual subscription at that identity would be granted.
Before this release the serving instance offered no way to resolve an arbitrary subscriber identity's level, and the grow instance offered no way to read a subscribing module's real identity: the composed api exposes no handlers, on never accepts a caller-supplied identity, and no accessor surfaced the current caller's path. These two primitives close both gaps.
The two enablers are purely additive — a project that calls neither sees nothing new. This release also fixes a public-event payload regression (#433, see Bug Fixes): the impl:created / impl:changed events regain a stable impl callable field and drop the internal wrapper handle from the public payload — the one behavior change consumers of those events should note (see Upgrade notes).
✨ Features
Host-only event.resolveLevel(subscriberPath, event) (#429)
api.slothlet.event.resolveLevel(subscriberPath, event) returns the level — "deny" / "notify" / "allow" — that the identity subscriberPath would be granted for event under the current event-rule pool and runtime context, without registering a subscription. It resolves through the same event-rule resolution the live subscribe path uses (layered, most-specific-wins precedence; conditional rules re-resolved against the current context), so a resolved level always agrees with what an actual subscription at that identity would be granted.
- Host-only. Because it answers for a caller-supplied identity — the inverse of
on, which trusts only the live caller — it is reachable solely from the composing host (or arun()/scope()descended from the host root), gated by the built-inslothlet.event.**deny exactly asevent.rules.add/rules.removeare. A module caller is refused withPERMISSION_DENIED, never answered; a supplied identity is never a way for a module to learn or act on policy it could not otherwise reach. - Pure. It registers nothing and mutates no state — it is a read of the rule pool. A
nullsubscriberPathdenotes a host subscription and always resolvesallow, mirroring the live host-subscription short-circuit. A non-emptyeventstring and astring-or-nullsubscriberPathare required; anything else throwsINVALID_ARGUMENT. - The trusted-side primitive for forwarding. A boundary layer subscribes at the host level (full payload) and, per far subscriber, calls
resolveLevelto decide what crosses:allow→ forward{ event, payload, meta };notify→ forward{ event, meta }only (the payload never leaves the serving instance);deny→ do not forward at all. Enforcement stays on the trusted side and the boundary layer never re-implements the policy.
See the new "Resolving a level without subscribing" section in docs/EVENTS.md.
api.slothlet.caller() — the current caller's ambient api path (#431)
api.slothlet.caller() returns the dotted api path of the caller currently in context — the identity in effect for the running code — or null for the host (no module caller). It reports the executing module's own path: the same value slothlet attributes calls, hooks, and event subscriptions to, not the identity of whoever called that module.
- Ungated. A module reading its own identity is not privileged information about anyone else, so unlike
resolveLevelit is open — a module may callself.slothlet.caller()to learn its own path, and only its own. It returnsnullat the host or for a call made outside any module extent. - Consistent with
on. It captures identity the same wayevent.ondoes, soevent.resolveLevel(caller(), event)on a trusted instance resolves to the level an actual subscription at that identity would be granted — the property that makes trusted-side enforcement of a forwarded subscription exact. - The grow-side counterpart of
resolveLevel. A cross-boundary forwarding layer readscaller()on the subscribing (grow) instance to attribute a far subscription to the subscribing module's real identity, rather than trusting a caller-supplied one, then sends that identity to the trusted (serving) side to resolve. The two primitives together are what let per-module trust cross a boundary safely.
🐛 Bug Fixes
Public impl:created / impl:changed carry the callable on impl again; the internal wrapper handle leaves the public event (#433)
Removing the raw unwrapped impl from the public impl:created / impl:changed events (#398) was correct, but between 3.16.2 and 3.17.0 the wrapped callable was reachable only via the reserved internal wrapper.__impl handle — a public subscriber reading the pre-3.17 e.impl field silently got undefined (breaking, for example, @cldmv/rummage's ext-host and view composer, which capture activate / deactivate from it). The public events now carry the wrapped callable on a stable public impl field — read as data.impl, never the raw impl and never __wrapperRef — and wrapper is dropped from the public event entirely: it was redundant with impl and re-exposed an internal name. impl is null before a lazy wrapper materializes. The internal event still carries wrapper for the framework's own ownership / metadata / routine subscribers (reached via subscribeInternal), so their behavior is unchanged — and removing the public wrapper eliminates the payload "wrapper leakage" attack surface at the source. See Upgrade notes.
📚 Documentation
- NEW: docs/changelog/v3/v3.18.0.md — this changelog.
- docs/EVENTS.md — added the "Resolving a level without subscribing" section (the
resolveLevelrow + host-only query and its cross-boundary use case, #429) and, within it, how the grow side supplies the subscriber's identity viaapi.slothlet.caller()(#431). - docs/LIFECYCLE.md — the
impl:created/impl:changedpayload now documents the stable publicimplcallable field and the removal of the publicwrapperhandle (#433).
🔧 Dependencies
No dependency updates.
Upgrade notes
A drop-in for v3.17.0 for code that does not subscribe to impl:created / impl:changed. The two enablers (event.resolveLevel, caller()) are additive — adopt them only if you are building trusted infrastructure that must resolve another identity's event level or read a subscriber's own identity (for example, forwarding events across a boundary).
One payload change to note (#433). The public impl:created / impl:changed events regain a stable impl field carrying the wrapped callable, and the internal wrapper handle is removed from the public payload. If you read the leaf's callable from these events:
- read
data.impl— the pre-3.17 field, restored, now carrying the wrapped callable (never the raw impl); - a consumer that adopted
data.wrapper.__implon 3.16.x–3.17.0 must switch todata.impl(there is no publicwrapperany more); data.implisnullbefore a lazy wrapper materializes — the callable then arrives on the subsequentimpl:changed.
Everything else is unaffected.
| Metric | Coverage |
|---|---|
| Statements | 100.0% |
| Branches | 100.0% |
| Functions | 100.0% |
| Lines | 100.0% |
Avg: 100.0% · cb4caee · Node lts/*