Repository navigation
v3.19.0
release: v3.19.0 - add impl:collision lifecycle event for collision…
Slothlet v3.19.0 Changelog
Release Date: September 2026
Release Type: Minor
Branch: release/3.19.0
Overview
Version 3.19.0 adds the impl:collision lifecycle event — an observable signal for a case the existing impl:created event structurally cannot report: when composing a module into a namespace another module already contributed to resolves a collision, impl:created fires only for the path winner, and for a leaf folded into a self-named namespace node it never fires for the dropped leaf at all. impl:collision fires for the collision itself and carries both writers plus how it resolved, so a consumer can observe a silently dropped, shadowed, or merged contribution regardless of nesting. It is purely additive — a project that never subscribes sees nothing new.
The release also carries two fixes to an ES Proxy invariant violation reachable through the composed wrapper. Reading certain properties off a wrapped node via Object.getOwnPropertyDescriptor — the per-entity routine selector .for / .contributors (#443), and, more generally, any non-configurable own property surfaced through the wrapper (#446) — threw a getOwnPropertyDescriptor invariant TypeError. Both are drop-in fixes; no api changed.
✨ Features
impl:collision lifecycle event (#441)
api.slothlet.lifecycle.on("impl:collision", handler) fires when composing a module into the api resolves a collision at a path another module already contributed to — during initial slothlet() startup or via api.slothlet.api.add(). Where impl:created fires post-placement for the path winner only (so a leaf a merge discards is never announced), impl:collision fires for the collision itself and carries both writers, so a consumer can observe a dropped or shadowed leaf regardless of nesting — including a member folded into a self-named namespace node, which emits no impl:created for the loser at all.
- Payload:
{ apiPath, resolution, incoming, owner, kind, collisionMode }.incoming/ownerare role-neutral — the arriving writer versus the prior holder — andresolutionis one of:dropped— the incoming leaf was discarded (a merge value-leaf loss, or a member folded off a self-named namespace);replaced— the incoming contribution shadows the existing owner (replace);merged— two namespace nodes were combined (informational, the shared-mount case).
- Emitted from the authoritative collision-resolution sites —
syncWrapper's merge branch andsetValueAtPath's replace branch on theapi.addpath, and the off-slot fold for the self-named-namespace case — gated to the primary api tree so theboundApimirror never double-fires. A centralizedemitImplCollisionhelper onComponentBasekeeps the emission consistent across those sites. - Fire-and-forget, like the other
impl:*events: listeners are isolated (a throwing listener never affects composition or the other listeners), and it fires synchronously at the collision-decision site, so a subscriber attached before the composing call observes every collision by the time that call resolves. - Lazy note: a collision between two materialized leaves, and a top-level namespace merge, are reported at compose time; a collision between leaves nested inside a still-lazy folder is surfaced when that folder materializes (deferred adoption), not at the enclosing
api.add(). ThestackRoutinesco-execution case (both contributions run rather than one dropping) is an orthogonal execution concern reported through the routine system — documented, not emitted here.
See the new impl:collision section in docs/LIFECYCLE.md.
🐛 Bug Fixes
Routine selector .for / .contributors no longer throw a Proxy invariant when read through the composed wrapper (#443)
Reading the per-entity routine selector — node.<routine>.for(key), .contributors, or one of the __slothletRoutine* markers — off a routine slot through the composed unified-wrapper proxy threw:
TypeError: 'getOwnPropertyDescriptor' on proxy: trap reported non-configurability for property 'for' which is either non-existent or configurable in the proxy target
#buildStackedCallable / #buildCascadeCallable defined those properties with a bare Object.defineProperty, defaulting them to non-configurable. The wrapper's getOwnPropertyDescriptor trap relays the impl's descriptor, so it reported a non-configurable property the proxy target does not carry as own+non-configurable — which the engine rejects. A plain .for get slipped past (so a compose-time typeof node.<routine>?.for check passed), but any getOwnPropertyDescriptor read of the slot threw once the tree re-materialized into its proxied shape — which is what a consumer scoping per-entity routines via node.<routine>.for(moduleID) hit on the deferred call. The seven descriptors are now configurable: true; this imposes no mutation risk (the slot is a managed, re-derived surface behind the proxy — a delete is absorbed, the property is non-writable, and the selector still routes), and through the proxy the trap must report the property as configurable regardless. Fixed in #445.
The wrapper getOwnPropertyDescriptor trap never reports a non-configurable descriptor for a virtual property (#446)
The general form of #443. The unified-wrapper getOwnPropertyDescriptor trap relayed the impl's own descriptor verbatim, so any non-configurable own property surfaced only through the wrapper — not just routine selectors, but for example a function assigned via wrap-on-set that carries a non-configurable own property — tripped the same invariant when its descriptor was read. The trap now runs any descriptor sourced from the wrapper or the impl through a guard that coerces configurable: true unless the actual proxy target carries that property as an own, non-configurable property — the only invariant-legal shape for a virtualized property — so the trap can no longer emit an invariant-violating descriptor. Descriptors the target genuinely backs (including a function's prototype) are reported faithfully. Fixed in #447; complements #443's source fix, which keeps the routine selector descriptors configurable at their definition.
📚 Documentation
- NEW: docs/changelog/v3/v3.19.0.md — this changelog.
- docs/LIFECYCLE.md — added the
impl:collisionsection: when it fires versusimpl:created, the{ apiPath, resolution, incoming, owner, kind, collisionMode }payload, thedropped/replaced/mergedresolutions, and the lazy-materialization andstackRoutinesnotes (#441).
🔧 Dependencies
No dependency updates.
Upgrade notes
A drop-in for v3.18.x. The impl:collision event is additive — subscribe to it only if you need to observe collisions that impl:created cannot surface (a dropped, shadowed, or merged contribution); a project that never subscribes is unaffected.
The two Proxy-invariant fixes (#443, #446) change no api and no signatures — they turn a getOwnPropertyDescriptor TypeError into a correct descriptor read. If a consumer shipped a downstream workaround that resolved node.<routine>.for(key) in a try and fell back to a direct routine call, that workaround is no longer needed and can be removed.
| Metric | Coverage |
|---|---|
| Statements | 99.9% |
| Branches | 99.9% |
| Functions | 100.0% |
| Lines | 100.0% |
Avg: 99.9% · 7ae97a6 · Node lts/*