Repository navigation
v3.17.0
release: v3.17.0 - add cascade:false + per-contributor…
Slothlet v3.17.0 Changelog
Release Date: September 2026
Release Type: Minor
Branch: release/3.17.0
Overview
Version 3.17.0 adds an instance-wide event system (#407): named, permission-gated publish/subscribe scoped to one composed api instance, sitting alongside hook (call interception) and lifecycle (framework events) as the third member of the family — where those carry framework signals, event carries arbitrary application/domain events. It ships with a three-level delivery model (deny/notify/allow) governed by an event-rule pool whose layered, most-specific-wins resolution mirrors the permission system, and the permission and hook rule systems were refactored onto that same layered precedence so all three resolve rules identically.
It also adds per-entity lifecycle routines (#400) — cascade: false plus .for(key) and .contributors on a stacked routine — for the case where several first-class packages co-own one namespace and the host must invoke exactly one contributor rather than a run-all cascade; a scoped-exclusion extglob (!(a|b)) in the glob engine (#360); and a policy correction that makes api.add()'s init-time collision options warn-and-ignore instead of silently strip (#380).
Four runtime bug fixes are included. Context cloning now preserves a function carried in a run()/scope() context instead of flattening it to {} (#408), and three api-generation edge cases are corrected: object default exports honor collisionMode for named-export conflicts (#421), all-caps names containing a hyphen sanitize to a valid identifier (#422), and the browser manifest loader honors hidden / apiDepth / scanHiddenFolders so a browser build produces the same api surface as the filesystem scan (#423). No public API is removed, and the new event system, per-entity routines, and scoped-exclusion glob are all additive — a project that does not use them sees no behavior change. This release also lands a large expansion and reconciliation of the API-RULES generation-rules catalog against the code (#424). See Upgrade notes for the behavior corrections (#380, #408, #421, #422, #423).
🐛 Bug Fixes
run() / scope() context clone is function-tolerant (#408)
run(context, fn) and scope(context, fn) deep-clone the supplied context so a callee cannot mutate the caller's object. The clone walked plain data correctly but treated a function value as an ordinary object — recursing its (usually empty) own-enumerable properties and producing {}, so any callable placed on a context (a factory, a callback, a bound service handle) silently became an empty object by the time the callee read it. deepClone now returns a function value by reference (typeof obj === "function" → return obj) rather than cloning it, so the callable arrives intact.
Sharing the callable by reference — rather than attempting to clone it — is deliberate: a function has no serializable own-state to isolate, and the data-isolation guarantee that motivates the clone is unaffected (every non-function value on the context is still deep-copied, so a callee still cannot mutate the caller's data). The alternative of stripping functions entirely was rejected because it silently drops a value the caller explicitly placed on the context; the alternative of a full structural clone is not possible for a closure. Callables on a context are therefore shared, exactly as they would be if passed as a normal argument.
Object default exports honor collisionMode for named-export conflicts (#421)
When a module's default export is an object and its named exports include a key that already exists on that object, the two default shapes diverged: a function default consulted collisionMode for such same-name conflicts, but an object default silently kept its own key regardless of the configured mode. The object-default branch of processModuleForAPI now resolves the conflict the same way as the function-default branch — merge/skip keep the existing property from the default object, error throws COLLISION_DEFAULT_EXPORT_ERROR, warn overwrites and emits WARNING_COLLISION_DEFAULT_EXPORT_OVERWRITE, and replace/merge-replace overwrite — so both default shapes behave consistently. The default mode (merge) is unchanged.
All-caps names containing a hyphen sanitize to a valid identifier (#422)
sanitizePropertyName with preserveAllUpper returned an all-caps name verbatim, including one containing a hyphen — e.g. FOO-BAR — producing an invalid JavaScript identifier, because the all-uppercase early-return lacked the hyphen exclusion the all-lowercase branch already had. Such names now fall through to segment processing (FOO-BAR → FOOBAR), keeping the uppercase while dropping the separator, so an all-caps name with a hyphen yields a usable leaf name instead of an unaddressable one.
Browser manifest loader honors hidden, apiDepth, and scanHiddenFolders (#423)
In browser (manifest) mode, #manifestNodeToStructure applied only the file filter and a __-prefix skip, so the consumer hidden glob, the apiDepth / maxDepth limit, the dot-prefix skip, and the scanHiddenFolders opt-out were silently ignored, and empty folders were not pruned — a shape divergence from the filesystem scan. The manifest walk now mirrors the disk gating: the hidden glob is matched against the api-relative path (a dotted apiPrefix is threaded through the recursion, since browser mode has no node:path), dot/__-prefixed entries are skipped, the walk truncates at apiDepth, and empty subtrees are pruned — so a browser build and a Node build produce the same api surface for the same inputs.
✨ Features
Instance-wide event system (#407)
A new event family, reachable at api.slothlet.event (and self.slothlet.event inside modules), gives one composed api instance named publish/subscribe. Slothlet stays boundary-agnostic — it knows nothing about processes, browsers, or trust — and enforces a single local policy: a per-subscriber delivery level, resolved from the subscriber's own identity using the same model the rest of the framework uses. See docs/EVENTS.md.
- Surface:
on(event, listener, { once? })andonce(event, listener)return{ level, off }— the granted delivery level plus an unsubscribe — so a subscriber knows up front whether it will receive payloads instead of silently getting nothing;off(event, listener)removes by reference;emit(event, payload?)publishes. A listener is always called(payload, meta)wheremetais the trigger envelope{ event, at, instanceID }. - Three delivery levels:
deny(subscription refused; the listener is never registered),notify(subscribed, delivered only the envelope — no domain payload; this is the default, so subscription is open and payload is opt-in), andallow(subscribed with the full payload). A host subscription — made with no module caller in context — is trusted like a host-initiated call and always resolvesallow. - Emit is never gated. The policy is enforced per subscriber, not on the emit side, so a module emitting an event can never be blocked from announcing it — gating emission would break the fire-and-forget contract.
emitis fire-and-forget with per-listener error isolation (one throwing listener never stops the others or the emitter) and returns a promise that settles once every listener has, so a caller mayawaitit when ordering matters. Each listener runs pinned to its subscriber's own extent and identity via the context manager, exactly like any other call. - Event-rule pool — a construct distinct from the binary
allow/denycall rules, declared underpermissions.eventsas{ default, rules }. Each rule is{ caller, event, effect, condition? }:calleris a glob matched against the subscriber's api path,eventa glob matched against the event name,effectone ofdeny/notify/allow, andconditionthe same optional condition shape permission rules accept (a subscriber's level is re-resolved per emit when any matching rule is conditional). - Layered, most-specific-wins precedence — the base level is the built-in default
notify; a matching rule overrides it, and when two rules are equally specific the higher layer wins. Layers rank built-in < manifest < instance < runtime: a module's manifest may open or close events for its own leaves, an instance-initpermissions.eventsconfig overrides the manifest, and a gated runtime rule overrides the instance — the consumer can lock everything down and let individual modules declare the narrow events they need open. Within a single layer, the last-registered rule wins. - Manifest-declared rules — a module manifest can declare event rules for its own leaves, wired into the same call + event enforcement path, so a module ships its intended event posture with itself rather than requiring the host to configure it centrally.
- Runtime rule mutation —
api.slothlet.event.rules.add(rule) → ruleIdandrules.remove(ruleId)mutate the pool at runtime. They are host-only and gated byapi.mutations.events(default off): there is no free runtime override, matching the permission system's gated-runtime model. A registry (epoch-versioned cache) keeps resolution off the hot path — a subscriber's level is computed once and reused until a rule change bumps the epoch.
The permission and hook rule systems were refactored onto the same layer-aware, equal-specificity tiebreak (built-in < manifest < instance < runtime, most-specific-wins, last-registered within a layer), so all three rule systems now resolve identically rather than each carrying its own ad-hoc precedence. See docs/PERMISSIONS.md.
Per-entity lifecycle routines: cascade: false, .for(key), and .contributors (#400)
The routine root cascade is a run-all: api.<name>() fires every matching contribution. That is exactly wrong for a per-entity lifecycle — a namespace co-owned by several first-class packages, each contributing its own function at the same leaf, where the host must invoke exactly one contributor (the one belonging to the entity being activated), never all of them. Three additions cover that case, all on top of the existing routines system (no new config surface beyond the cascade key):
cascade: falseon a routine entry (defaulttrue, and the only value the string shorthands can produce) suppresses the rootapi.<name>()run-all cascade entirely — use it when a run-all would be a bug.api.<path>.<name>.for(key)invokes the single contributor whose moduleID iskey, run in that contributor's own extent + identity (ambientself.*and permission checks resolve against that contributor) with the call's arguments passed straight through. It deliberately bypasses thestackRoutinesowner-filter — selecting a specific co-owner is the whole point, so a contributor that lost the shared-path collision still runs when addressed by key. An unknownkeythrowsINVALID_ARGUMENT. The selector precedes the invocation:activate.for("B")(args), neveractivate(args).for("B").api.<path>.<name>.contributorslists the moduleIDs present at that stacked path, in registration order, so a host can discover which co-owners it may address (symmetric withversioning.list(path)).
See the "Per-entity routines" section in docs/LIFECYCLE.md.
Scoped-exclusion extglob !(a|b) in the glob engine (#360)
compilePattern() — the shared glob engine behind HookManager, PermissionManager, the event-rule pool, and root-anchored routine names — now supports a scoped exclusion extglob: a path segment !(a|b) matches any single segment that is not one of the alternatives, compiling to a negative-lookahead segment matcher ((?!(?:a|b)(?:\.|$))[^.]+). This is distinct from a leading ! negation of a whole pattern; the two are disambiguated by position — isNegation is now "starts with ! but not !(", so a pattern beginning with !( is read as a scoped-exclusion segment, not a negated pattern. Lets a rule target "every child except these" in one segment (e.g. admin.!(root|system).*) instead of enumerating an allow-list.
api.add() collision options warn-and-ignore instead of silently stripping (#380)
collisionMode, mutateExisting, and recordHistory are init-time / internal policy, not per-call knobs — passing them to a runtime api.add() never took effect. Previously they were silently stripped, so a caller who passed { collisionMode: "replace" } expecting it to matter got no signal that it was ignored. api.add() now warns (WARNING_API_ADD_OPTION_LOCKED) and ignores the option — but the add still succeeds. A throw was deliberately rejected here: these options were already inert, so throwing on them would newly break a live application that had been passing them harmlessly, converting a silent no-op into a hard failure at runtime.
Opt in to honoring them with api: { mutations: { allowCollisionOverride: true } } — then the same options are applied with no warning. silent: true suppresses the warning (still ignored, still succeeds). forceOverwrite remains the always-available, targeted escape hatch regardless of the flag. See the api.mutations table in docs/CONFIGURATION.md.
🔧 CI & tooling
Pre-commit lint/format hook wired into the repo (#417)
Completes the CLDMV v4 onboarding by installing the shared pre-commit hook: .githooks/{pre-commit,install.mjs} copied from the org template, wired via "prepare": "node -e \"import('./.githooks/install.mjs').catch(()=>{})\"" (the crash-tolerant form — a bare invocation aborts npm publish against the packed tree). The hook runs lint + format:check (check-only, never writes) and rejects a commit on any issue, composing with the global commit-policy dispatcher rather than shadowing it. This is the one piece of the v4 system slothlet was missing; the audit confirmed the rest (workflows, dependabot, .configs, .prettierignore, test naming) was already in place.
CI inherits max_node_major from the reusable default (#410)
The CI matrix stopped pinning max_node_major: 24 and now inherits the CLDMV/.github reusable workflow's own default, so the matrix's upper Node bound tracks the org standard instead of a per-repo pin that drifts.
📚 Documentation
- NEW: docs/changelog/v3/v3.17.0.md — this changelog.
- NEW: docs/EVENTS.md — the event system: API surface, delivery levels, the event-rule construct, layered precedence, manifest declaration, runtime rule mutation, and listener identity + error isolation (#407).
- docs/PERMISSIONS.md — the event-rule section and the layer-aware equal-specificity tiebreak now shared across the permission, hook, and event rule systems (#407).
- docs/CONFIGURATION.md —
permissions.eventsnormalization,api.mutations.events, and theapi.mutations.allowCollisionOverrideentry in theapi.mutationstable (#407, #380). - docs/LIFECYCLE.md — the "Per-entity routines" section:
cascade,.for(key), and.contributors(#400). - docs/CONTEXT-PROPAGATION.md — the function-tolerant clone invariant for
run()/scope()context (#408). - docs/METADATA.md — corrected the
caller()mechanism description: caller identity comes from the context manager's active extent, not from aprepareStackTracestack walk. The doc had described a mechanism the framework does not use (#403). - Fenced-code-language fix in EVENTS.md so plain-text blocks pass the
markdown/fenced-code-languagelint gate (#416). - EXPANDED: docs/API-RULES.md and docs/API-RULES/ — a full-codebase audit of the decisions that shape how the api is generated, cataloged and reconciled against the code. The flatten conditions were completed and renumbered contiguous (C01–C25); seven new condition families were added — discovery/inclusion (G), naming (N), collision/ownership (O), mutation (M), versioning (V), routines (T), and built-in (B); the rule set was reconciled and extended (R1–R13 names, bodies, and citations corrected; Rules 14–21 added); the rule↔condition MAPPING was rebuilt for all 21 rules; and the coverage test now enforces the traceability invariants — every rule has at least one condition or flatten pattern, and every condition ties back to at least one rule (#424).
🔧 Dependencies
No dependency updates.
Upgrade notes
- No API breaking changes. The event system (#407), per-entity routines (#400), and scoped-exclusion glob (#360) are all additive; a project that does not use them sees no behavior change.
api.add()collision options now warn (#380). Code that passedcollisionMode/mutateExisting/recordHistoryto a runtimeapi.add()was already having those options silently ignored — the only change is that it now emits aWARNING_API_ADD_OPTION_LOCKEDwarning to make the previously-invisible no-op visible. The add still succeeds. To actually honor the options, setapi: { mutations: { allowCollisionOverride: true } }; to keep the old silent behavior, passsilent: true.forceOverwriteis unaffected.- Functions on a
run()/scope()context now survive the clone (#408). Previously a callable placed on a context was flattened to{}by the clone; it is now shared by reference and arrives intact. Data on the context is still deep-copied, so the caller's data remains isolated from the callee — only the by-reference sharing of functions is new. If any code relied on the (undocumented, defective) behavior of a context function becoming{}, that is the one case to check. - Runtime event-rule mutation is off by default.
api.slothlet.event.rules.add/rules.removeare host-only and gated byapi.mutations.events(default off) — enable it explicitly to mutate the event-rule pool at runtime. Subscribing (on/once) and emitting are always available; only rule mutation is gated. - Object default exports now honor
collisionMode(#421). A same-name conflict between an objectdefaultexport and a named export previously ignored the collision mode and always kept the default's key; it is now resolved by the configuredcollisionMode. The default mode (merge) is unchanged, so only a project that set a non-default mode and relied on an object default silently winning is affected — the fix makes the object case match the long-standing function-default behavior. - All-caps hyphenated names now sanitize (#422). Under
preserveAllUpper, a name likeFOO-BARpreviously passed through unchanged (an invalid identifier); it now sanitizes toFOOBAR. Any code addressing such a leaf by its old hyphenated spelling should use the sanitized name. - Browser manifest mode now applies
hidden/apiDepth/scanHiddenFolders(#423). A browser build previously ignored these when walking a manifest, so it could expose more of the tree (hidden entries, leaves past the depth limit) than the equivalent Node build. The browser surface now matches the filesystem scan; a browser build that inadvertently relied on hidden or too-deep leaves being present will no longer see them — the corrected, Node-consistent behavior.
| Metric | Coverage |
|---|---|
| Statements | 100.0% |
| Branches | 100.0% |
| Functions | 100.0% |
| Lines | 100.0% |
Avg: 100.0% · 9023632 · Node lts/*