Skip to content

v3.18.0

Choose a tag to compare

@cldmv-bot cldmv-bot released this 21 Sep 01:54
· 7 commits to master since this release
v3.18.0
79e811a

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 of on, 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), or null for 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 a run() / scope() descended from the host root), gated by the built-in slothlet.event.** deny exactly as event.rules.add / rules.remove are. A module caller is refused with PERMISSION_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 null subscriberPath denotes a host subscription and always resolves allow, mirroring the live host-subscription short-circuit. A non-empty event string and a string-or-null subscriberPath are required; anything else throws INVALID_ARGUMENT.
  • The trusted-side primitive for forwarding. A boundary layer subscribes at the host level (full payload) and, per far subscriber, calls resolveLevel to 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 resolveLevel it is open — a module may call self.slothlet.caller() to learn its own path, and only its own. It returns null at the host or for a call made outside any module extent.
  • Consistent with on. It captures identity the same way event.on does, so event.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 reads caller() 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 resolveLevel row + host-only query and its cross-boundary use case, #429) and, within it, how the grow side supplies the subscriber's identity via api.slothlet.caller() (#431).
  • docs/LIFECYCLE.md — the impl:created / impl:changed payload now documents the stable public impl callable field and the removal of the public wrapper handle (#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.__impl on 3.16.x–3.17.0 must switch to data.impl (there is no public wrapper any more);
  • data.impl is null before a lazy wrapper materializes — the callable then arrives on the subsequent impl:changed.

Everything else is unaffected.


coverage

Metric Coverage
Statements 100.0%
Branches 100.0%
Functions 100.0%
Lines 100.0%

Avg: 100.0% · cb4caee · Node lts/*

👥 Contributors